JWTとセキュリティトークン:仕組みと安全な運用ガイドのイメージ

なぜJWTのセキュリティに注意が必要なのか

JWTは、Web開発で最も誤った実装をされやすいセキュリティ機構の一つです。ライブラリのおかげで「とりあえず動くもの」は簡単に作れます——トークンは発行でき、検証もでき、一見問題なさそうに見えます。問題が表面化するのは後になってから。セキュリティ監査のとき、インシデントのとき、あるいは攻撃者が微妙な設定ミスを突いてきたときです。

この記事では、既知の攻撃手法、JWKSによる鍵配布、本番運用の実践的なチェックリストを解説します。

おさらい:JWTとは

JWT(JSON Web Token)は、ドット(.)で区切られた3つのBase64Urlエンコード済みの部分で構成されます。

header.payload.signature
  • Header:アルゴリズム(alg)とトークン種別を指定。
  • Payload:クレーム(主体に関するデータとメタデータ)を格納。
  • Signature:HeaderとPayloadが改ざんされていないことの暗号学的な証明。

Payloadは エンコード されているだけで 暗号化 はされていません。JWTを傍受した者は誰でもPayloadを読めます。署名は完全性を証明するだけで、秘匿性は提供しません。

攻撃手法1:alg: none 攻撃

JWT仕様は当初、アルゴリズム値 "none"(署名なし)を許容していました。一部の実装は鍵の有無を確認せずにこれを受け入れ、攻撃者が任意のペイロードでトークンを偽造できてしまいました。

攻撃シナリオ:

  1. 攻撃者が正規のJWTを傍受する。
  2. ヘッダーとペイロードをデコードする(鍵不要のBase64Urlデコード)。
  3. ペイロードを改ざんする:"role": "admin"
  4. "alg": "none" で再エンコードし、空の署名セグメントを送る。
  5. 脆弱なサーバーがトークンを受け入れる。

対策:

// jose — アルゴリズムを明示的に許可リスト化
import * as jose from 'jose';

const { payload } = await jose.jwtVerify(token, secret, {
  algorithms: ['HS256'],  // 'none' は絶対に含めない
});

検証に使うアルゴリズムを、トークン自身が指定するアルゴリズムに依存して決めてはいけません。アルゴリズムはサーバー側で強制します。

攻撃手法2:アルゴリズム混同(RS256 → HS256)

非対称JWTは署名に秘密鍵、検証に公開鍵を使います。脆弱な実装の一部は、検証アルゴリズムをJWTヘッダーの alg フィールド——つまり 攻撃者が制御できる フィールド——から決定します。

攻撃シナリオ(公開鍵サーバー):

  1. サーバーは通常RS256を使う。秘密鍵で署名し、クライアントは公開鍵で検証する。
  2. 攻撃者はサーバーの公開鍵を知っている(公開されているのだから当然)。
  3. 攻撃者は "alg": "HS256" のJWTを作る。
  4. 公開鍵をシークレットとしてHMAC-SHA256で署名する。
  5. 脆弱なサーバーはヘッダーの alg: HS256 を読んでHMAC検証に切り替え、公開鍵をHMACシークレットとして使い、偽造トークンを検証してしまう。

対策: 期待するアルゴリズムをサーバー側でハードコードし、検証方式の選択に alg クレームを信頼しないこと。

import * as jose from 'jose';
const publicKey = await jose.importSPKI(publicKeyPem, 'RS256');
const { payload } = await jose.jwtVerify(token, publicKey, {
  algorithms: ['RS256'],  // トークンのalgクレームを無視して固定
});

攻撃手法3:弱い/露出したシークレット

HS256(対称HMAC)では、署名と検証に同じシークレットを使います。このシークレットが、短い(32バイト未満)、推測可能(“secret” など)、公開されているソースコードにハードコードされている、といった場合、攻撃者は総当たりや推測で突破できます。一度シークレットを手に入れれば、任意のJWTを偽造できます。

対策:

  • 最低256ビット(32バイト)の暗号学的に安全なランダム値を使う。
  • シークレットは環境変数やシークレットマネージャー(AWS Secrets Manager、HashiCorp Vault)に保存する。
  • バージョン管理にコミットしない。
# 強いシークレットを生成(Linux/Mac)
openssl rand -base64 32

攻撃手法4:クレーム検証の漏れ

署名の検証だけでは不十分です。署名が有効でも、そのトークンを このリクエストで 受け入れてよいとは限りません。

クレーム検証しないと
exp期限切れトークンが永久に受け入れられる
nbf有効化前のトークンが受け入れられる
iss別サービスが発行したトークンが受け入れられる
audサービスA向けのトークンをサービスBが受け入れる
const { payload } = await jose.jwtVerify(token, secret, {
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
  // exp と nbf は jose が自動で検証する
});

JWKSによる鍵管理

本番システム、特に複数サービスや外部連携があるシステムでは、対称的な共有シークレットは管理しきれなくなります。トークンを検証する必要があるすべてのサービスがシークレットを知る必要があり、攻撃対象領域が広がります。

JWKS(JSON Web Key Set) は非対称暗号でこれを解決します。

  1. 認可サーバー が鍵ペア(秘密鍵+公開鍵)を生成する。
  2. 秘密鍵は認可サーバーに留まり、JWTの署名に使われる。
  3. 公開鍵は、標準化されたJSON形式で JWKSエンドポイント という既知のURLに公開される。
  4. どのサービスも、JWKSエンドポイントから公開鍵を取得してJWTを検証できる。

