Claude Code Environment Variables and Secrets in 2026: What Gets Passed to Subagents and What Does Not
Most secret leakage in Claude Code stems from misunderstanding what environment data subagents inherit. This post maps the three-layer isolation model and shows production patterns for scoping credentials per subagent without vault complexity.
Most secret leakage in Claude Code stems from misunderstanding what environment data subagents inherit. The default behavior is not "pass nothing" or "pass everything." It is a three-layer model where shell environment, MCP server credentials, and per-task scope each follow different inheritance rules. Teams that treat subagents like trusted workers leak database passwords into test fixtures and API tokens into debug logs. The failure mode here is subtle but expensive.
The problem looks like this: you set DATABASE_URL in your shell, spawn a subagent to run tests, and the subagent's test harness writes the production connection string to a snapshot file that gets committed. Or you configure an MCP server with admin credentials, delegate a refactoring task to a subagent, and the subagent's context includes the full credential set even though it only needs read access to a documentation index.
flowchart LR
A("Parent spawns subagent") --> B("Subagent inherits full shell env")
B --> C("Production DATABASE_URL in test snapshot")
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The solution is to treat subagents like untrusted workers with explicit allow-lists. You scope environment variables per subagent using process-level isolation, configure MCP servers with per-task credential sets, and audit what each subagent can actually see before you deploy. The result is that a code reviewer subagent cannot read your Stripe secret key and a test fixer cannot write to your production database.
flowchart LR
A("Parent spawns subagent") --> B("Subagent receives explicit safe env")
B --> C("Test runs with isolated test database")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This post shows the exact patterns for implementing that isolation in production TypeScript codebases.
Key Takeaways
- Subagents inherit the parent's shell environment by default unless you explicitly scope process-level environment variables.
- MCP server credentials live in a separate boundary and must be configured per-task to prevent subagents from accessing resources they do not need.
- The three-layer model (shell env, MCP credentials, per-task scope) determines what data crosses isolation boundaries.
- Production patterns require vault integration with token injection scoped to subagent lifetime, not static environment files.
- Auditing what each subagent can see requires runtime inspection tools that capture the effective environment, not just configuration files.
What Gets Passed to Subagents by Default (and What Doesn't)
The default behavior is that subagents inherit the parent's shell environment verbatim. If you set OPENAI_API_KEY in your .zshrc and spawn a subagent to run linting, the subagent's process sees that key. This is not a bug. It is how Unix process forking works. The distinction is critical.
What does NOT get passed: MCP server credentials are scoped to the server configuration, not the shell. If you register an MCP server with Anthropic's credential provider, the subagent does not automatically inherit those credentials unless you explicitly attach the server to the subagent's task context. The implication here is that you have two separate surfaces to secure.
The third layer is per-task scope. When you spawn a subagent with a specific instruction set, you can pass an environment map that overrides or supplements the inherited shell environment. This layer is where you implement isolation.
flowchart TD
A("Parent process starts") --> B("Shell env loaded")
B --> C("MCP servers registered")
C --> D("Subagent spawned")
D --> E("Inherits shell env")
D --> F("Receives per-task overrides")
E --> G("Effective environment")
F --> G
style G stroke:#7c9cf0,fill:#142544,color:#eaf2ff
Developers often assume that spawning a subagent creates a clean environment. It does not. The subagent starts with whatever the parent had and then applies task-specific overrides. If you never specify overrides, the subagent runs with full access to every secret in the parent's shell.
Environment Variable Inheritance: The Three-Layer Model
The three-layer model describes how environment data flows from configuration to runtime. Understanding this model prevents the common mistake of setting a secret in one layer and expecting isolation in another.
Layer one is the shell environment. This includes everything in .bashrc, .zshrc, exported variables, and the output of printenv. When you run claude-code from a terminal, every variable in that shell becomes part of the parent process environment. Subagents forked from the parent inherit this layer.
Layer two is MCP server configuration. Servers are registered with credential sets stored outside the shell environment. A server configured with a Notion API token does not expose that token to subagents through the shell. The token lives in the MCP registry. Subagents only access the server if you explicitly attach it to their task context.
Layer three is per-task overrides. When you spawn a subagent, you pass an environment map that can add new variables, override inherited ones, or unset variables from the shell. This layer has the highest precedence. It is where you implement principle-of-least-privilege.
flowchart TD
A("Shell environment layer") --> D("Parent process starts")
B("MCP credential registry") --> D
D --> E("Subagent spawns with task context")
C("Per-task override map") --> E
E --> F("Layer 3 overrides layer 1")
E --> G("Layer 2 only if server attached")
F --> H("Effective environment")
G --> H
style H stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The failure mode happens when teams set secrets in layer one, assume layer three will block them, and never audit what the subagent actually sees. The result is that a subagent meant to run tests in isolation connects to production because DATABASE_URL came from the shell and nothing in layer three overrode it.
Preventing Secret Leakage: Practical Patterns for Per-Subagent Scoping
The pattern that prevents leakage is to define an allow-list of environment variables per subagent type and block everything else. A code reviewer subagent gets NODE_ENV, CI, and LOG_LEVEL. It does not get DATABASE_URL, STRIPE_SECRET_KEY, or OPENAI_API_KEY. This approach inverts the default behavior from "inherit everything unless blocked" to "inherit nothing unless allowed."
Here is how to implement it in TypeScript:
type SubagentRole = 'code-reviewer' | 'test-fixer' | 'doc-generator';
const allowedEnvByRole: Record<SubagentRole, string[]> = {
'code-reviewer': ['NODE_ENV', 'CI', 'LOG_LEVEL'],
'test-fixer': ['NODE_ENV', 'CI', 'TEST_DATABASE_URL', 'LOG_LEVEL'],
'doc-generator': ['NODE_ENV', 'OUTPUT_DIR'],
};
function buildScopedEnv(role: SubagentRole): Record<string, string> {
const allowed = allowedEnvByRole[role];
const scopedEnv: Record<string, string> = {};
for (const key of allowed) {
const value = process.env[key];
if (value !== undefined) {
scopedEnv[key] = value;
}
}
// Explicitly unset dangerous variables
scopedEnv.DATABASE_URL = '';
scopedEnv.STRIPE_SECRET_KEY = '';
scopedEnv.OPENAI_API_KEY = '';
return scopedEnv;
}
async function spawnSubagent(role: SubagentRole, task: string) {
const env = buildScopedEnv(role);
// Spawn subagent with scoped environment
const result = await execSubagent({
task,
env,
inheritParentEnv: false, // Critical: do not inherit
});
return result;
}The inheritParentEnv: false flag is critical. Without it, the scoped environment gets merged with the parent's shell environment instead of replacing it. The subagent ends up with both the allow-listed variables and the blocked secrets.
The explicit unset step (DATABASE_URL = '') defends against configuration drift. If a team member adds DATABASE_URL to the allow-list for test-fixer, the unset step still prevents it from reaching the subagent until the unset line is also removed. This creates a two-gate requirement for exposing secrets.
MCP Server Credentials vs Shell Environment: Different Security Boundaries
MCP server credentials and shell environment variables are two separate attack surfaces. A subagent that cannot access your shell's OPENAI_API_KEY can still access an MCP server configured with that key if the server is attached to the subagent's task context.
The boundary difference is that shell environment flows through process inheritance. MCP credentials flow through explicit server attachment. You cannot block MCP credentials by unsetting environment variables. You block them by not attaching the server to the subagent's task.
flowchart LR
subgraph Parent["Parent process"]
A("Shell env with secrets")
B("MCP server registry")
end
subgraph Subagent["Subagent process"]
C("Inherits shell or receives overrides")
D("Server attached via task context")
end
A --> C
B --> D
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The practical implication is that you need two separate scoping mechanisms. For shell secrets, use the allow-list pattern from the previous section. For MCP servers, use per-task server attachment with credential rotation.
Here is how to scope MCP server access per subagent:
type MCPServerConfig = {
serverId: string;
capabilities: string[];
credentialTTL: number; // seconds
};
const serverConfigByRole: Record<SubagentRole, MCPServerConfig[]> = {
'code-reviewer': [
{ serverId: 'github-read-only', capabilities: ['read'], credentialTTL: 600 },
],
'test-fixer': [
{ serverId: 'test-db', capabilities: ['read', 'write'], credentialTTL: 300 },
],
'doc-generator': [
{ serverId: 'notion-docs', capabilities: ['read'], credentialTTL: 1200 },
],
};
async function attachServersToSubagent(
role: SubagentRole,
subagentId: string
): Promise<void> {
const configs = serverConfigByRole[role];
for (const config of configs) {
// Generate short-lived token scoped to subagent
const token = await generateScopedToken({
serverId: config.serverId,
subagentId,
capabilities: config.capabilities,
ttl: config.credentialTTL,
});
await attachServer(subagentId, config.serverId, token);
}
}The credentialTTL field ensures that even if a subagent leaks a token, the token expires within minutes. The capabilities array restricts what the subagent can do even if it has a valid token. A code-reviewer subagent with a read-only GitHub token cannot push commits.
Production Pattern: Vault Integration with Subagent-Scoped Token Injection
The production pattern for managing secrets across subagents is to integrate with a vault (HashiCorp Vault, AWS Secrets Manager, or Google Secret Manager) and inject short-lived tokens into the subagent's environment at spawn time. This approach eliminates static secrets in configuration files and ensures that each subagent gets exactly the credentials it needs for its lifetime.
The flow works like this: when you spawn a subagent, the orchestration layer requests a token from the vault scoped to the subagent's role and lifetime. The vault returns a token that expires in 5-10 minutes. The orchestration layer injects that token into the subagent's environment as a new variable (SCOPED_DB_TOKEN instead of DATABASE_URL). The subagent uses the scoped token for its work. When the subagent terminates, the token is already expired or close to it.
flowchart LR
A("Orchestrator spawns subagent") --> B("Request scoped token from vault")
B --> C("Vault generates 5-min token")
C --> D("Inject token into subagent env")
D --> E("Subagent uses scoped credentials")
E --> F("Token expires after task completes")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Here is how to implement vault-scoped token injection in TypeScript:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
type VaultConfig = {
secretName: string;
scopeToSubagent: boolean;
ttl: number;
};
const vaultConfigByRole: Record<SubagentRole, VaultConfig[]> = {
'test-fixer': [
{ secretName: 'test-db-credentials', scopeToSubagent: true, ttl: 300 },
],
'code-reviewer': [],
'doc-generator': [
{ secretName: 'notion-api-token', scopeToSubagent: true, ttl: 600 },
],
};
async function injectVaultSecrets(
role: SubagentRole,
subagentId: string,
env: Record<string, string>
): Promise<Record<string, string>> {
const configs = vaultConfigByRole[role];
const client = new SecretsManagerClient({ region: 'us-east-1' });
for (const config of configs) {
if (!config.scopeToSubagent) {
continue; // Skip non-scoped secrets
}
const command = new GetSecretValueCommand({
SecretId: config.secretName,
VersionStage: 'AWSCURRENT',
});
const response = await client.send(command);
const secretValue = response.SecretString;
if (!secretValue) {
throw new Error(`Secret ${config.secretName} returned empty value`);
}
// Parse secret and inject into env
const parsed = JSON.parse(secretValue);
const envKey = `SCOPED_${config.secretName.toUpperCase().replace(/-/g, '_')}`;
env[envKey] = parsed.token || parsed.value;
// Schedule token revocation after TTL
setTimeout(async () => {
await revokeToken(config.secretName, subagentId);
}, config.ttl * 1000);
}
return env;
}
async function spawnSubagentWithVault(role: SubagentRole, task: string) {
const subagentId = generateSubagentId();
let env = buildScopedEnv(role);
// Inject vault secrets
env = await injectVaultSecrets(role, subagentId, env);
const result = await execSubagent({
task,
env,
inheritParentEnv: false,
});
return result;
}The SCOPED_ prefix in the environment variable name makes it clear that this is a short-lived credential, not a long-lived secret. The setTimeout call schedules token revocation, but the real protection is the vault's TTL enforcement. Even if the setTimeout fails, the token expires.
This matters because static secrets in .env files or shell environments never expire. A leaked static secret stays valid until someone manually rotates it. A vault-scoped token leaks for 5 minutes and then becomes useless.
Testing Your Isolation: How to Audit What Each Subagent Can Actually See
The only way to know what a subagent can see is to audit it at runtime. Configuration files and allow-lists tell you what should happen. Runtime inspection tells you what does happen. The gap between the two is where secrets leak.
The pattern for auditing is to inject a diagnostic task into each subagent role during development that logs the effective environment and MCP server attachments. You run this diagnostic in a staging environment, capture the output, and verify that no unexpected secrets appear.
flowchart LR
A("Spawn subagent in staging") --> B("Inject diagnostic task")
B --> C("Subagent logs effective env")
C --> D("Audit logs for unexpected secrets")
D --> E("Verify isolation boundaries")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Here is how to implement runtime auditing in TypeScript:
type AuditReport = {
subagentId: string;
role: SubagentRole;
effectiveEnv: Record<string, string>;
attachedServers: string[];
unexpectedSecrets: string[];
};
const dangerousEnvKeys = [
'DATABASE_URL',
'STRIPE_SECRET_KEY',
'OPENAI_API_KEY',
'AWS_SECRET_ACCESS_KEY',
];
async function auditSubagentIsolation(
role: SubagentRole
): Promise<AuditReport> {
const subagentId = generateSubagentId();
let env = buildScopedEnv(role);
env = await injectVaultSecrets(role, subagentId, env);
// Spawn subagent with diagnostic task
const result = await execSubagent({
task: 'Print effective environment and attached servers',
env,
inheritParentEnv: false,
captureEnv: true,
});
const effectiveEnv = result.capturedEnv;
const attachedServers = result.attachedServers;
// Check for dangerous keys
const unexpectedSecrets = dangerousEnvKeys.filter(
(key) => effectiveEnv[key] && effectiveEnv[key] !== ''
);
const report: AuditReport = {
subagentId,
role,
effectiveEnv,
attachedServers,
unexpectedSecrets,
};
if (unexpectedSecrets.length > 0) {
console.error(`Isolation violation in ${role}:`, unexpectedSecrets);
}
return report;
}
async function runIsolationAudit() {
const roles: SubagentRole[] = ['code-reviewer', 'test-fixer', 'doc-generator'];
const reports: AuditReport[] = [];
for (const role of roles) {
const report = await auditSubagentIsolation(role);
reports.push(report);
}
// Generate audit summary
const violations = reports.filter((r) => r.unexpectedSecrets.length > 0);
if (violations.length > 0) {
throw new Error(`Isolation audit failed with ${violations.length} violations`);
}
console.log('All subagent roles passed isolation audit');
return reports;
}The captureEnv: true flag tells the subagent executor to return the effective environment instead of just the task output. This requires support from the underlying subagent runtime, but most production runtimes expose this capability through debug or introspection modes.
The audit should run in CI before deployment. If a developer adds a dangerous secret to an allow-list or misconfigures vault integration, the audit catches it before production. The failure mode without auditing is that you discover the leak when a customer reports seeing production database credentials in a test output file.
Frequently Asked Questions
Do subagents inherit environment variables from the parent process by default?
Yes. Subagents inherit the parent's shell environment unless you explicitly disable inheritance with a flag like inheritParentEnv: false. This means any secret in your shell is visible to all subagents unless you scope the environment per subagent.
What is the difference between shell environment secrets and MCP server credentials?
Shell environment secrets flow through process inheritance. MCP server credentials flow through explicit server attachment to the subagent's task context. You must scope both separately because blocking one does not block the other.
How long should vault-scoped tokens live for subagents?
5-10 minutes is the practical range. Shorter TTLs reduce leak exposure but increase vault request volume. Longer TTLs simplify debugging but increase the window where a leaked token stays valid. Measure your subagent task durations and set TTL to 2x the 95th percentile.
Can I audit subagent isolation in production without impacting performance?
No. Runtime auditing requires capturing and logging the effective environment, which adds latency and log volume. Run audits in staging or as a pre-deployment gate. Use static analysis and configuration validation in production to verify isolation without runtime overhead.
What happens if a subagent tries to access an MCP server it is not authorized for?
The server attachment fails and the subagent receives an error. The subagent cannot proceed with the task unless you handle the error and provide an alternative. This is correct behavior. Silent failures would allow subagents to work without credentials, which hides misconfigurations.
Conclusion: Treating Subagents Like Untrusted Workers
The core principle for securing environment variables and secrets in Claude Code is to treat subagents like untrusted workers. The default behavior of inheriting the parent's shell environment is a convenience for local development, not a safe pattern for production. Teams that deploy subagents without explicit scoping leak database credentials, API tokens, and vault keys into logs and artifacts.
The three-layer model (shell environment, MCP credentials, per-task scope) gives you the conceptual framework for implementing isolation. The allow-list pattern, vault integration, and runtime auditing give you the practical tools. Apply these in production and the difference will be immediate: subagents that can only see the secrets they need, tokens that expire minutes after tasks complete, and audit trails that catch misconfigurations before they reach production.
That covers the essential patterns for managing environment variables and secrets across Claude Code subagents. The pattern is simple: scope everything, inject dynamically, audit constantly.