- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- JWT、JWS、JWE — まず用語の整理から
- JWS の解剖 — ヘッダー、ペイロード、署名
- 攻撃手法 1 — alg 混同攻撃
- 攻撃手法 2 — kid インジェクション
- クレーム検証 — 署名だけ見るのは半分の検証
- 対称 vs 非対称 — そして EdDSA
- JWKS ベースの鍵ローテーション戦略
- 安全な検証コード — Java / Node / Go
- 保存場所の論争 — localStorage vs Cookie
- Revocation 問題 — JWT のアキレス腱
- アンチパターン集
- おわりに
- 参考資料
はじめに
JWT(JSON Web Token)は現代の認証インフラの血液のような存在です。OIDC の ID トークン、OAuth の access token、マイクロサービス間の認証、AI エージェントの委任証明まで — ほぼすべてのトークンが JWT 形式で流れています。Keycloak 26.6 が JWT Authorization Grant をサポートし始めたことからも分かるように、JWT の活用範囲は 2026 年も広がり続けています。
問題は、JWT が「署名された JSON にすぎない」というシンプルさゆえに、検証を雑に実装したシステムがあまりにも多いことです。alg を none に差し替えて署名検証を通過させ、RS256 の公開鍵を HMAC の秘密鍵に化けさせてトークンを偽造し、kid ヘッダーに SQL インジェクションを試みる攻撃は、CTF の問題ではなく実際の CVE リストに載っている事件です。
本記事では、JWT/JWS/JWE の構造から始め、既知の攻撃手法と防御法、JWKS ベースの鍵ローテーション戦略、言語別の安全な検証コード、そして永遠の宿題である revocation 問題まで、実務に必要なほぼすべてを扱います。
JWT、JWS、JWE — まず用語の整理から
3 つの用語はよく混同されますが、レイヤーが異なります。
| 仕様 | RFC | 役割 |
|---|---|---|
| JWT | RFC 7519 | クレーム(主張)を運ぶトークン形式の抽象定義 |
| JWS | RFC 7515 | 署名で完全性を保証する具体的なシリアライズ(大半の JWT) |
| JWE | RFC 7516 | 暗号化で機密性まで保証するシリアライズ |
| JWK / JWKS | RFC 7517 | 鍵と鍵セットの JSON 表現 |
| JWA | RFC 7518 | 使用可能なアルゴリズムのリスト |
一般に JWT と呼ばれるものの 99% は JWS Compact Serialization です。重要な違いを 1 つだけ覚えるなら、これです。
JWS は署名するだけ。ペイロードは誰でも読める。 Base64URL は暗号化ではありません。トークンに個人情報や秘密を入れてはいけない理由です。機密性が必要なら JWE を使うか、そもそもトークンに入れないことです。
JWS の解剖 — ヘッダー、ペイロード、署名
JWS Compact Serialization はドット(.)で区切られた 3 つの部分です。
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDYta2V5In0
.
eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbS9yZWFsbXMvbXlyZWFsbSIsInN1YiI6ImFsaWNlIiwiYXVkIjoib3JkZXItYXBpIiwiZXhwIjoxNzgwMDAwOTAwLCJpYXQiOjE3ODAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└── BASE64URL(header) . BASE64URL(payload) . BASE64URL(signature)
デコードすると、ヘッダーとペイロードは次のような JSON です。
{
"alg": "RS256",
"typ": "JWT",
"kid": "2026-06-key"
}
{
"iss": "https://idp.example.com/realms/myrealm",
"sub": "alice",
"aud": "order-api",
"exp": 1780000900,
"iat": 1780000000,
"scope": "openid profile orders:read"
}
署名は次のように計算されます。
signing_input = BASE64URL(header) + "." + BASE64URL(payload)
RS256: signature = RSASSA-PKCS1-v1_5-SHA256( private_key, signing_input )
ES256: signature = ECDSA-P256-SHA256( private_key, signing_input )
EdDSA: signature = Ed25519( private_key, signing_input )
HS256: signature = HMAC-SHA256( shared_secret, signing_input )
核心となる洞察: ヘッダーは攻撃者が自由に操作できる入力値です。alg、kid、jku、x5u のようなヘッダーパラメータを「信頼できるメタデータ」として扱った瞬間、脆弱性が生まれます。検証ロジックがヘッダーの指示に従うのではなく、サーバーが事前に定めたポリシーがヘッダーを検閲しなければなりません。
攻撃手法 1 — alg 混同攻撃
none アルゴリズム攻撃
JWA 仕様には署名なしの「Unsecured JWT」のための alg none が定義されています。初期のライブラリはトークンヘッダーの alg をそのまま信じていたため、攻撃者がヘッダーを次のように変えると:
{
"alg": "none",
"typ": "JWT"
}
署名部分を空にしたトークンが「検証通過」と処理される事故がありました(2015 年の複数ライブラリの CVE)。ペイロードを自由に偽造できるため致命的です。
偽造トークン: BASE64URL(noneヘッダー) . BASE64URL(偽造ペイロード) .
└ 署名なし
RS256 → HS256 鍵混同攻撃
より巧妙な変種です。サーバーが RS256(非対称)検証を行い公開鍵を保持しているとき、攻撃者が alg を HS256(対称)に変えるとどうなるでしょうか。
1. サーバーの RSA 公開鍵は公開されている (JWKS エンドポイントなど)
2. 攻撃者: alg を HS256 に変更した偽造トークンを作成
3. 攻撃者: HMAC の秘密鍵として「RSA 公開鍵の PEM 文字列」を使って署名
4. 脆弱なサーバー: alg=HS256 を見て HMAC 検証を実行
このとき検証鍵として RSA 公開鍵 (サーバーが持つまさにその鍵) を使用
5. HMAC(公開鍵PEM, ペイロード) が一致 → 偽造トークンが通過
公開鍵は秘密ではないため、攻撃者も同じ鍵で HMAC 署名を作れるという点を悪用したものです。
防御 — アルゴリズム許可リストの固定
防御原則はただ 1 つです。検証時に許可するアルゴリズムをサーバーコードにハードコードし、トークンヘッダーの alg はそのリストと照合するだけにする。
悪いコード: verify(token, key) ← alg をトークンが決定
良いコード: verify(token, key, algorithms=[RS256]) ← alg をサーバーが決定
最新のライブラリの多くはアルゴリズムの明示を強制しますが、レガシーコードと自前実装の検証ロジックは必ず点検すべきです。また、鍵タイプとアルゴリズムファミリーを紐付けて管理すれば(RSA 鍵は RS/PS 系のみ、EC 鍵は ES 系のみ)、鍵混同自体が不可能になります。
攻撃手法 2 — kid インジェクション
kid(Key ID)ヘッダーは「どの鍵で検証するか」を知らせるヒントです。サーバーが kid 値を検証せずに使うと、インジェクションの通り道になります。
シナリオ A: パストラバーサル (path traversal)
ヘッダー: "kid": "../../../../dev/null"
サーバーが kid でファイルシステムから鍵を読む構造なら
/dev/null (空の内容) が鍵になる → 空鍵で署名した偽造トークンが通過
シナリオ B: SQL インジェクション
ヘッダー: "kid": "x' UNION SELECT 'attacker-known-secret' --"
サーバーが SELECT key FROM keys WHERE kid = '...' のように照会すると
攻撃者が知る値が検証鍵として返される
シナリオ C: jku/x5u ヘッダーの悪用
ヘッダー: "jku": "https://evil.com/jwks.json"
サーバーが jku の URL から鍵を取得すると攻撃者の鍵で検証してしまう
防御法は次のとおりです。
- kid は不透明な識別子としてのみ扱い、事前にロードした鍵マップから完全一致で照会します。一致する項目がなければ即座に拒否します。
- kid 値に形式制約を設けます(英数字とハイフンのみ許可など)。
- jku、x5u ヘッダーは無視するか、許可リストの URL(自社 IdP の JWKS)のみ使用します。
- 鍵解決の経路にファイルシステム/DB の動的照会を入れません。
クレーム検証 — 署名だけ見るのは半分の検証
署名が有効ということは「IdP が発行した」という意味にすぎず、「この API のために、今、この用途で発行した」という意味ではありません。標準クレームをすべて検証する必要があります。
| クレーム | 意味 | 検証ルール |
|---|---|---|
| iss | 発行者 | 期待する issuer URL と完全一致 |
| aud | 対象 | 自サービスの識別子が含まれているか確認 |
| exp | 有効期限 | 現在時刻が exp 以前か(clock skew 60〜120 秒許容) |
| nbf | 有効開始 | 現在時刻が nbf 以後か |
| iat | 発行時刻 | 異常に未来/過去のトークンを拒否 |
| sub | 主体 | 空でないか、内部ユーザーモデルとマッピングできるか |
| typ / token_use | トークン種別 | access token と ID トークンの混用を遮断 |
特に aud 検証の欠落は最もよくあり、かつ最も危険なミスです。同じ IdP が発行したトークンであっても、「決済 API 用トークン」で「管理者 API」を呼べてしまうからです。マイクロサービス環境ではサービスごとに固有の audience を付与し、各サービスが自分の audience だけを受け入れるようにすることで、トークンの横移動(lateral movement)を防げます。
ID トークンと access token の混用にも注意が必要です。ID トークンは「クライアントにユーザーが誰かを知らせる」ためのもので、API 呼び出しの認可に使ってはいけません。RFC 9068(JWT access token プロファイル)はヘッダーの typ を at+jwt と明示することで、この混用を構造的に遮断します。
対称 vs 非対称 — そして EdDSA
アルゴリズム比較
| アルゴリズム | 種類 | 鍵 | 特徴 |
|---|---|---|---|
| HS256 | 対称 HMAC | 共有秘密鍵 | 速い。検証者も発行できてしまう(否認防止不可) |
| RS256 | 非対称 RSA | 2048 ビット以上 | 最も広く互換。署名サイズが大きい(256 バイト) |
| PS256 | 非対称 RSA-PSS | 2048 ビット以上 | RS256 の改良版(確率的パディング) |
| ES256 | 非対称 ECDSA P-256 | 256 ビット | 署名 64 バイト。ノンス再利用で鍵漏洩の危険 |
| EdDSA (Ed25519) | 非対称 | 256 ビット | 決定的署名、高速でサイドチャネル耐性。2026 年の推奨 |
選択基準はシンプルです。
- 単一サービスが自分のトークンを発行/検証: HS256 でも可能ですが、秘密鍵がすべての検証者に広がった瞬間、発行権限も広がります。構成が大きくなりそうなら最初から非対称にしましょう。
- IdP が発行し複数サービスが検証: 必ず非対称。検証者は公開鍵しか持たないため、漏れても偽造は不可能です。
- 新システムのアルゴリズム: EdDSA(Ed25519)が第一候補です。ECDSA のノンス再利用問題のない決定的署名で、署名/検証が速く署名サイズも小さい。Keycloak 26.6 から EdDSA を正式サポートしているため、realm の鍵プロバイダーで Ed25519 を選択できます。ただし古いクライアントライブラリの互換性は事前確認が必要です。
トークンサイズとパフォーマンス
JWT はすべてのリクエストヘッダーに載って運ばれるため、サイズはそのままコストです。
おおよそのサイズ比較 (同一クレーム基準)
HS256: ヘッダー+ペイロード + 32 バイト署名 → 最小
ES256: ヘッダー+ペイロード + 64 バイト署名
EdDSA: ヘッダー+ペイロード + 64 バイト署名
RS256: ヘッダー+ペイロード + 256 バイト署名 → Base64 後に約 342 文字増加
注意: ペイロードの肥大化のほうが大きな問題
- グループ/権限リストを丸ごと入れたトークンが 8KB を超え、
Web サーバーのヘッダー上限 (デフォルト 8KB) を超過する事故が頻発
- 権限はトークンに「参照」だけを入れ、詳細は API で照会する設計を推奨
検証パフォーマンスは、EdDSA と ECDSA の検証が RSA の検証より速いか同等で、RSA は特に署名生成が遅いです。トークン発行量の多い IdP ほど EdDSA の利点が大きくなります。
JWKS ベースの鍵ローテーション戦略
署名鍵を永遠に使ってはいけません。鍵が古くなるほど漏洩の可能性が蓄積し、漏洩時の被害範囲も大きくなります。標準パターンは JWKS(JSON Web Key Set)エンドポイントと kid を利用した無停止ローテーションです。
JWKS エンドポイント (Keycloak の例)
GET https://idp.example.com/realms/myrealm/protocol/openid-connect/certs
{
"keys": [
{
"kid": "2026-06-key",
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"use": "sig",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
},
{
"kid": "2026-03-key",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
}
]
}
無停止ローテーションの手順は次のとおりです。
ステップ 1: 新しい鍵を生成し JWKS に追加 (署名はまだ旧鍵で)
→ すべての検証者が新鍵をキャッシュする時間を確保
ステップ 2: 署名鍵を新鍵に切り替え (旧鍵は JWKS に維持)
→ 旧鍵で署名された未失効トークンも引き続き検証される
ステップ 3: 旧鍵で署名されたトークンがすべて失効した後、
旧鍵を JWKS から削除
タイミングルール:
ステップ1 → ステップ2 の間隔 >= 検証者の JWKS キャッシュ TTL
ステップ2 → ステップ3 の間隔 >= access token の最大有効期間
Keycloak はこのパターンを鍵プロバイダーの priority と active/passive 状態で実装しています。新鍵を高い priority で追加すると署名が切り替わり、旧鍵は passive として検証のみを担当させ、後で削除します。
検証者側のベストプラクティスも重要です。
- JWKS をリクエストごとに取得せずキャッシュします(TTL 5〜15 分)。
- 未知の kid に遭遇したら、1 回だけ JWKS を強制更新してみます。ローテーション直後の自然な状況です。それでもなければ拒否します。
- JWKS の更新に失敗したら、既存キャッシュで動作を維持します(可用性)。
- JWKS エンドポイント呼び出しに rate limit を設け、偽造 kid の殺到による DoS を防ぎます。
安全な検証コード — Java / Node / Go
Java(Spring Security Resource Server)
最も安全な方法は自分で検証しないことです。Spring Security に委ねましょう。
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com/realms/myrealm
audiences: order-api
// 追加のクレーム検証が必要な場合 (JwtDecoder のカスタマイズ)
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(
"https://idp.example.com/realms/myrealm");
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer(
"https://idp.example.com/realms/myrealm");
OAuth2TokenValidator<Jwt> withAudience = new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("order-api"));
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
return decoder;
}
issuer-uri 方式は OIDC discovery で JWKS の場所とサポートアルゴリズムを自動構成し、デフォルトで exp/nbf と issuer を検証します。audience の検証は上記のように明示的に追加する必要がある点を忘れないでください。
Node.js(jose ライブラリ)
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://idp.example.com/realms/myrealm/protocol/openid-connect/certs'),
{ cooldownDuration: 30000, cacheMaxAge: 600000 }
);
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://idp.example.com/realms/myrealm',
audience: 'order-api',
algorithms: ['EdDSA', 'RS256'], // 許可アルゴリズムをハードコード
clockTolerance: '60s',
});
if (payload.token_use && payload.token_use !== 'access') {
throw new Error('not an access token');
}
return payload;
}
jose の createRemoteJWKSet は kid マッチング、キャッシュ、未知の kid 時の 1 回再取得まで処理してくれます。algorithms を必ず明示する習慣が重要です。
Go(lestrrat-go/jwx)
package auth
import (
"context"
"errors"
"time"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
var cache *jwk.Cache
func InitJWKS(ctx context.Context) error {
cache = jwk.NewCache(ctx)
return cache.Register(
"https://idp.example.com/realms/myrealm/protocol/openid-connect/certs",
jwk.WithMinRefreshInterval(10*time.Minute),
)
}
func VerifyAccessToken(ctx context.Context, raw string) (jwt.Token, error) {
keySet, err := cache.Get(ctx,
"https://idp.example.com/realms/myrealm/protocol/openid-connect/certs")
if err != nil {
return nil, err
}
tok, err := jwt.Parse([]byte(raw),
jwt.WithKeySet(keySet), // kid ベースの鍵選択
jwt.WithIssuer("https://idp.example.com/realms/myrealm"),
jwt.WithAudience("order-api"),
jwt.WithAcceptableSkew(60*time.Second), // clock skew の許容
jwt.WithValidate(true),
)
if err != nil {
return nil, err
}
if tok.Subject() == "" {
return nil, errors.New("missing sub claim")
}
return tok, nil
}
3 言語に共通する原則は同じです。(1) アルゴリズム許可リストの固定、(2) issuer/audience の明示的検証、(3) JWKS キャッシュと kid マッチングはライブラリに委任、(4) 自分で Base64 デコードして検証ロジックを手書きしない。
保存場所の論争 — localStorage vs Cookie
SPA でトークンをどこに置くかは 10 年続く論争です。整理すると次のとおりです。
| 保存場所 | XSS 露出 | CSRF 露出 | 備考 |
|---|---|---|---|
| localStorage | 脆弱(JS で読める) | 安全 | 窃取されるとトークン自体が漏洩 |
| sessionStorage | 脆弱 | 安全 | タブを閉じると消滅、同様の限界 |
| メモリ(JS 変数) | 部分的に脆弱 | 安全 | リロードで消失、窃取の難度は上がる |
| HttpOnly Cookie | 安全(読み取り不可) | 脆弱 → SameSite で緩和 | トークン値自体は保護される |
冷静な現実認識が必要です。XSS が起きたら、どこに保存していてもほぼゲームオーバーです。 HttpOnly クッキーであっても、攻撃スクリプトがユーザーのブラウザから直接 API を呼べばよいからです。ただし、トークン値そのものの流出(オフラインでの悪用、別 IP からの使用)を防ぐという点で、HttpOnly クッキーに優位性があります。
2026 年時点の推奨順序は次のとおりです。
- BFF パターン: トークンはサーバーにのみ存在。ブラウザには HttpOnly + Secure + SameSite=Strict のセッションクッキー。SPA セキュリティの事実上の決着案です。
- BFF が難しい場合: access token はメモリ、refresh token は rotation を有効にしたうえで HttpOnly クッキー。
- localStorage に refresh token を置くのは避けましょう。XSS 一発で長期クレデンシャルが丸ごと流出します。
Revocation 問題 — JWT のアキレス腱
JWT の最大の利点である「ステートレス検証」は、最大の弱点でもあります。発行されたトークンは exp まで有効で、サーバーが「このトークンだけ無効」と宣言する標準手段がありません。解決策はトレードオフのスペクトラムです。
完全 stateless ◄──────────────────────────────► 完全 stateful
短い有効期間 denylist introspection opaque トークン
(5〜15分) (jti ブロックリスト) (RFC 7662) + セッションストア
即時遮断不可 無効化のみ保存 毎リクエスト IdP 照会 JWT を放棄
実務での推奨の組み合わせは次のとおりです。
- access token の有効期間を 5〜15 分に短縮 — 窃取被害の窓を構造的に縮めます。大半のサービスはこれだけで十分です。
- refresh token は stateful に管理 — rotation + reuse detection + サーバー側無効化。「ログアウト」と「強制遮断」は refresh レイヤーで実現されます。
- 高リスク操作には追加検証 — 決済、権限変更などは introspection(RFC 7662)や再認証を要求します。
- 非常用 denylist — インシデント対応時に特定の jti/sub を遮断する短 TTL キャッシュ(Redis など)を用意しておきます。exp が短いため denylist も小さく保てます。
- イベント駆動の伝播 — 大規模環境なら、OpenID Shared Signals/CAEP でセッション無効化イベントを購読するアーキテクチャも検討に値します。
アンチパターン集
最後に、現場で繰り返し発見されるアンチパターンを整理します。
| アンチパターン | 何が問題か | 修正 |
|---|---|---|
| トークンをデコードするだけで検証を省略 | Base64 デコードは検証ではない | 署名+クレームの全検証 |
| alg をトークンヘッダーから読んで分岐 | alg 混同攻撃の入口 | 許可リストのハードコード |
| aud 検証の省略 | トークンの横移動を許す | サービスごとに固有の audience |
| ペイロードにパスワード/PII を保存 | JWS は平文で読める | JWE または保存しない |
| 有効期限のないトークン発行 | 永久クレデンシャル化 | exp 必須、短く |
| HS256 の秘密鍵を短い文字列に | 総当たりに脆弱 | 256 ビット以上のランダム鍵 |
| エラーメッセージに検証失敗理由を詳細に露出 | 攻撃者へのフィードバック | 一般化した 401 応答 |
| 署名検証をゲートウェイのみが実施 | 内部直接呼び出しが無検証 | zero trust、各サービスが検証 |
おわりに
JWT セキュリティの本質は一文に要約できます。トークンの言うことではなく、サーバーが定めたポリシーを信じること。 alg も kid もクレームもすべて攻撃者が埋められる入力値であり、検証コードはこの入力値をポリシーの物差しで検閲する門番です。
- 構造: JWS は署名のみ、機密性は JWE。ペイロードは公開情報として扱いましょう。
- 攻撃: alg none、RS256→HS256、kid インジェクションはすべて「ヘッダーを信じる」ことから生まれます。アルゴリズムと鍵の出所をサーバーに固定しましょう。
- クレーム: iss/aud/exp/nbf を漏れなく。特に aud 検証がマイクロサービスの横移動を防ぎます。
- 鍵管理: JWKS + kid で無停止ローテーションを自動化し、新システムでは EdDSA を優先的に検討しましょう(Keycloak 26.6 で正式サポート)。
- ライフサイクル: 短い access token + stateful な refresh token + 非常用 denylist の組み合わせが現実的なバランスです。
検証コードは一度書けば数年間すべてのリクエストを通過させるコードです。本記事のチェックリストを手に、いま運用中のサービスの検証ロジックを一度点検してみてください。
参考資料
- RFC 7519 - JSON Web Token (JWT)
- RFC 7515 - JSON Web Signature (JWS)
- RFC 7516 - JSON Web Encryption (JWE)
- RFC 7517 - JSON Web Key (JWK)
- RFC 7518 - JSON Web Algorithms (JWA)
- RFC 8725 - JSON Web Token Best Current Practices
- RFC 9068 - JWT Profile for OAuth 2.0 Access Tokens
- RFC 7662 - OAuth 2.0 Token Introspection
- RFC 8037 - CFRG Elliptic Curve Signatures in JOSE (Ed25519)
- RFC 9700 - Best Current Practice for OAuth 2.0 Security
- OpenID Connect Core 1.0
- Keycloak Documentation
- Keycloak 26.6.0 Release Notes