JWTのヘッダーには kid(Key ID)クレームが含まれ、検証側がJWKS内のどの鍵を使うかを示します。

{ "alg": "RS256", "typ": "JWT", "kid": "2026-01-key" }

JWKSの利点:

  • 鍵ローテーション: 新しい鍵を追加し、古いトークンの期限切れを待ってから古い鍵を削除する——再起動やシークレット配布が不要。
  • シークレット共有ゼロ: サービスは公開鍵だけを持てばよい。検証サービスが侵害されても署名能力は侵害されない。
  • 複数発行者対応: 複数のJWKSエンドポイントを確認することで、複数プロバイダーのトークンを検証できる。
import * as jose from 'jose';

// joseのJWKSクライアントは鍵をキャッシュし、未知のkidで再取得する
const JWKS = jose.createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json')
);

const { payload } = await jose.jwtVerify(token, JWKS, {
  issuer: 'https://auth.example.com',
  audience: 'https://api.example.com',
});

JWKSジェネレーター を使えば、鍵ペアを作成し、どういうJSON形式で公開されるのかをブラウザ上でシミュレートできます。

トークン失効の戦略

JWTはステートレスなので、一度発行されると期限切れまで有効です。これは課題を生みます——ログアウト、パスワード変更、アカウント侵害の後など、トークンを即座に無効化したい場合はどうするのでしょうか?

戦略1:短い有効期限+リフレッシュトークン

  • アクセストークンは15〜60分で期限切れ。
  • リフレッシュトークン(HttpOnly Cookie)は7〜30日。
  • 失効はリフレッシュトークンのレベルだけで行えばよい。

戦略2:失効リスト(ブロックリスト)

失効した jti(JWT ID)クレームを、トークンの有効期限に合わせたTTLで高速ストア(Redis)に保存します。

// ログアウト時
await redis.set(`blocklist:${payload.jti}`, '1', { EX: remainingSeconds });

// リクエストごとに確認
const isRevoked = await redis.exists(`blocklist:${payload.jti}`);
if (isRevoked) throw new Error('トークンは失効しています');

クライアントでの安全な保存

JWTをどこに保存するかが、脆弱性の範囲を決めます。

保存先XSSリスクCSRFリスク推奨
localStorageなしトークンには避ける
sessionStorageなしトークンには避ける
JavaScript変数(メモリ)なし短期保持ならOK
HttpOnly Cookieなしあり(SameSiteで緩和)推奨

Webアプリで最も安全なのは、アクセストークンをメモリ(JavaScript変数)に、リフレッシュトークンを HttpOnlySecureSameSite=Strict Cookieに保存する方法です。

本番セキュリティ・チェックリスト

  • 署名シークレットは最低256ビットでランダム生成されている
  • シークレットはコードではなく環境変数/シークレットマネージャーに保存
  • alg はサーバー側で明示指定(トークンヘッダーから読まない)
  • alg: none は決して受け入れない
  • exp クレームを設定(アクセストークンは15〜60分推奨)
  • issaud クレームを毎リクエスト検証
  • 失効が必要なトークンには jti クレームを含める
  • トークンの発行・検証エンドポイントでHTTPSを強制
  • リフレッシュトークンは HttpOnly Cookieに保存(localStorage 不可)
  • 複数サービスなら非対称鍵配布にJWKSを使用
  • 鍵ローテーション手順を文書化・テスト済み

DevToolKitsで体感する

言葉だけでは難しい概念も、動かせば腑に落ちます。

  • JWTツール:トークンがどんな「断片」でできているか、ブラウザ上で安全に覗ける。
  • JWKSジェネレーター:鍵ペアを作り、公開時のJSON形式をシミュレートできる。
  • ハッシュ生成ツール:SHA-256などでデータが「指紋」に変わる様子を試せる。

よくある質問

HS256とRS256のどちらを使うべき?

HS256(対称):実装が簡単で、1つのサービスが発行も検証も行う単一サービス構成に適します。鍵を使うすべての場所で秘密にする必要があります。

RS256(非対称):複数サービス構成に推奨。秘密鍵は認可サーバーだけが持ち、他のサービスはJWKS経由で公開鍵を使って検証します。設定は複雑ですが、セキュリティ面で大きく優れます。

ダウンタイムなしで鍵をローテーションするには?

新旧両方の公開鍵をJWKSエンドポイントに同時に公開します。新しいトークンは新しい鍵(新しい kid)で署名します。古い鍵で署名された古いトークンは、JWKS内の古い公開鍵で引き続き検証できます。古いトークンがすべて期限切れになったら、古い鍵をJWKSから削除します。

JWTの暗号化はある?

あります。JWE(JSON Web Encryption) はペイロードを暗号化し、復号鍵なしには中身を読めません。標準のJWT(JWS)は署名するだけで暗号化はせず、ペイロードは誰でも読めます。JWEはペイロード自体を秘匿する必要がある場合にのみ使います。

同じJWTを認証と認可の両方に使える?

両方をペイロードに含めるのは一般的です。sub クレームがユーザーを識別し(認証)、rolescope などのカスタムクレームが何をできるかを制御します(認可)。ただしトークンサイズに注意し、頻繁に変わる大量の認可データを含めるのは避けましょう。