〜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」な設計が、結果的に一番安全。
おわりに
クリスマスにトークンで殴り合う羽目になったが、
結果として
- 認証設計の穴
- 環境変数運用の怖さ
- 「動いてるように見える」ことの危険性
を一気に洗い出せた。
来週、アクセストークン期限切れの実地テストをして、
本当に安定していればこの件は完全終了。
同じ沼にハマる人が一人でも減れば幸いである。

