Claude Codeのログイン期限—自動更新で延びないclaude -pの対処法

このMacに保存された claude.ai サインインの終わりは 2026-12-03T03:20:20Z。Claude Code 2.1.318 で claude auth status に加わった loginExpiresAt に出ていました。毎週土曜20時に launchd から claude -p を回すこのブログの自動投稿は、このままだと 12月5日の回で認証エラーになります。

目次

loginExpiresAtでサインインの終わる日を確認する

claude auth statusはJSONと–textで出し方が違う

claude auth status
{
  "loggedIn": true,
  "authMethod": "claude.ai",
  "apiProvider": "firstParty",
  "loginExpiresAt": "2026-12-03T03:20:20.758Z",
  "subscriptionType": "max",
  "storedClaudeAiLogin": true
}

email・orgId など7項目は省いています。同じ値を --text で出すと、1点ではなく幅になりました。

claude auth status --text | grep "Sign-in"
Sign-in ends: between 2026-11-30 and 2026-12-03 (2026-12-03T03:20:20.758Z)

「between」の左端は、JSON の値からちょうど3日引いた日付です。claude auth status はモデルを呼ばないので、cron から毎日叩いても料金はかかりません。

claude -pのJSON出力ではcredential_expires_at

claude -p "OKとだけ返して" --model haiku --max-turns 1 --output-format json \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["credential_expires_at"], d["total_cost_usd"], d["duration_ms"])'
2026-12-03T03:20:20.758Z 0.004147619999999999 1364

キー名は違っても、中身は loginExpiresAt と同じ値です。1回 $0.0041・1.4秒。実行結果の JSON を保存しているジョブなら、どの回の時点で何日残っていたかを後から追えます。無人の -p で何が止められたかを OpenTelemetry で拾う話は、Claude Code safety_eventの読み方—素の-pでは拒否が1件出ない に書きました。

自動更新ではサインインの終わりが延びない

公式ドキュメントの “Renew an expiring login” には、期限が近づいたときの扱いがこう書かれています。

Run /login to renew. The warning is informational and never blocks a request: authentication keeps working until the login actually expires.

警告はリクエストを止めず、期限までは普通に動く。延ばす手段として挙がっているのは /login だけです。Claude Code を毎日使っていれば勝手に延びる、とはどこにも書かれていません。

同じ節は “Renewing early matters most for sessions that run unattended.” と続きます。agent view のバックグラウンドセッションや Remote Control が期限を越えると、サインインし直すまで先に進めない、という説明です。launchd から毎週起動する -p も、期限を越えた回から認証で落ちます。

loginExpiresAtの中身はリフレッシュトークンの期限

2.1.319 のバイナリから文字列を抜くと、値の出どころと警告文が見つかりました。

strings -n 12 ~/.local/share/claude/versions/2.1.319 \
  | grep -o -E 'var s=86400000,d=3,u=7\+d|let t=e\.refreshTokenExpiresAt|Renewing it does not move (those dates|that date)' \
  | sort | uniq -c
   2 Renewing it does not move that date
   2 Renewing it does not move those dates
   1 let t=e.refreshTokenExpiresAt
   1 var s=86400000,d=3,u=7+d

loginExpiresAt は、保存済み認証情報の refreshTokenExpiresAt(リフレッシュトークンの期限)をそのまま返しています。refreshToken を持たない認証では、この関数は null を返します。警告文には “Renewing it does not move those dates.” とあり、トークンが自動で更新されても終わりの日付は動きません。

Keychain の項目の更新日時も見ました。

security find-generic-password -s "Claude Code-credentials" | grep -E '"(cdat|mdat)"'
    "cdat"<timedate>=0x32303236303632373131303034325A00  "20260627110042Z\000"
    "mdat"<timedate>=0x32303236313130363036323333335A00  "20261106062333Z\000"

この項目は今日の 06:23:33Z、このセッションを起動した時刻に書き換わっていました。それでも loginExpiresAt は 12-03T03:20:20Z のまま。-w を付けなければトークン本体は表示されないので、属性だけ見るならこのコマンドで足ります。

3日幅は定数d=3で決まっている

上の d=3 が --text に出る「between」の幅です。Claude Code は期限の3日前を earliest、期限そのものを latest として表示し、警告文も “(in N days at the earliest)” と earliest 基準で数えます。なぜ3日なのかは、ドキュメントにもコードにも説明がありません。

運用の締め切りは latest でなく earliest に置きます。このMacなら12月3日ではなく11月30日です。

サインインの有効期間はドキュメントにない

何日もつのかは、Authentication ページのどこにも書かれていません。手元の ~/.claude/history.jsonl を見ると、対話モードで /login を打った日は 6/27・7/27・8/24・9/23・10/21 で、間隔は27〜30日でした。claude auth login はこのファイルに残らないので、正確な起点は追えません。無人ジョブは、月に1回は切れるものとして組みます。

警告は3日前と10日前の2種類

