「ユーザーがログアウトしたはずなのに、誰かがアカウントを操作している」。これは以前、私が担当していた大規模なEコマースプラットフォーム(MAU 50万規模)で実際に発生したインシデントです。原因は、XSS脆弱性を突かれてローカルストレージから盗まれた長期間有効なリフレッシュトークンでした。アクセストークンの有効期限を15分に短縮していても、リフレッシュトークンが生きていれば攻撃者は無限に新しいアクセストークンを取得できてしまいます。
一般的に、JWT(JSON Web Token)はステートレスであることが利点とされていますが、セキュリティ要件が高い現代のWebセキュリティにおいては、完全なステートレス運用はリスクが高すぎます。本記事では、この問題を解決するための「リフレッシュトークンローテーション(Rotation)」と、トークン再利用を検知してセッションを即時無効化する戦略について、実戦レベルのコードと共に解説します。
現状分析:なぜ静的なリフレッシュトークンは危険なのか
当時のシステム環境は、バックエンドにNode.js (Express)、データベースにPostgreSQL、キャッシュ層にRedisを使用していました。認証基盤は自前で構築されており、JWTの署名検証のみに頼っていました。
攻撃のメカニズムは単純かつ致命的でした。攻撃者は窃取したリフレッシュトークンを使い、正規ユーザーが寝ている間もAPIを叩き続けていました。サーバー側は「署名が正しい」という理由だけで、機械的に新しいアクセストークンを発行し続けていたのです。これは典型的なJWTセキュリティの落とし穴です。
私たちは当初、トークンの有効期限(TTL)を極端に短くすることで対応しようとしましたが、これはUX(ユーザー体験)を著しく損ないました。ユーザーが頻繁に再ログインを強いられるようになったためです。そこで、OAuth 2.0のベストプラクティスでも推奨されている「ローテーション」の導入を決定しました。
失敗談:単純なホワイトリスト方式の限界
最初に試みたのは、単純な「有効なリフレッシュトークンのホワイトリスト化」でした。ユーザーIDをキーとして、現在有効なトークンをRedisに保存する方式です。
しかし、これには重大な欠陥がありました。ユーザーが複数のデバイス(PCとスマホなど)でログインする場合、単一のキーで管理すると、片方でトークンを更新した瞬間にもう片方がログアウトされてしまいます。デバイスごとの管理が必要だと気づきましたが、それ以上に問題だったのは「トークンが盗まれたこと自体を検知できない」という点でした。正規ユーザーがトークンをローテーションした後、攻撃者が古いトークンを使おうとしても、単に「無効なトークン」として拒否するだけでは、攻撃を受けている事実に気づけません。
解決策:トークンファミリーと再利用検知ロジック
最終的に採用したアーキテクチャは「トークンファミリー(Token Family)」という概念です。一連のリフレッシュトークンの連鎖を一つの家族として管理し、もし「既に使用済み(Rotated)」のトークンが提示された場合、それはトークンが盗難に遭っている証拠と見なします。
この場合、サーバーはそのファミリーに属する全てのトークンを即座に無効化し、正規ユーザーを含むすべてのセッションを強制終了させます。これにより、攻撃者を締め出し、正規ユーザーに再ログイン(パスワード変更など)を促すことができます。
// 依存ライブラリ: ioredis, jsonwebtoken
// Redis構造:
// Key: "rt_family:{userId}:{familyId}" -> Value: "{currentToken}"
// Set: "rt_used:{userId}:{familyId}" -> Value: ["oldToken1", "oldToken2"...]
const rotateRefreshToken = async (userId, familyId, incomingToken) => {
const redisKeyCurrent = `rt_family:${userId}:${familyId}`;
const redisKeyUsed = `rt_used:${userId}:${familyId}`;
// 1. 現在有効なトークンを取得
const currentToken = await redis.get(redisKeyCurrent);
// 2. [再利用検知ロジック]
// 入力されたトークンが現在のものではなく、かつ「使用済みリスト」に含まれているか確認
// ※ここでは簡易化のため、使用済み判定ロジックを概念的に記述
const isUsed = await redis.sismember(redisKeyUsed, incomingToken);
if (isUsed) {
console.warn(`[SECURITY ALARM] Reuse detected for User ${userId}. Invalidating family.`);
// クリティカル: トークンファミリー全体を削除(正規ユーザーもログアウトされる)
await redis.del(redisKeyCurrent);
await redis.del(redisKeyUsed);
throw new Error('Refresh token reuse detected. Account might be compromised.');
}
// 3. 有効なトークンかチェック(DBの最新と一致するか)
if (currentToken !== incomingToken) {
throw new Error('Invalid refresh token.');
}
// 4. ローテーション処理
const newToken = generateNewRefreshToken(userId, familyId);
// トランザクションでアトミックに更新
const pipeline = redis.pipeline();
pipeline.set(redisKeyCurrent, newToken, 'EX', 7 * 24 * 60 * 60); // 7日
pipeline.sadd(redisKeyUsed, incomingToken); // 古いトークンを使用済みに移動
pipeline.expire(redisKeyUsed, 7 * 24 * 60 * 60); // 使用済みリストも同期間保持
await pipeline.exec();
return newToken;
};
このコードの肝は、isUsed(再利用済み)の判定分岐にあります。通常の認証フローでは、クライアントは常に最新のトークンを持っています。もし古いトークンが送られてきた場合、それは「誰かが過去の通信を傍受した」か「盗んだトークンを攻撃者が使おうとした(しかし正規ユーザーが既にローテーションした後だった)」ことを意味します。
この厳格な認証システムの挙動こそが、セキュリティの要です。
パフォーマンスへの影響と検証
DB(Redis)へのアクセスが発生するため、純粋なステートレスJWTと比較してレイテンシへの懸念があるかもしれません。以下は、AWS ElastiCache(Redis)を使用して、t3.mediumインスタンスで負荷試験を行った際の結果比較です。
| 測定項目 | ステートレスJWT | ローテーション(Redis) | 影響度 |
|---|---|---|---|
| 認証処理時間 | 2ms | 14ms | 許容範囲内 |
| セキュリティ強度 | 低(有効期限依存) | 高(即時無効化可) | 劇的改善 |
| 同時接続数 (TPS) | 5,000 | 4,200 | 約16%低下 |
Redisへのラウンドトリップを含めても、認証処理のオーバーヘッドは10ms〜15ms程度に収まりました。この程度の遅延であれば、ユーザーが体感することはほぼ不可能です。むしろ、リフレッシュトークンローテーションによるセキュリティ向上のメリットが、わずかなパフォーマンス低下を補って余りある結果となりました。
Auth0: Refresh Token Rotationの公式解説並行リクエスト問題(Edge Cases)
この実装を本番投入した直後、特定の条件下で正規ユーザーが強制ログアウトされる問題が発生しました。原因はフロントエンドの「並行リクエスト(Race Condition)」です。
例えば、Reactアプリでページ読み込み時に複数のAPIを同時に叩き、その全てでアクセストークンの期限切れが検知されたとします。これら全てのリクエストが同時にリフレッシュトークン更新エンドポイントを叩くと、どうなるでしょうか?
- リクエストAがサーバーに到達し、ローテーション成功(トークンV1 → V2)。
- わずかに遅れてリクエストBがサーバーに到達。リクエストBはまだトークンV1を持っています。
- サーバーは「V1は使用済み」と判定し、セキュリティアラートを発動。ユーザーを強制ログアウトさせる。
この「誤検知」を防ぐためには、サーバー側で数秒間の「猶予期間(Grace Period)」を設けるか、フロントエンド側でリフレッシュ処理のキューイング(Mutexロックのような仕組み)を実装する必要があります。我々はフロントエンドでの制御(Axiosのインターセプターを利用してリフレッシュ中は他のリクエストを待機させる)を採用し、この問題を解決しました。
結論
JWTを用いた認証システムにおいて、リフレッシュトークンのローテーションと再利用検知は、セキュリティレベルを一段階引き上げるために不可欠な戦略です。Redisのような高速なKVSを組み合わせることで、パフォーマンスを犠牲にすることなく、セッションハイジャックのリスクを最小限に抑えることが可能です。特に金融系や個人情報を扱うアプリケーションでは、このアプローチが実質的な標準になりつつあります。
セキュリティと利便性のバランスを取りながら、堅牢なシステムを構築していきましょう。
Post a Comment