Lab note · from ArtificialMeter
Claude Code signed out by another app: refresh token rotation
Anthropic's OAuth token endpoint, as observed while building ArtificialMeter, issues a new refresh token on every refresh and rejects the old one with invalid_grant. So when a second program holds a copy of the login Claude Code is using, whichever of the two refreshes first signs the other out. The fix is ownership: only Claude Code refreshes the login it is using, other programs read with its current access token, and on macOS its Keychain item is reached only through the security tool Claude Code itself uses.
Symptoms
- Claude Code asks you to
/loginafter sitting idle while another tool keeps polling your usage. In ArtificialMeter's case the typical failure was an idle stretch across an expiry, overnight or over a weekend, with the app's 5-minute poll winning the race. - The other tool's copy of the account says "Sign-in expired", because Claude Code refreshed first.
- After a tool writes Claude Code's Keychain item, macOS asks for your login password on behalf of
security, once per running session per poll, and "Always Allow" does not make it stop.
Why it happens
The refresh token is single-use
OAuth allows this. RFC 6749, section 6 says the server "MAY issue a new refresh token, in which case the client MUST discard the old refresh token". RFC 9700, section 4.14.2 describes rotation as a replay defence: "The previous refresh token is invalidated", and if a token is used by two parties, "one of them will present an invalidated refresh token". Two legitimate programs sharing one login look the same to the server.
Anthropic does not publish its rotation policy for Claude Code logins. What the app saw was the old token failing with invalid_grant after an exchange. A review of ArtificialMeter a day after its first commit found four code paths that broke Claude Code's login this way:
- Polling refreshed Claude Code's login when it was within 60 seconds of expiry, and kept the new token to itself.
identify(), which only reads the account profile, refreshed the token and then threw the new one away, leaving both copies dead.- An account switch overwrote the outgoing account's newest token without saving it.
- The app read
~/.claude/.credentials.jsonbefore the Keychain. Claude Code keeps its login in the Keychain; the file was found eight hours behind, holding an already-rotated token.
The first path, before the fix:
static func fetchUsage(for account: Account, credentials: ClaudeCredentials)
async throws -> (snapshot: UsageSnapshot, credentials: ClaudeCredentials) {
var creds = credentials
if creds.needsRefresh { // expires in under 60 s
creds = try await ClaudeOAuth.refresh(creds) // rotates Claude Code's token
}
let response = try await ClaudeAPI.fetchUsage(token: creds.accessToken)
return (normalise(response, accountID: account.id), creds)
}
The Keychain item has a partition list
On macOS, Claude Code stores its login in the Keychain (Anthropic docs). The SecItem API on macOS "defaults to targeting the file-based keychain", which uses access control lists (TN3137). Part of that ACL is the partition list, which "limits access to the item based on an application's code signature" (man security). Apple's securityd source gives each caller a partition ID: apple-tool: for the security command-line tool, teamid: for code signed with a developer certificate, cdhash: for other signed code. When a caller's ID is not in the item's list, a read asks the user.
Claude Code reaches this item through /usr/bin/security. ArtificialMeter updated the same item with SecItemUpdate, and afterwards the item's partition was the app's instead of apple-tool:. Every security read Claude Code made, one per session per poll, then raised a login-password dialog. The write itself looked ordinary:
let status = SecItemUpdate(query as CFDictionary, [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
] as CFDictionary)
Apple does not document that an update rewrites the partition list. This is what happened here, and the repository does not isolate which part of the call caused it.
The fix
Claude Code refreshes its own login; the app only reads it
Each poll reads Claude Code's current login first and marks the account it belongs to. That account is fetched with refresh forbidden:
// UsageEngine: the account Claude Code is signed in as
if account.id == liveID, let live {
return Job(account: account, credentials: live, allowRefresh: false)
}
// ClaudeProvider.fetchUsage
if creds.needsRefresh {
guard allowRefresh else { throw FetchError.awaitingClaudeCode }
creds = try await ClaudeOAuth.refresh(creds)
}
A 401 on that account gets the same answer instead of a refresh, and the row reads "Claude Code renews this sign-in on its next request — showing the last reading". identify() never refreshes. Every other account belongs to the app, which refreshes it and saves the rotated token straight away.
A switch saves the outgoing sign-in first
A switch runs in a fixed order. It checks the target's own saved sign-in before touching anything of Claude Code's. It takes Claude Code's current token and records it under its account, adding the account if the app does not track it. It installs the target in the Keychain, reads it back from the Keychain, and rolls back if the write did not land. The outgoing token is renewed during the takeover only if it has lapsed, which is safe at that point because nothing else will use it once the switch lands.
Reach the item the way its owner does
The app's Security framework wrapper now refuses any service that is not its own:
private static func requireOwn(_ service: String) throws {
guard service == Keychain.service else {
throw KeychainError.foreignService(service)
}
}
Claude Code's item, Claude Code-credentials, goes through security instead. A write feeds security -i the same command line Claude Code uses, on stdin, with the JSON hex-encoded so it needs no quoting:
"add-generic-password -U -a \"\(account)\" -s \"\(service)\" -X \"\(hex(data))\"\n"
Running security as a subprocess also makes a timeout real. A stuck process can be killed and its dialog goes with it, where a SecItemCopyMatching call on a worker thread stayed parked behind a dialog nobody could dismiss.
How to check you've fixed it
- Count requests to the token endpoint against a fake network. ArtificialMeter's
TokenOwnershipTestspoll with a lapsed Claude Code login and expect zero token requests. Another checks that after a switch the app holds the outgoing account's newer token. - Exercise the write path on a throwaway item, never the real one.
ArtificialMeter --selftest keychainwrites, reads back, overwrites and deletes an item under its own service name throughsecurity. - After a real switch, running Claude Code sessions should keep working with no password dialog.
ArtificialMeter --diagnoseis read-only, never renews a token, and reports whether Claude Code's Keychain copy and file copy agree.
Caveats
- The project targets macOS 14 or later and builds in the Swift 6 language mode.
- The rotation behaviour is observed, not documented. The ownership rule is safe whether or not the policy changes.
- While Claude Code's access token is lapsed, the app shows the last reading until Claude Code next makes a request.
security -ireads each line into a 4,096-byte buffer (MAX_LINE_LENin Apple'ssecurity.c). Claude Code's item also carries MCP server logins, so the command grows. A longer line was cut in two and failed withsecurity write failed (exit 1). Past 4,032 bytes the app now passes the command as arguments instead, the same threshold the repo found in Claude Code 2.1.283.- Listing item names still uses
SecItemCopyMatchingwith attributes only, and reads no secret. - The MCP logins in the same item are merged back untouched on every write. Dropping them would sign you out of every connected MCP server.
More lab notes
- Anthropic API key in an iOS app binary: move it to the KeychainA Swift string literal ships in the app binary, readable with strings. Store a user-supplied key in the Keychain with a ThisDeviceOnly class instead.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.