同じ期限でも、対話モードと -p では警告が出始める日が7日ずれます。

場面出始める時点出力先根拠
対話モードlatest の3日前起動時の画面“Renew an expiring login”
claude -p(テキスト出力)latest の10日前stderr に1行CHANGELOG 2.1.318
claude -p(JSON 出力)警告は出ないcredential_expires_at に値だけCHANGELOG 2.1.318
/status期限切れの後Login 行に Expired — log in againv2.1.210 以降

対話モードは起動時に3日前から

文面は Your login expires in 3 days · run /login to renew。出始める latest の3日前は、--text の earliest と同じ時刻にあたります。

claude -pはstderrに10日前から

u=7+d の10日がこの窓です。期限までは約27日残っているので、以下は実行して出たものではなく、バイナリ内の文字列をそのまま引いています。

Warning: this sign-in ends between <earliest> and <latest> (in <N> days at the earliest). Renewing it does not move those dates. Run claude auth login before then, or use claude setup-token for unattended jobs.

<> の3か所は、バイナリでは変数の埋め込みになっている部分です。

earliest の日に入ると “can end at any time from now until …”、latest を過ぎると “may have ended between …” に文面が切り替わります。

launchdの無人実行では警告が誰にも届かない

run_blog.shはstderrをcron.logに混ぜて終わる

このブログの投稿は run_blog.sh が claude -p "$(cat prompts/blog.md)" --dangerously-skip-permissions 2>&1 で回しています。10日前の警告は stderr の1行なので、2>&1 で cron.log に流れ込むだけです。

macOS の通知センターに上げるのは、終了コードが0以外のときだけ。警告はリクエストを止めないので実行は成功し、通知は飛びません。cron.log は今の時点で 235KB。その中の英文1行を毎週探す運用にはなっていません。

土曜20時の5回を並べると警告は1回だけ

期限と警告の窓を、土曜20時(JST)の実行に当てはめました。判定はバイナリと同じく「残り10日未満で警告」「earliest までの日数は UTC の日付差」で計算しています。

from datetime import datetime, timedelta, timezone

DAY = 86400
expires = datetime.fromisoformat("2026-12-03T03:20:20.758+00:00")
earliest = expires - timedelta(days=3)
jst = timezone(timedelta(hours=9))

run = datetime(2026, 11, 7, 20, 0, tzinfo=jst)
while run < datetime(2026, 12, 6, tzinfo=jst):
    left = (expires - run).total_seconds()
    days_to_earliest = int(earliest.timestamp() // DAY) - int(run.timestamp() // DAY)
    if left <= 0:
        state = "期限切れ(認証で失敗)"
    elif left < 10 * DAY:
        state = f"stderrに警告(at the earliest {days_to_earliest}日)"
    else:
        state = "警告なし"
    print(f"{run:%m/%d %H:%M}  残り{left / DAY:5.1f}日  {state}")
    run += timedelta(days=7)
11/07 20:00  残り 25.7日  警告なし
11/14 20:00  残り 18.7日  警告なし
11/21 20:00  残り 11.7日  警告なし
11/28 20:00  残り  4.7日  stderrに警告(at the earliest 2日)
12/05 20:00  残り -2.3日  期限切れ(認証で失敗)

警告が cron.log に入るのは 11/28 の1回だけです。それを見落とすと、次の 12/5 の回で投稿が止まります。

期限切れのclaude -pは認証エラーで止まる

Login expiredとOAuth token has expiredは別のエラー

エラー一覧のページでは “Login expired” と “OAuth token revoked or expired” が別の節に分かれていて、-p での文面も違います。

  • 保存済みログインが切れた場合: Failed to authenticate: OAuth session expired and could not be refreshed
  • CLAUDE_CODE_OAUTH_TOKEN が切れた場合: Failed to authenticate. API Error: 401 OAuth token has expired ...

前者では、更新に失敗した時点で Claude Code が保存済み認証情報を消し、以後はリクエストを送らずに手元で止めます。v2.1.206 より前は残った認証情報のまま送り、401 で返ってきていました。直すには同じ環境で claude を起動してログインし直すか、claude auth login を打ちます。

401でもsubtypeはsuccessのまま

期限切れの状態は手元で作れないので、ダミーのトークンで 401 を起こしました。API が弾くので料金は $0 です。

CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-dummy \
  claude -p "OKとだけ返して" --model haiku --max-turns 1 --output-format json \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ["subtype","is_error","api_error_status","terminal_reason","total_cost_usd"]})'
echo "rc=${PIPESTATUS[0]}"
{'subtype': 'success', 'is_error': True, 'api_error_status': 401, 'terminal_reason': 'api_error', 'total_cost_usd': 0}
rc=1

subtype は success のまま。subtype == "success" で成否を見ているスクリプトは、この認証エラーを成功として扱ってしまいます。判定には is_error と終了コード(1)を使います。

