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

Resolved 💬 3 comments Opened Apr 18, 2026 by arthureffting Closed Apr 25, 2026

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 ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