【入門編】 JWTのjti(JWT ID)クレームを用いたリプレイ攻撃の完全防御 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

こんにちは!開発現場のセキュリティを守るホワイトハッカーの視点から、今回はWeb開発で避けて通れない「認証」の裏側、そして少し油断するとシステムが乗っ取られてしまう恐ろしい「リプレイ攻撃」の防ぎ方についてお話しします。

セキュリティ用語や暗号の話を聞くと、「なんだか難しそうだな…」「自分にはまだ早いかも」と身構えてしまうことってありますよね。でも大丈夫です!一歩ずつ、身近な例えから紐解いていけば、誰でも確実に強固なシステムを作れるようになります。

今回は、現代のWebアプリケーションでよく使われるJWT(JSON Web Token)を安全に使いこなすための、とても大切な鍵である jti(JWT ID)クレームを使ったリプレイ攻撃の完全防御について、一緒に楽しく学んでいきましょう!

—

1. 家の鍵と合鍵のコピーに例える「リプレイ攻撃」の恐怖

まずは、サイバー攻撃者が狙う「リプレイ攻撃(再春攻撃)」がどういうものなのか、私たちの身近な生活に置き換えて考えてみましょう。

想像してみてください。あなたはオートロックのスマートなマンションに住んでいて、スマホに届く「一度だけ開く電子的な解錠パスコード」を使ってエントランスに入るとします。
もし、あなたがエントランスの鍵を開けた瞬間、背後でこっそりそのパスコードを盗み見た泥棒がいたらどうなるでしょうか?

泥棒が、あなたが通り過ぎたあとにその「全く同じパスコード」をもう一度エントランスのリーダーにかざしたら……。マンションのシステムが「お、さっきと同じ正しいコードですね!どうぞお入りください」と勘違いして、ドアを開けてしまったら大問題ですよね。

これがリプレイ攻撃です。
ネットワークの世界でも全く同じことが起きるんです。ユーザーがログインしたときに発行される「アクセストークン(身分証明書のようなもの)」を悪意ある攻撃者に盗み見られ、それを何度もサーバーに「再送(リプレイ)」されることで、ユーザーになりすまして不正アクセスされてしまうのです。

「署名があるから安全」の大きな勘違い

ここで、よくある開発者の勘違いポイントがあります。
「うちのシステムはJWTを使っていて、ちゃんと秘密鍵で署名(Signature)しているから、中身を改ざんされることはないよ。だから安全なはず!」と思っていませんか?

確かに、JWTの署名があれば「中身が途中で書き換えられていないこと」は保証できます。しかし、「そのトークンが過去に一度使われたものかどうか」までは、署名だけでは判断できないのです。本物のチケットのコピーを何度も使い回すようなもので、署名が本物であっても、使われた「タイミング」や「回数」をチェックする仕組みがなければ、泥棒の侵入を許してしまいます。

—

2. すべてのチケットに「シリアルナンバー」を振る:jti(JWT ID)とは?

では、このリプレイ攻撃から身を守るにはどうすればよいのでしょうか?
答えは簡単です。映画のチケットやコンサートの入場券を思い浮かべてみてください。すべてのチケットに、世界に一つだけの「シリアルナンバー(整理番号)」が印刷されていますよね。一度もぎられたチケットは、二度と再入場に使えません。

JWTの世界でもこれと全く同じことを行います。それが、JWTのペイロード(中身のデータ)に含める jti(JWT ID)クレーム です。

jti には、UUID(Universally Unique Identifier:重複する確率が奇跡的に低いランダムな文字列)などを設定し、発行するすべてのトークンに「世界で一つだけの固有のID」を付与します。

サーバー側では、次のようなルールでトークンを管理します。

1. ユーザーがログインしたら、ユニークな jti を持ったJWTを発行する。
2. ユーザーからリクエストが届いたら、JWTの署名を検証し、さらに jti の値を取り出す。
3. 「この jti は、すでに過去に使われていないか?」をデータベースや高速なキャッシュサーバー(Redisなど)で確認する。
4. まだ使われていなければOK。その jti を「使用済みリスト」に登録し、処理を実行する。
5. もし、すでに「使用済みリスト」に載っている jti だったら、「このトークンは使い回しだ!」と即座にブロックする。

この仕組みを取り入れるだけで、たとえトークンが盗み見られても、一度使われたら二度と使えない「ワンタイムチケット」に変貌させることができるのです。

—

3. 実装のステップ:サーバー側での具体的な防御コード

それでは、実際にバックエンド(今回はPHPの擬似コードをベースにします)で、どのように jti の重複チェックとリプレイ攻撃の防御を実装するのかを見ていきましょう。