テキスト出力でも終了コードは1で、Failed to authenticate. API Error: 401 OAuth access token is invalid. は stderr ではなく stdout に出ました。ドキュメントが -p 向けに挙げる構造化コード authentication_failed は、今回の JSON には含まれていませんでした。キーで見るなら is_error と api_error_status です。

claude setup-tokenの1年トークンに切り替える

発行してCLAUDE_CODE_OAUTH_TOKENで渡す

“Generate a long-lived token” 節によると、claude setup-token は CI やスクリプト向けに1年有効な OAuth トークンを発行します。実行すると /login と同じ認可画面がブラウザで開き、承認するとターミナルにトークンが表示されます。

Claude Code はこのトークンをどこにも保存しません。控えておいて、使う環境の CLAUDE_CODE_OAUTH_TOKEN に入れます。必要なプランは Pro・Max・Ultra・Team・Enterprise のいずれかです。できるのはモデルへのリクエストだけで、Remote Control の確立や claude.ai コネクタの取得には使えません(Claude Code Remote Controlの設定—スマホ操作と切れる原因の潰し方 で触れた制限です)。--bare 付きの実行はこの変数を読まないので、bare で回すジョブは ANTHROPIC_API_KEY か apiKeyHelper を使います。

認証の優先順位では/loginより先に使われる

“Authentication precedence” の7段のうち、CLAUDE_CODE_OAUTH_TOKEN は5番目、/login の保存済みログインは最後の7番目です。変数を入れれば保存済みログインより先に使われ、401 になっても保存済みログインには切り替わりません(v2.1.225 以降)。ANTHROPIC_API_KEY は3番目。シェルに残っていると、-p では確認なしで API キーのほうが使われます。

保存済みログインとOAuthトークンとAPIキーの違い

保存済みログインCLAUDE_CODE_OAUTH_TOKENANTHROPIC_API_KEY
有効期間記載なし(手元では27〜30日)1年今回は確認していない
保存場所macOS は Keychain、Linux は ~/.claude/.credentials.json自分で管理自分で管理
loginExpiresAt値が入るnullnull
期限前の警告3日前・10日前なしなし
課金サブスクリプションサブスクリプションAPI 従量
--bare読まない読まない読む

null の2列は、どちらもダミー値を入れた claude auth status で確かめました。

preflightで期限を見張る

auth statusの終了コードではトークンの中身を見ていない

CLI リファレンスの説明は “Exits with code 0 if logged in, 1 if not.” です。

CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-dummy claude auth status > /dev/null && echo "rc=0"
rc=0

中身がでたらめなトークンでも loggedIn: true で、終了コードは0でした。loggedIn の判定に、トークンが有効かどうかは入っていません。残り日数も終了コードには出ないので、claude auth status || notify ... で見張ると、気づくのは切れた後です。

earliestを締め切りにして判定する

import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone

margin_days = float(sys.argv[1]) if len(sys.argv) > 1 else 10

out = subprocess.run(["claude", "auth", "status"], capture_output=True, text=True).stdout
status = json.loads(out)
expires_at = status.get("loginExpiresAt")

if expires_at is None:
    print(f"authMethod={status.get('authMethod')}: loginExpiresAt=null")
    sys.exit(0)

expires = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
earliest = expires - timedelta(days=3)
left = (earliest - datetime.now(timezone.utc)).total_seconds() / 86400
print(f"earliest={earliest:%Y-%m-%d} latest={expires:%Y-%m-%d} 残り{left:.1f}日")
sys.exit(20 if left < margin_days else 0)
python3 login_expiry_check.py; echo "rc=$?"
python3 login_expiry_check.py 30; echo "rc=$?"
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-dummy python3 login_expiry_check.py; echo "rc=$?"
earliest=2026-11-30 latest=2026-12-03 残り23.9日
rc=0
earliest=2026-11-30 latest=2026-12-03 残り23.9日
rc=20
authMethod=oauth_token: loginExpiresAt=null
rc=0

既定の10日は -p の警告と同じ窓ですが、こちらは earliest から数えるので3日早く鳴ります。run_blog.sh に組み込むなら、preflight の直後に python3 login_expiry_check.py || notify "mohablog 自動投稿" "claude auth login が必要" を1行足す形になります。

トークン認証では loginExpiresAt が null なので、このスクリプトでは判定できません。1年トークンの期限は Claude Code から読めないため、発行した日はカレンダーかパスワードマネージャーに自分で残します。

まとめ

  • 2.1.318 以降、claude auth status の loginExpiresAt と -p JSON の credential_expires_at でサインインの終わりが分かる。中身は refreshTokenExpiresAt で、自動更新では動かない
  • 締め切りは latest の3日前にあたる earliest。--text の「between」の左端がそれ
  • -p の警告は10日前から stderr に1行出るだけで、終了コードは0。401 の JSON も subtype は success なので、is_error と終了コードで判定する
  • 無人ジョブは preflight で loginExpiresAt を読むか、claude setup-token の1年トークンに移す。トークンの期限は Claude Code から読めない
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次