Skip to content

Security model

Why claude-coder is safe to run on your Claude subscription within Anthropic's Terms of Service.

claude-coder drives Claude Code as a human would, using your subscription's OAuth login — never an API key. Five independent, fail-closed layers keep that invariant true: auth, environment, MCP, keystrokes, and logs. Each layer is auditable on its own; if one slips, the next still holds.

You can confirm the auth posture at any time with the read-only doctor check:

claude-coder doctor
✓ auth            subscription OAuth (no billed credential in force)

1. Subscription-OAuth invariant

Every session must authenticate as subscription OAuth. The SDK reports an apiKeySource on its first system message; claude-coder gates on it with a fail-closed allow-list (src/sdk/auth-guard.ts):

export const ALLOWED_API_KEY_SOURCES: ReadonlySet<string> = new Set<string>(['oauth', 'none']);

Only two sources pass:

Source Meaning Verdict
oauth subscription login (claude /login) accepted
none runtime reports no explicit key source accepted
user / project / org / temporary an explicit API key rejected
anything else (unknown / future / empty) unrecognized rejected

The check (assertAuthFromInit) runs on the SDK init message, before any user prompt is sent. (A single benign priming message is enqueued first — only to unblock the SDK's streaming-input init, which does not emit system/init until a message arrives; on an auth failure that priming turn is interrupted to zero cost, so it never bills.) A non-allow-listed source throws TransportError{kind:'auth'} and no user turn ever runs — so an explicit key can never bill your account through claude-coder.

Two compile-time canaries keep the rejected set pinned to the SDK's real ApiKeySource union: one asserts every rejected source (user/project/org/temporary) is still a member of the union, so renaming or removing a rejected source breaks the build; the other asserts the rejected and allowed sets stay disjoint. A genuinely new or unknown source is not caught at build time — it is rejected at runtime by the fail-closed allow-list (only oauth/none pass ALLOWED_API_KEY_SOURCES.has(source)), so it can never silently bill.

Note

Auth is always the subscription. A profile that sets ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, or AWS_BEARER_TOKEN_BEDROCK is rejected — these redirect auth or billing off your subscription.

2. Environment hardening at the transport

Some env vars out-rank OAuth in the child process. claude-coder strips them at the transport, as the last line of defense, after merging any profile env. The deny set is one shared source of truth (src/sdk/auth-guard.ts):

export const CREDENTIAL_ENV_VARS = [
  'ANTHROPIC_API_KEY',
  'ANTHROPIC_AUTH_TOKEN',
  'CLAUDE_CODE_USE_BEDROCK',
  'CLAUDE_CODE_USE_VERTEX',
  'CLAUDE_CODE_USE_FOUNDRY',
  'AWS_BEARER_TOKEN_BEDROCK',
  'ANTHROPIC_BASE_URL',
] as const;

This holds for both backends. hardenEnv (SDK) and hardenPtyEnv (PTY) each clone the full parent environment so the child keeps PATH and HOME, then delete every key in the deny set — even if a profile tried to set one. ANTHROPIC_BASE_URL is in that deny set, so a profile-supplied endpoint override is stripped; the runtime alone may then re-pin it to a controlled value (via pinBaseUrl) after the strip — the strip removes a profile's URL, never the runtime's. The PTY path also pins DISABLE_AUTOUPDATER=1, so the version-banded binary cannot silently auto-update out of band (see section 4).

The same module strips credential-bearing settings keys, not just env vars: before a profile's settings reaches the SDK, scrubSettings (src/sdk/auth-guard.ts) drops apiKeyHelper, proxyAuthHelper, awsCredentialExport, awsAuthRefresh, and gcpAuthRefresh — auth helpers that could re-inject an explicit key or external auth out-ranking OAuth. A settings file cannot smuggle an auth helper past the transport.

It is defense in depth: a profile that sets one of these vars is rejected early by the profile guard (secrets), and the transport strips them again at spawn. Two independent layers must both fail for a credential var to reach the child.

3. MCP isolation

Your machine may have ambient MCP servers configured (project .mcp.json, user settings, plugins). A driven session should not inherit them — an auth-needing ambient server churns the welcome screen and breaks idle detection, and you never asked the session to load it.

Both backends isolate the child unconditionally:

  • PTY always passes --strict-mcp-config (src/pty/start-args.ts).
  • SDK always sets strictMcpConfig: true (src/sdk/options-builder.ts).

Under strict mode the child loads only the MCP servers claude-coder injects on purpose — the servers a profile declares in its config.mcpConfig, routed through a separate, session-scoped config. The user's ambient MCP is ignored entirely. See profiles for how a profile declares the servers it wants injected.

4. PTY keystroke fail-closed

The PTY backend types into Claude Code's real terminal UI to answer permission and trust dialogs. The risk is injecting a keystroke into a UI that has changed shape — answering the wrong question. claude-coder is structured so a drifted or unknown UI gets no keystroke at all.

It does not exact-pin the claude version. Exact pinning breaks on every auto-update. Instead it uses a version band plus per-surface verification.

Version band. At startup the binary's version must fall in the band (src/pty/version-guard.ts):

export const SUPPORTED_VERSION_RANGE = Object.freeze({ min: '2.1.0', maxExclusive: '2.2.0' });

An out-of-band version (older than 2.1.0 or 2.2.0+) fails closed with TransportError{kind:'unsupported_version'} before any keystroke. Any in-band 2.1.x patch is accepted — so routine claude updates do not break the wrapper.

Per-surface structural and label verification. The band alone never authorizes a keystroke. Each dialog is parsed and checked against the pinned grammar at inject time:

  • Permission prompt (resolveAllowOnce): requires exactly one "Yes" option (end-anchored label, so "Yes, and always allow…" does not match), the full certified option set, and a matching structural fingerprint over the prompt's header/footer/option-count. Any mismatch returns a fail-closed reason and no keystroke is sent.
  • Trust dialog (resolveTrustAccept): requires exactly one row matching the end-anchored accept label (/^Yes, I trust this folder$/i), and returns that row's index — never a hardcoded key. A relabeled, missing, or suffix-extended accept option fails closed.

Grammar-drift latching. A watcher observes the live version banner and grammar fingerprint mid-session. If either drifts from the seed observed at start, it latches injectionForbidden and no further keystrokes are injected for the rest of the session — the session stays observable but inert.

Warning

The net guarantee: a drifted, downgraded, or unrecognized TUI never receives a blind keystroke. The auto-accept of a trust or permission dialog is only ever sent when the live UI structurally matches the certified grammar.

5. Secret redaction in events and logs

claude-coder publishes session events and diagnostics. Resolved secrets must never leak into them. Two categories are scrubbed (src/fleet/secrets.ts):

  • Explicit secret refs — a profile value declared as a secret is redacted at any length, no questions asked.
  • Signal-matched literals — a literal value is redacted when it looks like a credential: a credential-shaped key name (TOKEN, KEY, SECRET, PASSWORD, AUTH, …), a known token prefix (sk-, ghp_, AKIA, eyJ, -----BEGIN, …), a DSN/connection string with an embedded password, or a high-entropy token.

Matched values are scrubbed globally across every published event and every error/diagnostic message. The redaction walk is safe on live event objects: it replaces strings, but passes functions, non-plain objects (signals, streams, class instances), and primitives through by reference — so it removes the secret payload without corrupting the event.

For how to declare secrets, where they resolve, and the full match rules, see secrets.

Layer summary

Layer Enforces Posture
Auth allow-list apiKeySource ∈ {oauth, none} reject explicit keys before first prompt
Env hardening credential vars stripped at transport strip after merge, both backends
MCP isolation only injected servers load --strict-mcp-config / strictMcpConfig always on
Keystroke safety version band + per-surface verification no keystroke into a drifted UI
Secret redaction secrets scrubbed from events/logs global string redaction, structure-safe

Each layer is fail-closed and independent. Auth is always your subscription's OAuth — never an API key.

See also: concepts for how transports and profiles fit together, and troubleshooting for what an unsupported_version or auth error looks like in practice.