SDK OAuth refresh silently skipped when expiresAt is outside the 5-minute window (Gate 2 ignores force=true)

Status Fixed / completed
Maintainer reply ✓ Yes — claude[bot]
Activity 3 comments · opened Apr 18, 2026 · closed Apr 25, 2026
💡 Likely answer: A maintainer (claude[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

In the vendored SDK (@anthropic-ai/claude-code cli.js, reproduced on v2.1.111), the OAuth token-refresh function UR1(q, K) has two gates:

  • Gate 1 (XQ(z.expiresAt) against the in-memory token) — bypassed when K=true (the force flag).
  • Gate 2 (XQ(Y.expiresAt) against the token re-read from disk) — never bypassed, regardless of K.
async function UR1(q, K) {
  let z = o7();
  if (!K) {
    if (!z?.refreshToken || !XQ(z.expiresAt)) return false  // Gate 1 (respects force)
  }
  if (!z?.refreshToken) return false;
  let Y = await To6();                                       // re-read from disk
  if (!Y?.refreshToken || !XQ(Y.expiresAt)) return false    // Gate 2 (ignores force)
  // ...refresh exchange...
}

function XQ(q) { return Date.now() + 300000 >= q }           // 5-minute window

The 401 handler calls UR1(0, true). Gate 1 passes. Gate 2 reads disk and applies XQ again — if the token on disk has expiresAt more than ~5 minutes in the future, Gate 2 returns false and the refresh is silently skipped. The caller sees a failed refresh and the agent dies with an unrecoverable auth error, even though the refreshToken on disk is perfectly valid.

Impact

I've been spawning subprocess-based sub-agents that inherit credentials from a parent session. Parent-session tokens typically have expiresAt ≈ now + 45min. When a sub-agent hits 401 mid-flight (token rotation by another session, or simple staleness), the OAuth retry path fires, but Gate 2 sees expiresAt = now + 45min, XQ returns false, and the refresh is skipped. The sub-agent crashes.

In a 24-hour window I observed three separate kill events, each taking down 5–6 sub-agents in quick succession, with timing that correlates with rolling token expiry across the fleet.

Reproduction

  1. Valid .credentials.json with expiresAt = Date.now() + (45 * 60 * 1000) (45 minutes in the future).
  2. Access token is actually stale (e.g. rotated by a parallel session) so the next API call 401s.
  3. SDK receives 401, enters $B / _Y(0, true) / UR1(0, true).
  4. Gate 1 passes (force bypass).
  5. Gate 2 re-reads the disk, XQ(now + 45min) returns false, refresh is skipped.
  6. Agent throws a permanent auth failure. No retry, no observable error mentioning the token at all.

A naive test harness that writes expiresAt = now + 2min will NOT reproduce this — Gate 2 passes and the refresh succeeds. That is why the bug has been shipping silently.

Expected behavior

When K=true (force), UR1 should perform the refresh exchange regardless of XQ(expiresAt) on the disk copy. The whole point of the force flag is to recover from a 401 where the server has told us the token is dead — expiresAt on disk is no longer authoritative evidence of validity.

Suggested minimal patch:

async function UR1(q, K) {
  let z = o7();
  if (!K) {
    if (!z?.refreshToken || !XQ(z.expiresAt)) return false
  }
  if (!z?.refreshToken) return false;
  let Y = await To6();
  if (!Y?.refreshToken) return false;                 // still need refreshToken to exist
  if (!K && !XQ(Y.expiresAt)) return false            // respect force here too
  // ...refresh exchange...
}

Observability note

I have a full static-analysis + runtime-trace write-up including FS-watch evidence, SDK debug probe output, and five reproduction runs here: https://github.com/arthureffting/claude-config/pull/87 — happy to share anything else that helps triage.

Environment

  • @anthropic-ai/claude-code cli.js v2.1.111
  • Windows 11, git-bash
  • Node 20

Thanks for looking at this.

View original on GitHub ↗

3 Comments

0xbrainkid · 5 months ago

Precise binary analysis of a critical OAuth refresh bug: Gate 2 in UR1(q, K) ignores the force=true flag and re-applies the 5-minute expiry window check against the disk token, causing forced refreshes to silently fail when the token has more than 5 minutes remaining.

The impact you describe — sub-agent fleet kills during multi-agent workflows — is exactly the kind of failure that is hardest to debug without knowing the root cause. A 401 retry that silently returns false looks identical to a genuine authentication failure from the outside.

The fix is one line: Gate 2 should respect K (the force flag) the same way Gate 1 does:

// Current (broken):
if (!Y?.refreshToken || !XQ(Y.expiresAt)) return false    // Gate 2 (ignores force)

// Fixed:
if (!K && (!Y?.refreshToken || !XQ(Y.expiresAt))) return false    // Gate 2 (respects force)

This makes force=true skip both gate checks, allowing the refresh exchange to proceed when the caller explicitly requests it (e.g., on a 401 response), regardless of how much time remains on the token.

Why Gate 2 exists at all: The re-read from disk is useful to handle the case where another process (another agent, a concurrent session) has already refreshed the token between Gate 1 and Gate 2. If the fresh disk token has a new expiry that is not yet near, there is no need to refresh again. This logic is correct for the non-forced case. For the forced case (401 response received), the caller has already received evidence that the token is not valid — the disk check is irrelevant.

For your fleet: The force=true path (401 handler) is the critical fix. If you can patch the binary or load a modified version, changing the single Gate 2 condition is the entire fix. Alternatively, clearing the cached expiresAt before the 401 retry would also cause Gate 2 to pass.

claude[bot] contributor · 5 months ago

This issue was fixed as of version 2.1.118.

github-actions[bot] · 5 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.