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.
Claude Code stores a subscription OAuth credential at
~/.claude/.credentials.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.
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/claudecodeOne package, one concern, no ambient state:
type Credentials struct {
AccessToken, RefreshToken string
ExpiresAt time.Time
Scopes []string
SubscriptionType string
}
func Load(home string) (*Credentials, error) // parse. no network
func Save(home string, c *Credentials, expect string) error // read-modify-write, atomic, CAS
func (c *Credentials) Expired(now time.Time) bool // with skew
func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error)
// Source is the policy: refresh on expiry, write back only on rotation.
func NewSource(c *Credentials, h *http.Client, home string) *Source
func (s *Source) Token(context.Context) (string, error)
refreshTokenExpiresAt is conspicuously absent from Credentials. That is
deliberate and the reasoning is in the refresh section below: a field we cannot
keep accurate after a rotation is worse than no field.
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; Source decides whether
anything is persisted, because that decision is policy and Refresh is
mechanism. NewSource takes home rather than a path so the "no write-back"
case is the obvious one to express — pass "".
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.
llm.ClientauthToken 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:
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.
The question was whether POST /v1/oauth/token rotates refresh tokens. If it
does, refreshing without writing the result back consumes the token 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.
The probe never got an answer, and that is the answer. Ten attempts to
https://console.anthropic.com/v1/oauth/token with the stored refresh token and
Claude Code's client id — nine in a burst over roughly six minutes, then one
more much later in case the first nine were self-inflicted — every one of them:
HTTP 429 {"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}}
No exchange ever happened, so the refresh token was not consumed and nothing
was damaged. A rejection that persists uniformly across six minutes, and again
long after the traffic that might have caused it, is not throttling. And we have
already seen this exact pattern once: the Messages API answers a request shape
it declines with 429 rate_limit_error too, rather than a 401. On these OAuth
surfaces a 429 is not reliable evidence of a rate limit, which is worth knowing
before anyone reads one as "wait and retry".
The honest reading is that this request — endpoint, client id, or grant shape — is not the one this credential is refreshed with. Finding the one that is would mean guessing at authentication endpoints while holding someone's live refresh token, which is not a thing to do on a hunch.
Refresh is implemented anyway, designed for both answers. Since we cannot learn whether the endpoint rotates, the code stops needing to know:
The conditional is the whole design. Unconditional write-back makes us a routine writer of another tool's credential store; never writing gambles the user's login on an answer we could not get. Writing exactly when not writing has already caused harm needs no answer at all.
Save is a read-modify-write, not a serialize: the document is parsed, the
three fields we came to change are replaced, and every other key goes back as
found — including rateLimitTier, which this package does not model, and any
key it has never heard of. Numbers decode as literals rather than float64, or
the millisecond timestamps would return as 1.78966826946e+12 and we would have
corrupted the file we were trying to keep valid. The write is atomic: temp file
in the same directory, 0600, then rename, so a crash midway leaves the
original rather than half a credential. Verified by round-tripping a copy of a
real credentials file and diffing every key.
Write-back is best effort but never silent. A store we cannot write is a worse
next session, not a failed current one — we are holding a token that works, and
refusing to use it would be strictly worse than a stale file. But rotated-and-
unstorable is exactly the state this design exists to prevent: the token on disk
is dead and its replacement dies with the process. So Source carries an
OnWriteBackFailure hook, wired to the event log unconditionally and to stderr
outside the TUI, and swallowing that error is a bug rather than a policy.
Four more things the write has to survive, none of them hypothetical:
The owner logging in underneath us. If the user runs claude mid-session,
their fresh credential is strictly better than ours. Save takes the refresh
token it expects to find and declines with ErrStoreChanged if the file no
longer holds it — a compare-and-swap, not a blind overwrite.
Power loss. The temp file is fsynced before the rename and the directory after, or the rename can be durable while the contents are not, and the machine comes back to an empty credential file.
A response with no expires_in. That would mint a token born expired, so
the next request refreshes again, and the one after that — a hammering loop of
our own making against an endpoint that already only answers 429. It is
rejected as malformed instead.
A rotated token's unknown lifetime. The exchange never says how long the
new refresh token lives, so refreshTokenExpiresAt is not modelled at all —
not parsed, not written, preserved only as one more key we leave alone. The
alternative was worse in both directions: write a guess and we corrupt the
meaning of a field Claude Code relies on; keep the old token's expiry beside
a rotated token and our own next startup reads it, decides the refresh token
is dead, and refuses to send a request the server would have honoured.
Instead Source asks once and remembers the answer. An answer from the
server beats a timestamp we would have had to invent.
What counts as an answer is the load-bearing part. Only 400/401/403 — the
server saying this grant is no good — wrap ErrExpired and get remembered.
A 429 or a 5xx does not, because latching one would disable refresh for the
rest of the session on a login that is fine. 429 is precisely where that
would bite: it is the only response this endpoint has ever given us, and we
still do not know whether it means "slow down" or "no". Being wrong in the
direction of asking again is cheap; being wrong the other way costs the user
their session.
Because the endpoint has never once answered, none of this is verified against
the live service. It is verified against httptest and against the real file's
shape. The first real refresh is still the first real refresh.
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.
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.
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.