実務でそのまま参考にできるように、日本語のコメントを丁寧に添えています。

<?php
// ユーザーからのリクエストを受け取り、JWTの検証とリプレイ攻撃対策を行うサンプル関数
function verifyTokenAndPreventReplay($jwtToken, $redisClient, $secretKey) {
    try {
        // 1. JWTの署名を検証し、ペイロード(中身)をデコードする
        // (※実際の開発では firebase/php-jwt などの信頼できるライブラリを使用してください)
        $decodedPayload = JWT::decode($jwtToken, new Key($secretKey, 'HS256'));
        
        // 2. ペイロードから一意なIDである 'jti' クレームを取り出す
        if (!isset($decodedPayload->jti)) {
            // jtiが含まれていない古いトークンや不正なトークンは拒否する
            throw new Exception("セキュリティエラー: トークンに有効なjtiが含まれていません。");
        }
        $jti = $decodedPayload->jti;

        // 3. トークンの有効期限(exp)を取得する
        $expirationTime = $decodedPayload->exp;
        $currentTime = time();
        
        // 有効期限までの残り時間を計算(Redisの有効期限設定に利用するため)
        $ttl = $expirationTime - $currentTime;
        if ($ttl <= 0) {
            throw new Exception("セキュリティエラー: このトークンは有効期限切れです。");
        }

        // 4. Redisなどの高速なストレージを使い、この 'jti' がすでに使われたかチェックする
        // Redisの SETNX (Set if Not Exists) コマンドに似た挙動を利用します
        $cacheKey = "used_jti:" . $jti;
        
        // すでにキーが存在するか確認
        if ($redisClient->exists($cacheKey)) {
            // 【警告】すでに使われているjtiを発見!リプレイ攻撃の可能性大です!
            // ログに記録し、不正アクセスとして厳重に対処します
            error_log("【警告】リプレイ攻撃を検知しました。jti: " . $jti);
            throw new Exception("セキュリティエラー: このトークンはすでに使用されています。再利用は許可されていません。");
        }

        // 5. 初めて使用されるトークンなので、「使用済み」としてRedisに記録する
        // 重要: トークン本来の有効期限(exp)と同じ秒数だけRedisに保持し、期限が切れたら自動削除させる
        // (無限にメモリを消費しないための実務的なテクニックです)
        $redisClient->setex($cacheKey, $ttl, "used");

        // 6. 検証クリア!安全なリクエストとして次の処理へ進む
        return [
            "success" => true,
            "userId" => $decodedPayload->sub
        ];

    } catch (Exception $e) {
        // エラーメッセージを返却(詳細はログに隠し、ユーザーにはシンプルに伝えるのがコツ)
        return [
            "success" => false,
            "message" => $e->getMessage()
        ];
    }
}

実装時の大切なポイント(現場の知恵)

上記のコードで注目してほしいのが、「Redisの有効期限(TTL)をJWT自体の有効期限に合わせている点」です。

もし、使用済み jti を永遠にデータベースに保存し続けたらどうなるでしょうか? システムが長年運用されるにつれてデータが膨れ上がり、データベースがパンクしてしまいますよね。
JWTにはもともと exp(Expiration Time:有効期限)という寿命が設定されているため、「そのトークンが生きている期間だけ、使用済みリストに残しておけば十分」なのです。寿命が切れたトークンは、どうあがいても使えないため、リストから消えてもセキュリティ上の問題は一切ありません。このメモリ効率を意識した設計こそが、プロのインフラ・バックエンドエンジニアの腕の見せ所です。

—

4. まとめ:一歩ずつ、堅牢なシステムを作っていこう

今回は、共通鍵・公開鍵暗号の基本を押さえた上で、JWTの jti クレームを使ったリプレイ攻撃の完全防御について解説しました。

  • 署名だけではリプレイ攻撃を防げない(本物のチケットの使い回しに注意)。
  • すべてのトークンに固有の識別子 jti(UUIDなど)を持たせる。
  • サーバー側(Redisなど)で一度使われた jti を記録し、二重使用を弾く。
  • メモリを圧迫しないよう、jti の保存期間はJWTの有効期限(exp)に連動させる。

セキュリティ対策というと、なんだか壁が高く感じられるかもしれませんが、仕組みを一つずつ紐解いていけば、私たちが日常生活で使っている防犯の知恵と全く同じであることに気づくはずです。

「難しそう」を「なるほど、こうやれば安全なんだ!」に変えていく楽しさを感じながら、ぜひ皆さんの開発現場でも jti を取り入れた堅牢な認証基盤を実装してみてくださいね。一歩ずつ、安全で信頼されるエンジニアへの階段を登っていきましょう!

コメント

タイトルとURLをコピーしました