Subscription OAuth is the hard half of multi-provider auth
An Anthropic subscription token changes the HTTP request, the first system block, refresh persistence, and the login race — it is not an API key with a different prefix.
On this page
An API key answers one question: what secret goes in the provider client’s auth field?
An Anthropic subscription login answers at least five. How does a terminal receive the browser redirect? How is that redirect tied to the attempt that opened it? What changes on the HTTP request? What exact system block must lead the prompt? Where does a rotated refresh token get written so the next process sees it?
Treating subscription OAuth as “another way to obtain a key” hides all five behind a string. Efferent models it as a different credential kind and keeps that distinction alive until provider construction.
The credential is a tagged union
The auth file can hold an API key, an OAuth bundle, or a local-provider marker. The OAuth case carries access token, refresh token, and an absolute early-refresh time:
{
"anthropic": {
"type": "oauth",
"access": "…",
"refresh": "…",
"expires": 1783852200000
}
}
The tag matters because the same Anthropic model id takes a different wire path depending on the credential. Flatten this to string and some later heuristic has to guess whether to send x-api-key or Authorization: Bearer. Authentication mode is domain state, not token syntax.
Credentials live in ~/.efferent/auth.json. A workspace may also carry .efferent/auth.json; local entries override global ones per provider. New logins write globally because the credential belongs to the user, while logout removes the provider from both tiers so a stale local override cannot resurrect it.
Authorization code plus PKCE, in a terminal
The Anthropic flow uses authorization code + PKCE. Efferent generates a random verifier, hashes it into the S256 challenge, and also uses the verifier as state:
export const beginAnthropicOAuth = Effect.map(generatePkce, (pkce) => ({
verifier: pkce.verifier,
authorizeUrl: `${AUTHORIZE_URL}?${new URLSearchParams({
client_id: ANTHROPIC_CLIENT_ID,
response_type: "code",
redirect_uri: ANTHROPIC_REDIRECT_URI,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state: pkce.verifier,
})}`,
callbackPort: 53692,
callbackPath: "/callback",
}))
PKCE proves that the process exchanging the code is the process that began the login. state prevents a redirect from a different attempt being accepted as this one. Reusing the verifier for state keeps one random value alive through the TUI without weakening either comparison.
The terminal then races two completion paths:
- a scoped loopback server waits on
localhost:53692/callback; - the input box accepts a pasted redirect URL for SSH, containers, and browsers on another machine.
Both converge on the same parser and exchange. Whichever path wins interrupts the other. Esc interrupts the server fiber too, so abandoning a login does not leave a port open in the background.
The browser-open command is best-effort. Failure leaves the URL visible and the paste route usable. Opening a browser is convenience; exchanging the correct code is the protocol.
Parse the inputs people actually paste
Remote logins produce surprisingly varied clipboard material: a full redirect URL, a raw query string, code#state, or a bare code. parseAuthorizationInput accepts all four and returns Options rather than throwing on garbage.
That forgiving input boundary does not relax the CSRF check. If a pasted form includes state, it must equal the in-flight verifier. A bare code has no state to compare, which is a deliberate usability concession for providers and terminals that surface only the code; PKCE still binds the exchange.
OAuth changes the provider request
For API-key auth, @effect/ai-anthropic can build its normal client. Subscription auth instead removes the key field and transforms the HTTP client:
const anthropicOAuthTransform =
(access: Redacted.Redacted<string>) =>
(client: HttpClient.HttpClient) =>
client.pipe(
HttpClient.mapRequest((request) =>
request.pipe(
HttpClientRequest.setHeaders({
Authorization: `Bearer ${Redacted.value(access)}`,
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
}),
),
),
)
It also prepends an exact Claude Code identity block as the first system message. Anthropic rejects the subscription path without it:
export const CLAUDE_CODE_SYSTEM =
"You are Claude Code, Anthropic's official CLI for Claude."
That requirement is why OAuth cannot disappear behind resolveKey(): string. The token value is only half the selection; provider construction still needs to know the credential’s kind so it can shape headers and prompt together.
Refresh writes back to the file that won
Every request re-reads the global and local auth files. That makes a login performed mid-session visible on the next model call and lets another process update credentials without rebuilding a Layer.
When an OAuth access token is near expiry, resolveKey refreshes it and persists the whole updated credential back to the file that supplied the winning entry:
const fresh = yield* refreshAnthropicToken(cred.refresh)
const updated: Credential = { ...cred, ...fresh }
yield* writeAuthFile(
holder.path,
new Map([...holder.entries, [provider, updated]]),
)
Writing to the holder is important. If a workspace-local token overrode a global one, refreshing into the global file would succeed now and then “revert” on the next read when the stale local entry won again.
The file write is temp-file-plus-rename and mode 0600. Credentials never spend a moment in a partially written target file, and a crash before rename leaves the old valid file intact. Corrupt JSON is warned about rather than silently presented as “logged out,” because absence and damage demand different fixes.
Refresh early, but only once
The token endpoint’s expires_in becomes an absolute timestamp with a five-minute skew already subtracted. The store adds a smaller one-minute check before deciding to refresh. The overlap buys time for clock skew and a model request that starts just before nominal expiry.
Refreshing on demand rather than on a timer keeps the CLI idle when the user is idle. It also creates a concurrency edge: two processes can notice expiry and refresh simultaneously. The current atomic file write prevents corruption, but it does not serialize provider-side token rotation across processes. If Anthropic invalidates the old refresh token on first use, one process may lose that race and require retry or login. Local-file auth has limits; pretending otherwise would be worse than naming them.
The abstraction boundary
The clean boundary is not “all providers return a key.” It is:
AuthStoreresolves a typed credential, refreshing if necessary;- the router resolves the current model and credential on every call;
- provider construction translates that pair into the correct client, headers, and prompt prelude.
API keys take the short branch. Subscription OAuth takes the protocol branch. The agent loop sees one LanguageModel either way, but the edge does not erase distinctions it still needs.
That is the general lesson for multi-provider auth: normalize the interface presented to the core, not the semantics required at the wire. A subscription token that needs special beta headers and a mandatory identity prompt is not an API key. A good adapter makes the rest of the program forget that fact only after it has honored it.