この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
/loginto 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 again | v2.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_TOKEN | ANTHROPIC_API_KEY | |
|---|---|---|---|
| 有効期間 | 記載なし(手元では27〜30日) | 1年 | 今回は確認していない |
| 保存場所 | macOS は Keychain、Linux は ~/.claude/.credentials.json | 自分で管理 | 自分で管理 |
loginExpiresAt | 値が入る | null | null |
| 期限前の警告 | 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と-pJSON の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 から読めない