ブラウザにログイン画面が開き、認証は通り、緑のチェックマークまで出る。なのに次に叩いたコマンドは、まるで一度もログインしていないかのように弾かれる。しかも直前まで動いていたのに、ある日を境に急に、だ。
「ログインし直せば直るだろう」と思って再ログインする。また成功する。また次のコマンドで落ちる(この時点で3回目のログイン画面を見せられている)。この無限ループに一度でも入ったことがあるなら、原因はおそらくログインそのものではない。ログインの成功と、その結果を保存する処理は別レイヤーで動いているというだけの話だ。エラーメッセージは律儀に「認証に失敗しました」と言ってくるが、聞くべき質問は本当は別にある。認証に失敗したのか、認証は成功して保存だけが失敗したのか。この2つ、見分ける気がないと一生見分けられない。
この記事で扱うこと
- OAuth認証を使うCLIツールが「ログインは成功したのに次が失敗する」状態に陥る典型パターン
- 原因がブラウザ認証ではなく、ローカルの資格情報保存(OS keyring)にあることの見つけ方
- keyring 保存を確実にする代わりに踏みがちな、もう一つの落とし穴
- GitHub CLI・AWS 周りのツール・Docker credential helper にも共通するこの構造
対象は特定のツールに限らない。OAuthでログインするCLIをコンテナ・CI・ヘッドレス環境で使っているなら、同じ穴が空いている可能性がある。
実際に起きたこと:ログインは通っていた
社内でGoogle Workspace系の操作をCLIから行うツールを、コンテナ環境の中で使っていた。ある日、そのコンテナ経由のリクエストがすべて失敗するようになった。
Authentication failed: Failed to get token: Server error: invalid_grant: Bad Request
invalid_grant は「リフレッシュトークンが失効している」ときに典型的に出るエラーだ。マウントもenvも正しかったので、素直にもう一度ログインし直した。ブラウザが開き、認証は通り、成功のレスポンスも返ってきた。ところが、直後に同じコマンドを叩くとまた同じエラーが出る。
原因を追っていくと、ログインコマンドの裏側でこう失敗していた。
OS keyring failed: Platform secure storage failure: User interaction is not allowed.
この失敗は、OAuthのブラウザ認証が成功した「後」に起きる。 ブラウザでの認証自体は通り、認証プロバイダ側にはちゃんと許可(grant)が作られる。ただし、その結果をローカルに保存する段階——OSの資格情報ストア(keyring)への書き込み——が失敗していた。ユーザーの目には「ログインできた」としか映らない。だから誰も気づけない。
実際、保存されていた資格情報のタイムスタンプを確認すると、直近の更新は2か月以上前のままだった。その間に何度も「ログインし直した」つもりの操作が、全部この経路で静かに失敗し続けていたことになる(2か月分のログイン試行が、1回も貯金されずに消えていたわけだ)。
なぜ気づけないのか
普通、認証エラーは「ログインしていない」か「権限がない」かのどちらかだと考える。どちらも、もう一度ログインすれば直る前提の話だ。今回のケースはその前提が崩れている。
「ログイン成功」の画面は、図でいう「grantが作られる」時点、つまり認証プロバイダ側の話だけを見て出ている。その後の「OS keyringへの書き込み」で保存に失敗しても、その事実を利用者に伝える経路がなければ、失敗は見た目のうえで消える。エラーが出ないことは、成功したことの証拠にならない。 では、何も表示されないなら安全だと思っていいのだろうか。今回のケースは、それがいちばん危ない前提だと教えてくれる。この構造は、CLIの認証周りに限らずよくあるパターンでもある。
保存が失敗する条件も特定の状況ではない。keyring backend が実際に触っているのは macOS の Keychain や、GNOME Keyring・KWallet(Linux)のような、GUIセッションを前提にしたセキュアストレージだ。以下のような環境ではそもそも「ユーザーに許可を尋ねる画面」を出す先がなく、書き込みが弾かれる。
| 実行環境 | keyring書き込みが失敗しやすい理由 |
|---|---|
| Dockerコンテナ | GUIセッション・ログインキーチェーンが存在しない |
| CIランナー | 非対話・ヘッドレスで確認ダイアログを出せない |
| サンドボックス化したエージェント実行環境 | セキュリティデーモンへのアクセス自体が権限で塞がれている |
| SSH経由の非対話シェル | ログインセッションのキーチェーンがアンロックされていない |
社内でAIエージェントの実行環境をサンドボックス化していたときにも、似た壊れ方に遭遇したことがある。エージェントの内部からGitHub CLIを呼ぶ処理が、認証済みのはずなのに~/.config/ghの読み込みで失敗していた。追ってみると、GitHub CLIのトークンもOSのセキュリティデーモン経由でkeyringに保存されており、サンドボックスの制限でそこへの到達自体が塞がれていた。ツールが違っても、壊れ方の骨格は同じだった(律儀に同じ穴に落ちてくれるので、原因の見当は早くつく)。
直し方:keyring を経由させない
対応はシンプルで、keyring以外の保存先を明示的に指定することに尽きる。多くのOAuth CLIは、環境変数で保存方式(backend)を切り替えられる。GitHub CLIを例にすると、認証状態は次のコマンドで確認できる。
gh auth status
ここで環境ごとに結果が割れるなら、保存方式が環境に依存している証拠だ。GUIセッションがある手元のMacでは通り、コンテナやCIでは通らない、という差が出る。
対応の方向性は、保存先をOS keyringからファイルベースの暗号化ストアに固定することだ。ツールによって環境変数名は異なるが、考え方は共通している。
# 例: 資格情報の保存方式(backend)をfileに固定する
export SOME_CLI_KEYRING_BACKEND=file
固定する場所も重要になる。シェルごとに別々に書くと、片方のシェルでだけ再発する。fish と zsh の両方を使っているなら、両方が読み込む共通の初期化ファイルに1回だけ書く方が事故が少ない。ホームディレクトリ配下の設定を宣言的に管理している場合は、シェル別のファイルではなく、両シェル共通で読まれる環境変数の定義箇所にまとめて書くのが安全だ。
もう一つの落とし穴:ファイルパスを固定しすぎる
keyringを回避できたところで、もう一段の罠がある。保存先を固定するのと、保存先を「ファイルパスで上書きする」のは別の話だ。
多くのOAuth CLIは、暗号化ファイルストア(例: credentials.enc)への保存とは別に、特定のトークンファイルを直接指すオプション(あるいは環境変数)を持っている。この2つは優先順位が対等ではないことが多く、後者が指定されていると前者より優先される。再ログインして暗号化ストアが更新されても、CLIは古いファイルパスの中身をそのまま読み続ける。
実測で確認した結果はこうだった。
| 実行条件 | 使われる資格情報 | 結果 |
|---|---|---|
| keyring backend を file に固定のみ | 暗号化ストア(直近の再ログインで更新済み) | 成功 |
| 上記に加えて、固定パスのトークンファイルも指定 | 固定パスのファイル(古いまま) | invalid_grant |
これは同じ「静かに失敗する」パターンの変種だ。エラーメッセージは同じinvalid_grantなので、一見すると「まだkeyring保存が直っていない」ように見える。実際には保存はもう直っていて、読む場所を自分で固定してしまっていたというオチになる。
この落とし穴は他のCLIにも形を変えて存在する。gcloud CLIのGOOGLE_APPLICATION_CREDENTIALSは、実はADC(Application Default Credentials)の外側にある別ルートではなく、ADCが資格情報を探す順序の最初のステップそのものだ。指定した瞬間に、gcloud auth application-default loginで作られた資格情報やアタッチ済みサービスアカウントより優先される。CI環境でよく使われるaws-vaultも、保存方式を--backend=fileのようにヘッドレス向けへ明示的に切り替えるオプションを持つ一方、AWS_ACCESS_KEY_IDのような環境変数を固定で流し込むと、その値が更新されない限りずっと使われ続ける。npm のNPM_TOKENをCI設定に長期固定しているケースも同じ構造で、トークンをローテーションしても設定側を書き換えない限り古いままだ。Docker credential helperはここまでの例と優先順位が逆で、credHelpersやcredsStoreを設定していれば、~/.docker/config.jsonに残っている平文のauthsエントリより優先される。ただし対象レジストリにヘルパーが設定されていない場合は、docker loginで書き込まれた古い平文エントリがそのまま使われ続けることがある。
「動的に更新される保存先」と「固定パス・固定値での上書き」が両方設定されていたら、後者を疑う。 優先順位のルールはツールごとに違っても、「上書き設定は更新されない」という性質だけは共通している。
手元で確認する
自分の環境がこの状態になっていないか、手元で実際に試せる範囲で確認する手順を置いておく。GitHub CLIを例にする。
まず、現在の認証状態とトークンの出どころを確認する。
gh auth status
Token: gho_**** のように出るだけでは、それがkeyring経由なのかファイル経由なのか分からない。macOSなら、Keychainに実際にエントリがあるか、保存日時が更新されているかを直接見られる。
security find-generic-password -s "gh:github.com" -g 2>&1 | grep -E "cdat|mdat"
gh auth login を実行した直後にこのコマンドを叩き、mdat(更新日時)が「今」に更新されていなければ、ログインは通っていてもローカルへの保存は失敗している。
環境変数による上書きが効いているかどうかも、実行するだけで分かる。
GH_TOKEN=<手元にある古いトークン> gh auth status
このときLogged in to github.com as ...が古いトークンの持ち主のまま表示されるようなら、環境変数の固定値がkeyring側の更新より優先されている状態だ。CI設定にGH_TOKENを長期固定している場合、意図せずこの状態に入っていないかを一度は確認しておく価値がある。
まとめ
「ログインできた」という画面は、ブラウザ側の認証成功しか保証していない。ローカルへの保存が別レイヤーで失敗していても、その画面は同じように出る。
- OAuthのブラウザ認証成功と、ローカルへの資格情報保存は別工程。片方の成功がもう片方を保証しない
- コンテナ・CI・サンドボックス化したエージェント実行環境では、OSのkeyringへの書き込みがそもそも成立しないことがある
- keyring を避けてファイルベースの保存に固定しても、固定パスや固定値での上書き設定が残っていると、今度はそちらが古いまま使われ続ける
- 疑わしいときは、保存日時や実際に使われているトークンの出どころを直接確認する。エラーメッセージだけでは「ログインが失敗した」のか「保存が失敗した」のか区別できない
コンテナ経由でCLIを動かしている環境があるなら、gh auth statusを1回叩いて出どころを確認するところから始めるのがいちばん早い(正直、コマンド1つで終わる。2か月気づかなかった話をした後だと、なおさら軽く感じる)。



