クリスマスに起きた「リフレッシュトークンの乱」

〜JWT / refresh token / 401 ループ地獄からの完全復旧記録〜

はじめに

2025年のクリスマス当日、
自作アプリ Mylers Track にて、認証周りでなかなか香ばしい不具合に遭遇した。

症状だけ見るとよくある話だ。

  • アクセストークン期限切れ
  • リフレッシュトークンで更新されるはず
  • なのに 401が返り続ける
  • ログイン画面に飛ぶ時と、飛ばない時がある
  • しかもアプリは「動いているように見える」

だが、掘っていくと JWT設計・環境変数・旧実装の負債 が複合的に絡んだ、なかなかの地雷原だった。

この記事は、その 切り分けから復旧までの全記録 である。


システム構成(前提)

  • モバイルアプリ(iOS / Android)
  • APIサーバー(PHP)
  • 認証方式:
    • Access Token(JWT, 短命)
    • Refresh Token(JWT, 長命・DB保存)
  • 通信方式:
    • Authorization: Bearer <access_token>

① 最初の症状:refresh されない

ログを見ると、こんな状態だった。

GET /check → 401
POST /refresh → 401

refresh が 401を返す=更新不能 なので、
アプリは正しくログイン画面へ遷移する。

これはまだ理解できる。


② refresh を直したら、今度は「変に動く」

refresh のバグを修正し、
refresh が 200を返す ようになると、今度はこうなった。

check_session → 401
refresh_token → 200
check_session → 401
list_events → 401

問題はここ。

  • refresh は成功している
  • なのに access token が効かない
  • それでもアプリはログイン画面に行かず「動いているように見える」

これは 一番危険な状態

UIは正常なのに、
サーバー的には「お前、誰?」状態。

位置情報アプリでこれをやると、

「走ってる本人は正常だと思ってるけど、
サーバーは全部捨てている」

という最悪のUXになる。


③ JWTが壊れている? → 否

まず疑ったのはこれ。

  • Authorization ヘッダが壊れている?
  • トークンが連結されている?
  • 途中で切れている?

そこで確認:

dotCount = substr_count($jwt, '.');

結果:

dotCount = 2

JWTの形式(header.payload.signature)は正常。
壊れていない。


④ 次に出た決定打:「Signature verification failed」

サーバーのJWT検証で出ていたエラーはこれ。

Signature verification failed

これは 期限切れではない

意味はただ一つ。

発行時に使った secret と、検証時の secret が違う


⑤ 環境変数の地雷

ここで .env を見直して、すべてが繋がった。

過去の実装

$secretKey = $_ENV['SECRET_KEY'];
JWT::encode(..., $secretKey, 'HS256');

新しい実装

$ACCESS_SECRET  = getenv('ACCESS_SECRET') ?: 'ACCESS_SECRET';
$REFRESH_SECRET = getenv('REFRESH_SECRET') ?: 'REFRESH_SECRET';

ところが .env にあったのは これだけ

SECRET_KEY=xxxxx

つまり、

  • 発行:SECRET_KEY
  • 検証:ACCESS_SECRET(未設定 → デフォルト文字列)

当然、署名が一致しない


⑥ さらに恐ろしい事実

もし環境変数が未設定だった期間があれば、その間に発行されたトークンは、

ACCESS_SECRET
REFRESH_SECRET

という 固定文字列 で署名されている可能性がある。

これはもはやセキュリティ事故寸前。


⑦ 正しい復旧方法

今回取った方針はこれ。

✔ 即時復旧

.env に以下を明示的に設定。

ACCESS_SECRET=(SECRET_KEYと同値)
REFRESH_SECRET=(SECRET_KEYと同値)

✔ 旧トークン救済

refresh 検証では 複数secretを試す

$REFRESH_SECRETS = [
 REFRESH_SECRET,
 SECRET_KEY(旧)
];

✔ DB照合優先

refresh token は DB に保存されているため、

  • DB一致 → OK
  • DBに無い → 無効

という 台帳方式 に寄せた。


⑧ 最終確認ログ

iOS / Android 両方でこれが揃った。

check_session → 200
list_events → 200

完全復旧。


⑨ 教訓(これが一番大事)

×ダメな挙動

  • 401なのにアプリが動いている
  • refresh成功=復旧成功と誤認
  • 401ループ

〇正しい挙動

  • 401 → refresh 1回だけ
  • それでも401 → 即ログイン画面
  • 未認証状態で位置情報送信しない

「飛ばされてOK」な設計が、結果的に一番安全。


おわりに

クリスマスにトークンで殴り合う羽目になったが、
結果として

  • 認証設計の穴
  • 環境変数運用の怖さ
  • 「動いてるように見える」ことの危険性

を一気に洗い出せた。

来週、アクセストークン期限切れの実地テストをして、
本当に安定していればこの件は完全終了。

同じ沼にハマる人が一人でも減れば幸いである。