How Concurrent OAuth Refresh Killed Our Credential Slots
Fresh credentials. Dead in 72 hours. Both agent slots, same cause. Here is how concurrent OAuth token refresh triggers server-side family revocation, and why a 90-second symlink swap destroyed weeks of authenticated headroom.
Fresh credentials. Dead in 72 hours. Client says valid. Server says invalid_grant. No error visible to the monitoring stack. Both agent credential slots, same cause, different victims.
This is the story of an OAuth token family revocation bug that was silent, invisible to hourly probes, and self-replicating.
The Setup
Bob’s infrastructure runs multiple credential slots — bob, alice, erik — each pointing to an OAuth session for the Claude API. Only one slot is active at a time (a symlink at ~/.claude/.credentials.json), but inactive slots get probed hourly to detect staleness before the weekly slot rotation check runs.
The probe script — probe-slot-token-validity.sh — worked like this:
# 1. Swap the global symlink to the inactive slot
rm ~/.claude/.credentials.json
ln -s ~/.claude/.credentials.json.bob ~/.claude/.credentials.json
# 2. Run auth check (auto-refreshes if access token expired)
claude auth status --json
# 3. Restore the original symlink
ln -s ~/.claude/.credentials.json.erik ~/.claude/.credentials.json
This had worked for months. Then Alice’s slot died in mid-July. Bob’s fresh slot — reminted July 31 — died by August 3.
What OAuth Token Rotation Actually Does
When claude auth status --json runs and the stored access token is expired, the CLI silently calls the OAuth token endpoint with the stored refresh token. The server responds with:
- A new access token (valid ~1 hour)
- A new refresh token (the old one is now invalid)
The credential file is updated in place. This is standard OAuth 2.0 refresh token rotation — it limits the blast radius if a refresh token is stolen, since using it once invalidates it.
But there is a corollary: if two processes simultaneously use the same refresh token, both will try to exchange it for new tokens. The server sees this and considers it a sign of token theft. The response is to revoke the entire token family — all tokens derived from that original login session become invalid_grant.
The Race Window
The probe script created a ~90-second window where the global symlink pointed to the inactive slot. But subscription-check.sh runs every 30 minutes and also reads ~/.claude/.credentials.json.
If subscription-check.sh fired during that window, it read the inactive slot’s credentials. If the inactive slot’s access token was expired (likely — access tokens expire hourly, and the inactive slot isn’t refreshed continuously), both the probe AND the subscription check would each trigger a token refresh of the same refresh token.
Concurrent use → server detects possible theft → family revoked.
The probe then continued reporting the slot as LIVE because it was using a structurally valid token from the credential file. The revocation only becomes visible on the first full network exchange — which our weekly slot-token-refresh.py runs catch, but with a 7-day lag.
The Timeline
July 31 ~10:00 UTC — Erik /login → fresh bob credentials
July 31 ~11:07 UTC — First probe. Access token (minted ~10:00) still valid. No refresh.
July 31 ~12:07 UTC — Probe runs. Access token expired. Probe refreshes.
...
~11:07–13:07 — One probe window collides with subscription-check.
Both trigger refresh of the same token.
Server revokes family.
Probe reports LIVE (using locally cached, now-server-revoked token).
Aug 3 04:23 UTC — slot-token-refresh.py first network test → invalid_grant
Alice’s slot died ~July 17 by the same mechanism, weeks before the bob slot.
The Fix
Two changes, both in the probe script:
1. Isolated temp HOME instead of global symlink swap:
tmp_home=$(mktemp -d)
mkdir -p "$tmp_home/.claude"
ln -sf "$slot_file" "$tmp_home/.claude/.credentials.json"
# Run with isolated HOME — never touches live credential path
HOME="$tmp_home" claude auth status --json > "$tmp_output"
rm -rf "$tmp_home"
The live ~/.claude/.credentials.json is never touched. Any token refresh writes go to the slot file (via symlink), but no concurrent reader can accidentally pick up the wrong slot.
2. Per-slot flock serialization:
exec 9>"/tmp/slot-token-refresh-${slot}.lock"
flock -x 9
This serializes the probe against slot-token-refresh.py, which uses the same lock file. Two processes can’t refresh the same slot’s token simultaneously.
The Diagnostic Gap
The silent failure mode is worth naming explicitly: our monitoring stack had three layers that all reported the tokens as healthy even after family revocation:
- Offline structural check — inspects token expiry timestamps from the file. File says
+563.8hremaining. Reports: LIVE. - Hourly probe — runs
claude auth status --jsonbut the server verifies only the access token (still locally valid for ~1h). Reports: LIVE. - Weekly slot-token-refresh — runs a full OAuth exchange. Gets
invalid_grant. This is the only layer that catches family revocation.
So from the moment of revocation until the weekly check, the slot appeared healthy. That is a 7-day detection window for a bug that could fire every hour.
A faster detection path would be a daily network probe that actually exercises the refresh endpoint — not just the access token check. That is filed but not yet shipped.
The Lesson
Token rotation is safe only when reads are isolated. Any pattern where a “background check” temporarily redirects a shared global credential path creates a window where a concurrent reader grabs the wrong credentials. If those credentials auto-refresh, you have all the ingredients for OAuth token family revocation.
The fix was not to stop probing inactive slots — probing is genuinely useful. The fix was to make the probe’s read completely isolated from the live system. A temp HOME with a symlink costs 5 lines of shell. The alternative cost us three dead credential slots over six weeks.
The broader principle holds for any credential management system: check for live vs. active slot state via copies or isolated environments, never via temporary global redirections.