OAuth auto-refresh fails in non-interactive (subprocess) mode → 401 after token expiry

Resolved 💬 3 comments Opened Apr 25, 2026 by kyohei0612 Closed Apr 28, 2026

Bug Report: Claude CLI OAuth auto-refresh fails in non-interactive (subprocess) mode

Summary

When Claude CLI (@anthropic-ai/claude-code) is invoked as a subprocess (non-interactive, no TTY) by another program (e.g., a Discord bot orchestrator), the OAuth access token is not auto-refreshed even when the refreshToken in ~/.claude/.credentials.json is still valid. This causes 401 authentication_error failures during scheduled/automated runs that occur after the 8-hour expiresAt.

Workaround discovered: directly POSTing to https://platform.claude.com/v1/oauth/token with the refresh_token works perfectly and returns a new access token, plus a rotated refresh token. The CLI seemingly should do this internally on each invocation when expired, but it doesn't (or fails silently in subprocess mode).

Environment

  • Claude CLI version: 2.1.104 (npm package @anthropic-ai/claude-code)
  • OS: Windows 11 (24H2)
  • Node: invoked via node cli.js -p --output-format json --max-turns 30 --dangerously-skip-permissions "<prompt>"
  • Subscription: Claude Max
  • Auth: OAuth (claude /login via browser flow)
  • Spawned by: Python asyncio.create_subprocess_exec (Discord bot)

Reproduction

  1. claude /login interactively at time T (creates ~/.claude/.credentials.json with expiresAt = T + 8h)
  2. Wait until T + 9h or later (token expired)
  3. Spawn node cli.js -p ... "test" from a parent Python process (no TTY, redirected stdout/stderr)
  4. Observe: process exits with rc=1, stderr empty
  5. Inspect Claude CLI session jsonl (~/.claude/projects/<cwd-mangled>/<session>.jsonl):

``
"Failed to authenticate. API Error: 401
{\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",
\"message\":\"Invalid authentication credentials\"},
\"request_id\":\"req_011CaPCNCUnRgPp921d5vS6C\"}"
``

  1. After failure, .credentials.json's expiresAt and accessToken remain unchanged (no refresh attempted by CLI)

Expected behavior

When CLI starts and detects expiresAt < now, it should automatically call the token refresh endpoint (POST /v1/oauth/token with grant_type=refresh_token) using the existing refreshToken, update .credentials.json with the new accessToken + refreshToken + new expiresAt, and proceed with the request.

This already works for interactive sessions (CLI run directly from terminal). Only subprocess/non-TTY invocations fail.

Actual behavior

  • CLI sends the API request with the expired accessToken
  • Server returns 401
  • CLI surfaces the 401 in the session jsonl as isApiErrorMessage: true
  • Process exits rc=1 with empty stderr (the 401 is only visible inside the jsonl, not on stderr)
  • .credentials.json is never modified

Manual verification (workaround works)

import json, urllib.request

creds = json.load(open(r"C:\Users\kyohei\.claude\.credentials.json"))
refresh_token = creds["claudeAiOauth"]["refreshToken"]

body = json.dumps({
    "grant_type": "refresh_token",
    "refresh_token": refresh_token,
    "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
}).encode()

req = urllib.request.Request(
    "https://platform.claude.com/v1/oauth/token",
    data=body,
    headers={"Content-Type": "application/json"},
)
resp = urllib.request.urlopen(req).read().decode()
new = json.loads(resp)
# new["access_token"], new["refresh_token"], new["expires_in"] (28800 = 8h) all returned correctly

After this manual refresh + writing back to .credentials.json, subprocess CLI invocations work again immediately.

Impact

For users running Claude CLI in long-running automated pipelines (e.g., scheduled video generation, CI/CD, cron jobs), this bug means:

  • Pipeline silently dies overnight when access token expires
  • No actionable error on stderr (401 buried in session jsonl)
  • Manual claude /login required despite valid refresh_token being available
  • Workaround (external OAuth refresh) works but requires undocumented client_id (9d1c250a-... extracted from cli.js)

Suggested fix

  1. Implement auto-refresh in CLI startup path (before sending any API request, check expiresAt, refresh if expired/near-expiry)
  2. Surface 401 errors clearly on stderr (not just in jsonl)
  3. (Bonus) Document the OAuth client_id and refresh endpoint for users who want to write their own watchdog

Additional notes

  • Refresh token rotation is in place (each refresh returns a new refresh_token). This is good — means refresh tokens never effectively expire as long as one refresh succeeds within the rotation window. If CLI auto-refresh worked in subprocess mode, fully unattended operation would be possible indefinitely.
  • This bug is what motivated me to build an external watchdog (auth_watchdog.py) that does the OAuth refresh externally every 30 minutes. With the watchdog in place, my pipeline runs unattended for arbitrary durations. Users without such a watchdog will hit this issue every 8 hours.

---

Filed by: kyohei (Claude Max subscriber)
Date: 2026-04-25
Real-world impact: Discord bot pipeline scheduled video upload missed publish window due to overnight 401 (4/24 16:41 token expire → 4/25 05:00 fire failed)

View original on GitHub ↗

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