claude-code - 💡(How to fix) Fix OAuth auto-refresh fails in non-interactive (subprocess) mode → 401 after token expiry [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#53063Fetched 2026-04-25 06:13:21
View on GitHub
Comments
1
Participants
2
Timeline
5
Reactions
1
Timeline (top)
labeled ×4commented ×1

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).

Error Message

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

Root Cause

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).

Fix Action

Fix / Workaround

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).

Manual verification (workaround works)

  • 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)

Code Example

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

---

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
RAW_BUFFERClick to expand / collapse

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\"}"
  6. 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)

extent analysis

TL;DR

The Claude CLI OAuth auto-refresh fails in non-interactive mode, causing 401 authentication errors, and can be worked around by manually refreshing the token using the refresh_token endpoint.

Guidance

  • The issue is likely caused by the Claude CLI not handling token refresh correctly in non-interactive mode, so checking the expiresAt time and refreshing the token if necessary should be implemented.
  • To verify the issue, run the Claude CLI in non-interactive mode and check the ~/.claude/.credentials.json file to see if the expiresAt time is updated after a refresh.
  • To mitigate the issue, users can implement an external watchdog script, like auth_watchdog.py, to periodically refresh the token.
  • The client_id used in the manual refresh workaround should be documented for users who want to implement their own solutions.

Example

import json, urllib.request

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

# Refresh token
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)

# Update credentials
creds["claudeAiOauth"]["accessToken"] = new["access_token"]
creds["claudeAiOauth"]["refreshToken"] = new["refresh_token"]
creds["claudeAiOauth"]["expiresAt"] = int(time.time()) + new["expires

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

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.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix OAuth auto-refresh fails in non-interactive (subprocess) mode → 401 after token expiry [1 comments, 2 participants]