Claude Code "Login expired" in Scheduled Runs: Fix It
A scheduled Claude Code job that authenticates with a saved claude.ai login will eventually stop working with Login expired · Please run /login, and nothing on an unattended box can answer that prompt. The fix is to stop depending on the saved login for jobs nobody watches: use ANTHROPIC_API_KEY, an apiKeyHelper, or a one-year token from claude setup-token in CLAUDE_CODE_OAUTH_TOKEN. The documentation says sessions on those credentials never see this message.
This page was checked on 2026-11-05 against the Claude Code authentication, errors and CLI reference pages and the changelog, with Claude Code 2.1.318 (published 2026-11-04). We could not reproduce an actual expiry: that needs a stored login that is close to its end, and this session had none. What we did run is marked "run" below. Everything else is documented behavior, marked "docs".
What the error means (docs)
Claude Code tried to renew the saved claude.ai login, the OAuth service rejected the stored refresh token, and Claude Code cleared the saved credentials. After that, each model request stops locally with the message, before it reaches the API, because only /login can create new credentials.
In non-interactive mode (-p) and the Agent SDK the structured error code is authentication_failed. The non-interactive text is Failed to authenticate: OAuth session expired and could not be refreshed.
Two details matter for automation:
- Retrying does nothing. The errors page says retrying without signing in shows the same message on every request.
- It is not the same as the API rejecting a token. Claude Code itself produces
Login expiredfor a login it already failed to renew, so no request is sent.
Before v2.1.206, Claude Code sent the request anyway with whatever credential remained in the environment, and you saw a model error or a 401 instead. If your logs from an old install look different, that is why.
Which credentials never see it (docs)
The errors page states that sessions authenticated with an API key, CLAUDE_CODE_OAUTH_TOKEN, or a third-party provider do not use the saved login and never see this message. The authentication page lists the order Claude Code picks a credential, highest first:
- Cloud provider credentials (Bedrock, Vertex, Foundry variables)
ANTHROPIC_AUTH_TOKENANTHROPIC_API_KEY(in-pmode the key is always used when present)apiKeyHelperoutputCLAUDE_CODE_OAUTH_TOKEN- Anthropic profile and federation credentials
- Subscription OAuth credentials from
/login
So a saved /login is the last thing consulted. A job that sets any of the first five is not using it. A job that sets none of them is, which is the common accident: you logged in once on the server to try it, the cron job worked, and weeks later it did not.
Option 1: an API key, billed per token
Set ANTHROPIC_API_KEY from a Claude Console key in the job's environment. This is the route our other pages use (running Claude Code on a schedule, the unattended VPS setup). It has no login to expire. It is billed per token rather than against a subscription, so it is a cost decision as well as a reliability one.
Option 2: a one-year token from setup-token (docs)
For people who want a subscription to cover unattended runs, the authentication page describes claude setup-token:
claude setup-token
It opens the same browser authorization as /login and prints a one-year OAuth token after you approve. It does not save the token anywhere. You copy it and export it where the job runs:
export CLAUDE_CODE_OAUTH_TOKEN=your-token
Limits the documentation states:
- It requires a Pro, Max, Ultra, Team or Enterprise plan.
- The token can only make model requests. It cannot establish Remote Control sessions or fetch claude.ai connectors. MCP servers you configure locally still work.
- Bare mode (
--bare) does not readCLAUDE_CODE_OAUTH_TOKEN. A script that passes--barehas to useANTHROPIC_API_KEYor anapiKeyHelper. - If you run
/loginwhile the variable is set, the current session switches to the new login. To keep that login in new sessions, remove the variable from your shell profile and from settings files that can set it.
"One-year" also means the job breaks again in a year. Put the renewal date in a calendar when you mint it. We found no documented way to read the token's expiry from claude auth status (see below).
Option 3: an apiKeyHelper for rotating credentials (docs)
If a vault hands out short-lived keys, apiKeyHelper runs your script to fetch one. The authentication page notes a warning when the helper takes more than 10 seconds, and that a script that errors, times out or prints nothing makes requests fail with Your apiKeyHelper script is failing within three attempts. That is a louder failure than a silent expiry, which is the point.
Seeing an expiry coming
Five signals exist for a saved claude.ai login:
| Signal | Where it shows | Source |
|---|---|---|
Your login expires in 3 days · run /login to renew | Startup banner, when the login is within three days of ending | docs |
Login row that reads "Expired" with a prompt to log in again | /status, since v2.1.210 | docs |
loginExpiresAt | claude auth status JSON, added in 2.1.318 | changelog |
credential_expires_at | claude -p JSON result, added in 2.1.318 | changelog |
| One-line stderr warning, starting 10 days before the sign-in ends | claude -p text output, added in 2.1.318 | changelog |
The banner and the /status row are interactive, so a cron job never shows them to anyone. The 2.1.318 additions are the ones that help unattended work, and the stderr warning lands in your job log. We found the last three only in the changelog entry for 2.1.318, not in the authentication, CLI reference or headless pages we read, so treat the exact field behavior as unconfirmed until the docs catch up.
The authentication page adds that the warning appears only when a claude.ai login is the active credential. It does not appear when a cloud provider, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN or apiKeyHelper supplies the credential.
What we ran
On 2.1.318, with a scratch HOME, claude auth status printed JSON with authMethod set to oauth_token, loggedIn true and loginExpiresAt null. That session was authenticated by an injected token, not a stored claude.ai login, so null is what the field should show for a token credential if it only describes a stored login. That is our inference from one observation, not a documented statement. A claude -p "hi" --output-format json run on the same credential returned a normal result object with no credential_expires_at key at all, and claude auth status exited 0.
The CLI reference documents that claude auth status exits 0 when logged in and 1 when not, and that authMethod is one of none, claude.ai, oauth_token, api_key, api_key_helper or third_party. A wrapper script can use that today:
#!/usr/bin/env bash
# Run before the real job. Fails the job early with a clear log line.
set -euo pipefail
if ! out=$(claude auth status 2>&1); then
echo "claude auth status failed: $out" >&2
exit 1
fi
echo "$out" | grep -E '"(authMethod|loginExpiresAt)"' >&2 || true
Do not rely on the loginExpiresAt line being present on versions before 2.1.318.
Two jobs, one login: the refresh lock (docs)
If several Claude Code processes share one home directory, a saved login that needs renewing is refreshed under a shared lock. A second process that waits on a lock held by another process, or left behind by one that exited, can fail with Could not refresh your login because another Claude Code process is refreshing it. In -p mode the code is server_error and the message says it is usually transient: retry in a minute. This is not a rejected login. Staggering overlapping jobs, or giving them an API key or CLAUDE_CODE_OAUTH_TOKEN, avoids the lock entirely, because the documentation says those sessions never see this message.
The 2.1.318 changelog also lists improvements for saved logins on shared network home directories (less often deleted, briefly emptied or replaced by an older copy). If your agent home is on NFS or similar, that release is worth having, but we did not test it.
A checklist for a job that has to run next month
- Know which credential it uses:
claude auth statusand readauthMethod. - If it says
claude.ai, move the job to an API key or asetup-tokentoken. - Keep tokens out of the repo and out of the unit file. Use an env file with mode 600.
- Log stderr. The 10-day warning goes there.
- Write down the
setup-tokenrenewal date if you used one. - Fail the job loudly on a nonzero exit instead of swallowing it.
FAQ
Why did my cron job work for weeks and then fail with "Login expired"?
Because it was using the login you created once with /login. Claude Code renews that login automatically, and when the renewal fails it clears the credentials and every later request stops with Login expired. Use an API key, apiKeyHelper or CLAUDE_CODE_OAUTH_TOKEN for unattended jobs.
How long does a claude setup-token token last?
The documentation says it generates a one-year OAuth token. It is not saved anywhere by the command, so store it yourself.
Does ANTHROPIC_API_KEY ever show "Login expired"?
The errors page says sessions authenticated with an API key never see that message. A bad or revoked key fails differently, and a key belonging to a disabled or expired organization causes authentication failures, per the authentication page.
Can I check how long my login has left?
Interactively, /status shows an Expired Login row only after expiry, and a startup banner appears within three days. The 2.1.318 changelog adds loginExpiresAt to claude auth status and credential_expires_at to claude -p JSON, plus a stderr warning from 10 days out. We confirmed the field exists as null on a token credential and did not see a populated value.
Why does claude --bare ignore my setup-token token?
Bare mode does not read CLAUDE_CODE_OAUTH_TOKEN. Pass ANTHROPIC_API_KEY or an apiKeyHelper instead.