# Claude Code OAuth credentials
The harness already speaks OAuth on the wire: `-auth-token` sends
`Authorization: Bearer` and the `oauth-2025-04-20` beta header, and has since
Phase 1. What it cannot do is find a token. You either paste one on the command
line or export it, and either way it expires in a few hours and you paste again.
Read Claude Code's credentials instead. If you are logged into Claude Code, the
harness starts with no arguments.
## What we are reading, and why that is a liability
Claude Code stores a subscription OAuth credential at
`~/.claude/.credentials.json`:
```json
{ "claudeAiOauth": {
"accessToken": "sk-ant-oat01…", "refreshToken": "sk-ant-ort01…",
"expiresAt": 1789668269460, "refreshTokenExpiresAt": 1791404074460,
"scopes": ["user:inference", "user:profile", …],
"subscriptionType": "team", "rateLimitTier": "…" } }
```
Both timestamps are epoch **milliseconds**. `user:inference` is the scope that
matters; the others are for surfaces we do not call.
Two caveats that shape everything below.
This is Claude Code's private storage, not a documented interface. The
first-party OAuth path is `ant auth login`, which writes profiles under
`~/.config/anthropic/` that every Anthropic SDK reads automatically. That path
is stable and supported. It is also not available here — no `ant` binary, no
`~/.config/anthropic` — so we read the file that exists. The schema and the
refresh endpoint can change under us without warning, which is the argument for
putting all of it behind one small package whose tests fail loudly rather than
one that degrades quietly.
And the credential is issued to Claude Code. Using it from another client is
outside Anthropic's terms for that credential, which is a reason to keep this
strictly opt-out-able and clearly announced, and never the silent default when
another credential was offered. API keys and gateway bearer tokens carry no such
condition and keep working exactly as they do today.
## Precedence
First match wins:
| Source | Notes |
| --- | --- |
| `-api-key` / `-auth-token` | explicit, so it beats everything |
| `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | today's behaviour, unchanged |
| `~/.claude/.credentials.json` | new, and only under the conditions below |
| — | nothing: `errNoCredentials`, as now |
The file is consulted only when every earlier source came up empty **and**
`client.IsDefaultEndpoint()` is true. That second condition is the one worth
being stubborn about: a discovered credential must never be shipped to an
endpoint the user pointed us at. Otherwise `-base-url https://someone-elses-proxy`
silently forwards your subscription token to a third party because a file
happened to exist on disk. A credential you typed is a credential you chose to
send; a credential we found is not.
When the file is the source, say so through `notice()` — the same channel that
already announces a non-default endpoint, and for the same reason. `-no-claude-code-auth`
skips discovery entirely.
## `internal/claudecode`
One package, three jobs, no ambient state:
```go
type Credentials struct {
AccessToken, RefreshToken string
ExpiresAt time.Time
Scopes []string
SubscriptionType string
}
func Load(home string) (*Credentials, error) // parse. no network
func (c *Credentials) Expired(now time.Time) bool // with skew
func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error)
```
`Load` takes `home` rather than calling `os.UserHomeDir` so tests point it at a
fixture directory, matching how `skills.Discover` already takes its roots.
`Refresh` returns new credentials and writes nothing — see below.
Missing file is not an error worth decorating: it means "not logged in", and the
caller falls through to `errNoCredentials`. A file that exists but does not parse
*is* an error, reported with the path, because that is a broken install rather
than an absent one.
`Expired` subtracts a 60-second skew margin, so a token expiring during the
request we are about to send counts as expired now.
On macOS Claude Code keeps this in the Keychain and the file does not exist.
That degrades to "no credential found", which is correct, and is documented
rather than worked around.
## Dynamic credentials in `llm.Client`
`authToken` is a fixed string read once at construction, used by both `attempt`
and the streaming path. A token that expires mid-session needs to change, so:
```go
func WithTokenSource(f func(context.Context) (string, error)) Option
```
`WithAuthToken` becomes the constant case of it. Both request paths call the
source and set the bearer from the result; the existing header logic is
untouched, because the header was never the problem.
Resolving a credential can now fail at request time rather than at startup, and
where that resolution happens matters. `CreateMessage`'s retry loop only returns
early for a non-retryable `*APIError`; anything else falls through and is tried
again. A credential error is not an `*APIError`, so resolving inside `attempt`
would retry a dead credential three times with backoff.
So the credential is resolved once per call, before the loop, and passed into
`attempt` and `streamAttempt` as a value. That also means one refresh per
request rather than one per attempt, and it lets both request paths share a
single `apply` rather than duplicating the header logic as they do today.
The token source refreshes at most once per expiry, caches in memory for the
process lifetime, and is mutex-guarded: streaming and non-streaming requests can
be in flight together, and two concurrent refreshes of the same token is exactly
the scenario the next section is about.
## Refresh, and an open question that gates it
Refreshing is `POST /v1/oauth/token` with the refresh token. If the endpoint
returns a *new* refresh token, then refreshing without writing it back consumes
the one Claude Code still has on disk — its copy goes dead and `claude` asks you
to log in again. We would have broken a tool outside this repo to save a paste.
We do not yet know whether that endpoint rotates. So the first implementation
task is a probe, not code: refresh once, compare the returned refresh token to
the stored one, report.
- **No rotation** — in-memory-only refresh is safe. Build it as described.
- **Rotation** — stop. Bring the result back before writing refresh code; the
choice then is write-back (with atomic rename and 0600) or no refresh at all,
and it is not a choice to make silently inside an implementation task.
Until that resolves, the fallback is the honest one: use the stored token, and
when it is expired fail with `your Claude Code login has expired, run claude to
refresh it`. That is a working feature on its own. Refresh is an improvement on
top of it, not a prerequisite.
## The system prompt
**Measured: the identity string is required.** Four requests, alternating
shapes back to back against `api.anthropic.com/v1/messages` with the same
token:
| System blocks | Result |
| --- | --- |
| `defaultSystem` alone | `429` ×2 |
| identity string, then `defaultSystem` | `200` ×2 |
Interleaved deliberately, because the first run of this probe was confounded:
six plain requests 429'd over two minutes and the identity request then
succeeded on its first try, which is equally consistent with a rate limit
clearing. Alternating removes that reading — a real rate limit does not sort
itself by system prompt.
The rejection arrives as **`429` with `"type":"rate_limit_error"`**, not the
`401` or `403` you would expect for a credential the server declines to honour.
That is worth writing down: an OAuth request missing the identity block looks
exactly like a quota problem, and waiting it out is the obvious wrong response.
So the agent prepends a second `SystemBlock` carrying the identity string when —
and only when — the credential came from OAuth. Both blocks stay static, so the
cached prefix survives intact, which is the property `defaultSystem`'s comment
exists to protect. The cache breakpoint moves to the *last* system block; left
on the first, it would cache the identity string and re-read the real prompt
every turn.
## Testing
`Load`: missing file, unparseable JSON, the millisecond epoch converted
correctly, an expired access token, an expired *refresh* token (which is
terminal — no amount of refreshing fixes it).
`Refresh` against `httptest`: success, `400 invalid_grant`, transport error.
No test makes a real network call. These land only if the probe clears refresh
to be built; if it does not, the expired-token error path is what gets tested
instead.
`llm.Client`: the token source is called per request and its value reaches the
`Authorization` header; a source error propagates without retries; `WithAuthToken`
still behaves as the constant source.
Precedence, which is where the security property lives and therefore gets the
most tests: flag beats env beats file; a non-default base URL means the file is
never read; `-no-claude-code-auth` means the file is never read. That third one
is a test rather than a comment because "we did not send the credential" is not
observable from the outside.
## Not doing
No login flow — no PKCE, no browser, no callback server. You log in with Claude
Code; the harness reads what that produced. If `ant` shows up on this machine
later, its profile is a better source than this file and worth revisiting then.