[BUG] VS Code extension requires re-authentication every ~8 hours — OAuth token refresh blocked by Cloudflare

Resolved 💬 2 comments Opened Jun 10, 2026 by hradilm Closed Jun 10, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

The VS Code extension requires manual browser re-authentication every ~8 hours. The OAuth refresh token is present and valid, but the silent refresh never succeeds.

Platform: macOS 15 (Darwin 24.6.0), Apple Silicon (arm64)
Extension version: 2.1.170 (also confirmed on 2.1.169)
CLI version: 2.1.145

Investigation

I debugged this end-to-end. There are two separate problems:

---

Problem 1: Cloudflare blocks the token refresh endpoint from the CLI

The VS Code extension delegates all API calls — including auth — to the CLI binary (~/.local/share/claude/versions/2.1.145, a native Mach-O arm64 executable). When the access token expires the CLI gets a 401, then attempts a refresh token grant against https://claude.ai/api/oauth/token. This request is intercepted by Cloudflare and returns 403 with a JS challenge page.

Reproduction (replace with a valid refresh token from the macOS keychain entry Claude Code-credentials-*):

curl -s -o /dev/null -w "%{http_code}" -X POST "https://claude.ai/api/oauth/token" \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"refresh_token","refresh_token":"sk-ant-ort01-...","client_id":"https://claude.ai/oauth/claude-code-client-metadata"}'
# → 403 (Cloudflare HTML challenge, not a JSON error)

The same request made through a real browser (with Cloudflare clearance cookies) succeeds. The CLI has no browser context, so the refresh always fails, and the user is forced back to interactive browser auth.

---

Problem 2: Unit mismatch in extension.js — background refresh never fires

Even though the main auth path goes through the CLI binary, extension.js contains its own OAuth token management (used for MCP and other sub-flows). This code has a unit mismatch that permanently disables the background refresh:

// Xs() and Eu() both return Math.floor(Date.now() / 1000)  — epoch SECONDS
// But expiresAt is stored in epoch MILLISECONDS (e.g. 1781021521334)

let r = t.expiresAt - Xs();   // r ≈ 1,779,272,000,000 — always >> threshold
if (r > Z9) return t.token;   // Z9 = 120  — always true, refresh never fires

Because r is always ~1.7 trillion (ms vs s mismatch), getToken() always returns the cached token without ever calling backgroundRefresh(). The token silently expires and only the subsequent 401 from the API triggers an interactive re-auth prompt.

Affected locations in extension.js:

  • let r=t.expiresAt-Xs();if(r>Z9) (around byte offset 245513 in 2.1.170)
  • let r=t.expiresAt-Eu();if(r>LTe) (around byte offset 881133 in 2.1.170)

Fix:

// Normalize expiresAt to seconds before comparing against Xs()/Eu() (which return seconds)
let r = (t.expiresAt > 1e12 ? Math.floor(t.expiresAt / 1000) : t.expiresAt) - Xs();

---

What Should Happen?

  1. The CLI binary's refresh token request to https://claude.ai/api/oauth/token should not be blocked by Cloudflare. Either whitelist requests carrying the Claude Code user-agent / client metadata URL, or expose a Cloudflare-exempt token refresh endpoint for non-browser clients.
  1. The unit mismatch in extension.js should be fixed so expiresAt (milliseconds) is normalized to seconds before comparing against Xs()/Eu() (which return seconds). This restores the intended background-refresh behavior.

Token Details

  • Access token TTL: ~8 hours (expires_in from server)
  • Refresh token: present and stored in macOS keychain under Claude Code-credentials-*
  • Keychain entry format: { claudeAiOauth: { accessToken, refreshToken, expiresAt (ms), scopes, subscriptionType } }
  • Auth method: claude.ai OAuth (not API key)

Error Messages/Logs

No error surfaced to the user — the extension silently fails the refresh and presents a browser login prompt. With the unit mismatch in place, no [CLAUDE-PATCH]-style log ever fires because getToken() always takes the early-return path.

View original on GitHub ↗

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