OAuth auto-refresh fails in non-interactive (subprocess) mode → 401 after token expiry
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 /loginvia browser flow) - Spawned by: Python
asyncio.create_subprocess_exec(Discord bot)
Reproduction
claude /logininteractively at time T (creates~/.claude/.credentials.jsonwithexpiresAt = T + 8h)- Wait until T + 9h or later (token expired)
- Spawn
node cli.js -p ... "test"from a parent Python process (no TTY, redirected stdout/stderr) - Observe: process exits with rc=1, stderr empty
- 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\"}"
- After failure,
.credentials.json'sexpiresAtandaccessTokenremain 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.jsonis 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 /loginrequired despite valid refresh_token being available - Workaround (external OAuth refresh) works but requires undocumented client_id (
9d1c250a-...extracted from cli.js)
Suggested fix
- Implement auto-refresh in CLI startup path (before sending any API request, check
expiresAt, refresh if expired/near-expiry) - Surface 401 errors clearly on stderr (not just in jsonl)
- (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)
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