RFC 7519: JWT
base64urlは暗号化ではないという誤解から、algをトークンに選ばせる致命的な実装、そして失効できないという代償まで、RFC 7519を精読してJWTを使ってよい場面と避けるべき場面を判断できる。
- RFC 7519は、クレームをJSONで表してURLセーフな文字列に詰めるJWTの定義。署名はJWS・暗号化はJWEに分業されており、実務のJWTはほぼJWS署名付き——各部はbase64urlエンコードにすぎず暗号化ではない。
- 登録済みクレームはiss・sub・aud・exp・nbf・iat・jtiの7つだが、expを含め全て任意。日時はミリ秒でなくエポック秒のNumericDateなので、Date.now()をそのまま入れてはならない。
- alg=noneの許容とRS256→HS256のアルゴリズム混同は、いずれもトークン自身のalgを信じたことが原因の古典的脆弱性で、対策はRFC 8725が明文化した。exp前の失効はできないので、短命トークン+リフレッシュ併用が定石。
このRFCが決めたこと
横にスクロール
RFC 7519(2015年5月)は、JSON Web Token(JWT)を定義した文書だ。著者はM. Jones、J. Bradley、N. Sakimuraで、同月にまとめて発行されたJOSE(JSON Object Signing and Encryption)系の文書群の一つにあたる。仕様が冒頭で与える定義は簡潔で、JWTとは「クレーム(claim、主張)を二者間でやり取りするための、コンパクトでURLセーフな表現手段」である。クレームの集合をJSONオブジェクトとして表し、それをJWS構造のペイロード、またはJWE構造の平文として運ぶ。
押さえるべきは、7519が定めているのが「トークンの中身の書き方」に限られる点だ。署名の計算方法も暗号化の手順も7519自身は持たず、すべて別文書へ委ねている。7519が受け持つのは、issやexpといったクレーム名の登録簿と、その値の型、そして「何をJWTと呼んでよいか」という枠付けである。ちなみに仕様は発音にも触れており、JWTは英単語のjotと同じように読むことが提案されている。
JWTは単体で完結する技術ではなく、JWSかJWEの上に乗って初めて意味を持つ。「JWTは安全か」という問いが成立しないのはこのためで、安全性を決めているのは常に下層のJWS/JWE側の使い方だ。
要点の精読
3つのドットと、base64urlは暗号化ではないという事実
JWS署名付きJWTの実体は、ドットで区切られた3つの部分からなる1本の文字列だ。それぞれがbase64urlでエンコードされている。
header . payload . signature
| | +-- BASE64URL(署名またはMACのバイト列)
| +-- BASE64URL(UTF8(JWTクレームセット))
+-- BASE64URL(UTF8(JOSEヘッダ))
署名の計算対象(JWS Signing Input):
ASCII(BASE64URL(UTF8(JOSEヘッダ)) || '.' || BASE64URL(ペイロード))
ここでいうbase64urlは、RFC 4648の5章が定めるURL・ファイル名で安全な文字集合を使い、末尾の = パディングをすべて省いた符号化を指す。+ と / を使わないので、そのままURLのパスやクエリに載せられる。JWTがURLセーフだと言われる理由はこれだけであり、eyJ で始まるのは単に {" をbase64urlした結果にすぎない。
つまり、署名付きJWTのペイロードは、トークンを手にした者なら誰でも読める。署名が与えるのは改ざん検知と発行元の証明であって、機密性ではない。手元のトークンをJWTデコーダに貼れば、鍵を一切持たないブラウザ上で中身がそのまま展開されることがすぐ確認できる。ペイロードへ入れてよいのは、相手に見られても困らない情報だけだ。
署名付きJWT(JWS)の中身は秘密ではない。パスワード、APIキー、内部の識別子、個人情報をペイロードへ置けば、トークンを受け取った側と、経路上でそれを覗いた者に丸ごと渡ることになる。中身を隠したいならJWEを使うか、そもそも入れない設計にする。通信路の秘匿はRFC 8446: TLS 1.3の仕事であって、JWSの署名が肩代わりしてくれるものではない。
JOSEファミリーの中での位置
7519は単独では動かない。2015年5月に発行された一連のJOSE文書が、役割ごとに層を分けている。
| RFC | 略称 | 受け持つ範囲 |
|---|---|---|
| RFC 7515 | JWS | 署名・MACによる完全性と真正性の保護 |
| RFC 7516 | JWE | 暗号化による機密性の保護 |
| RFC 7517 | JWK | 鍵をJSONで表現する形式(JWKSはその集合) |
| RFC 7518 | JWA | HS256やRS256といったアルゴリズム名の定義 |
| RFC 7519 | JWT | クレームセットの中身と登録済みクレーム名 |
現実に流通している「JWT」は、ほぼ例外なくJWS署名付きだ。JWEは中身を秘匿したい場面に限られ、遭遇頻度は大きく下がる。両者は見た目でも区別でき、JWSのコンパクトシリアライズが3部構成なのに対し、JWEは5部構成(ヘッダ・暗号化鍵・IV・暗号文・認証タグ)になる。ドットが2つならJWS、4つならJWEだ。
もう一つ、仕様の定義として重要な制約がある。7519は「JWTは常にJWSコンパクトシリアライズまたはJWEコンパクトシリアライズで表現される」と明言している。JWSにはJSONシリアライズという冗長な表現形式も存在するが、それはJWTとは呼べない。JWTとは、ペイロードがJSONクレームセットであるコンパクトシリアライズ済みのJWS/JWEだ、と言い切れる。なおクレームセットが従うJSONそのものの文法はRFC 8259: JSONが定める。
登録済みクレームとNumericDate
7519の4.1節は、意味を標準化したクレームを7つ登録している。
| クレーム | 意味 | 検証側がすべきこと |
|---|---|---|
| iss | 発行者 | 期待する発行者と一致するか照合する |
| sub | 主体(トークンが誰について語るか) | issとの組で一意になる前提で扱う |
| aud | 想定受信者 | 自分を指していなければ拒否する |
| exp | 有効期限 | 現在時刻と比較する。時計ずれの許容は数分まで |
| nbf | 有効開始時刻 | この時刻より前なら拒否する |
| iat | 発行時刻 | 古すぎるトークンの判定に使う |
| jti | トークンの一意な識別子 | 再利用検知や失効リストの鍵にする |
見落とされがちなのは、7519がこの7つすべてを「使用はOPTIONAL」と定めていることだ。expさえ仕様上は必須ではない。無期限のJWTを発行しても7519には違反しない。実務で必須と感じるのは、後述するプロファイルや常識がそう要求しているからであって、原文が守ってくれるわけではない。
audについては7519自身が強い言葉を使っている。「このクレームが存在する場合、それを処理する主体がaudの値のいずれかで自分を識別できないなら、そのJWTは拒否されなければならない(MUST)」。ここで肝心なのは、存在確認では足りないという点だ。audが入っていることを確かめて満足する実装は、隣のサービス向けに発行された正規のトークンを受理してしまう。なおiss・sub・audの値はStringOrURI型で、コロンを含む場合はURIとして解釈される決まりになっている。
そしてexp・nbf・iatの型がNumericDateだ。定義は「1970-01-01T00:00:00Z UTCから指定時刻までの秒数を表すJSON数値。うるう秒は無視する」——秒であって、ミリ秒ではない。JavaScriptの Date.now() はミリ秒を返すため、それをそのままexpへ入れると値が約1000倍になり、西暦5万年台を指す事実上無期限のトークンが出来上がる。逆にサーバー側がミリ秒前提でexpを比較すれば、正しいトークンが常に期限切れ扱いになる。どちらもテストの正常系をすり抜けやすい類のバグだ。
algをトークンに選ばせた瞬間に壊れる
JWTの脆弱性の歴史は、ほぼ一つの誤りに集約される。検証に使うアルゴリズムを、検証対象であるトークン自身のヘッダから読み取ってしまうことだ。
第一の古典が alg: none である。7519の6章は「Unsecured JWT」を正式に定義しており、これはalgヘッダの値が none で、署名部が空文字列のJWSを指す。署名を持たないトークンが仕様上ちゃんと存在するということだ。したがって、ヘッダのalgを読んでその方式で検証するライブラリは、攻撃者が {"alg":"none"} へ書き換えて署名を空にしたトークンを「noneなので署名チェック不要」と判断し、任意のペイロードを受理する。
第二がアルゴリズム混同だ。サーバーがRS256(RSA署名)を想定し、検証関数へRSA公開鍵を渡しているとする。攻撃者はalgをHS256(HMAC)へ書き換え、公開されているRSA公開鍵そのものをHMACの共有鍵として署名を計算する。ヘッダのalgに従って方式を切り替える実装は、手元の「鍵」でHMACを検証し、一致してしまう。公開鍵は誰でも入手できるのだから、これは任意のトークンを偽造できることを意味する。
この2つはいずれも、Tim McLeanが2015年3月に公表したものだ。7519の発行が同年5月だから、脆弱性の指摘のほうがRFCの成立より早かったことになる。JWTは生まれた時点でこの弱点を抱えていた。
ヘッダの他のパラメータも同じ性質を持つ。kid(鍵ID)を鍵の検索キーとしてそのままSQLやファイルパスへ渡せば注入の入口になり、jku(JWKセットURL)や x5u(X.509 URL)に書かれたURLを検証なしに取りに行けば、攻撃者が用意した鍵セットを掴まされるうえ、サーバーサイドリクエストフォージェリの踏み台にもなる。いずれもヘッダは攻撃者が自由に書ける領域だという一点に帰着する。
algヘッダは「発行側が何を使ったか」の申告にすぎず、「検証側が何を使うべきか」の指示ではない。検証時に許可する方式はサーバー側で固定し、鍵は事前に登録した集合からのみ選ぶ。kidは許可リストへの完全一致で引き、jkuやx5uのURLは信用しない。この原則を外すと、署名検証は「検証しているように見えるだけの処理」に化ける。
こうした知見は、後にRFC 8725(BCP 225、2020年2月)としてまとめられた。この文書は7519をUpdatesする位置づけで、アルゴリズム検証の実施、issとsubの検証、audの検証、そして異なる種類のJWTを取り違えないための明示的型付け(typヘッダに at+jwt のような具体的なメディア型を入れる)といった対策を、実装者向けの指針として明文化している。7519を読むなら8725まで併読しないと片手落ちだ。
失効できないという代償と、JWTを使ってよい場面
JWTの売りは、ステートレスに検証できることだ。トークンの中身に必要な情報が自己記述されているので、リクエストごとにセッションストアを引かなくてよい。サービスが分かれていても、鍵さえ配れば各サービスが独立に検証できる。
その裏返しが失効だ。サーバーが状態を持たないということは、発行済みトークンを取り消す手段も持たないということでもある。ログアウトしても、権限を剥奪しても、漏えいが判明しても、expが来るまでそのトークンは有効なまま通る。ここで失効リストを導入すればリクエストごとにその照合が要るわけで、避けたかったはずの状態参照が戻ってくる。ステートレスという利点は、失効可能性と引き換えに得ているものだと理解しておく必要がある。
| 観点 | JWS署名付きJWT | 不透明なセッションID |
|---|---|---|
| 検証コスト | 鍵で署名を検証するだけ(ストア参照なし) | リクエストごとにセッションストアを参照 |
| 即時失効 | できない(失効リストを足せば可能だが状態が戻る) | レコードを消せば即座に無効化 |
| 中身の可読性 | 誰でも読める(機密は置けない) | ランダム文字列で情報を持たない |
| 適した場面 | 短命・サービス間・認可の受け渡し | 同一サイト内の通常のログインセッション |
| 主な事故 | alg混同、aud未検証、失効できない | セッション固定、ストアの単一障害点 |
現実的な緩和策は、アクセストークンの寿命を数分から十数分に絞り、更新はリフレッシュトークンで行う構成だ。これは失効を諦める代わりに、漏えいしたトークンが使える時間を短く抑える設計であって、即時失効の代替にはならない。要件に「全端末から今すぐログアウトさせる」が含まれるなら、どこかに状態を持つ以外の道はない。
ここから導かれる結論は、JWT不要論でもJWT万能論でもない。JWTが素直に嵌まるのは、短命で、信頼境界をまたいで持ち回る必要があり、受け取った側が発行元へ問い合わせずに検証したい場面だ。まさにRFC 6749: OAuth 2.0やOpenID Connectが扱う領域で、実際にRFC 9068(2021年10月)はOAuth 2.0アクセストークンをJWTで表すプロファイルを標準化し、iss・exp・aud・sub・client_id・iat・jtiを必須と定めることで7519の緩さを締めている。
一方、同一サイト内の普通のログインセッションにJWTを持ち込む理由は乏しい。セッションストアへの参照はどうせ必要になりがちで、不透明なセッションIDをCookieに入れるほうが実装は単純で、失効も一発で効く。「JWTのほうがモダンだから」という理由だけで選ぶと、失効できないという代償だけを払うことになる。
つまずきやすい点
まず、署名を検証したことと、そのトークンが自分宛てであることは別だ。署名が通るのは「自分の信頼する発行者が出した本物」までを意味するに過ぎず、同じ発行者が隣のサービス向けに出したトークンも署名は正しく通る。issとaudを検証して初めて宛先が確定する。次に、ライブラリの既定値を安全だと決めてかからないこと。algの許可リストを渡さない呼び出し方ができるAPIは今も残っており、その形で書けた実装は正常系のテストを全部通ってしまう。防御の欠落は動作確認では見つからないという点は、セキュリティ全般に共通する性質だ。
最後に、7519だけを読んで実装しないこと。7519は登録済みクレームをすべてOPTIONALとし、alg: none を正式に認め、algの検証方法については何も強制しない。仕様に忠実であることと安全であることが一致しない稀な例であり、実装が従うべき規範はRFC 8725であり、OAuthの文脈ならRFC 9068だ。原文の緩さは、後続文書が締めるという前提で書かれている。
まとめ
RFC 7519が定めたのは、クレームをJSONで表しコンパクトシリアライズされたJWS/JWEに載せる、という一点に尽きる。base64urlはエンコードであって暗号化ではなく、7つの登録済みクレームはすべて任意で、NumericDateは秒であり、alg: none は仕様の一部だ。この緩さを前提に、algと鍵はサーバー側で固定し、issとaudを検証し、寿命を絞る——RFC 8725が明文化したこの構えまで含めて初めて、JWTは実務で使える道具になる。他のRFC解説はRFC精読から辿れる。
RFC精読の記事ガイド
RFC 7519: JWTを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
RFC
比較で見る軸
難易度: advanced / カテゴリ: RFC精読 / タグ数: 5
導入後に効く点
登録済みクレームはiss・sub・aud・exp・nbf・iat・jtiの7つだが、expを含め全て任意。日時はミリ秒でなくエポック秒のNumericDateなので、Date.now()をそのまま入れてはならない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- RFC精読
- タグ数
- 5
判断チェックリスト
- 自社の用途が「RFC / JWT」に近いか確認する。
- 強みである「RFC 7519は、クレームをJSONで表してURLセーフな文字列に詰めるJWTの定義。署名はJWS・暗号化はJWEに分業されており、実務のJWTはほぼJWS署名付き——各部はbase64urlエンコードにすぎず暗号化ではない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。