[BUG] VS Code extension requires re-authentication every ~8 hours — OAuth token refresh blocked by Cloudflare
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?
- The CLI binary's refresh token request to
https://claude.ai/api/oauth/tokenshould not be blocked by Cloudflare. Either whitelist requests carrying theClaude Codeuser-agent / client metadata URL, or expose a Cloudflare-exempt token refresh endpoint for non-browser clients.
- The unit mismatch in
extension.jsshould be fixed soexpiresAt(milliseconds) is normalized to seconds before comparing againstXs()/Eu()(which return seconds). This restores the intended background-refresh behavior.
Token Details
- Access token TTL: ~8 hours (
expires_infrom 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.aiOAuth (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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