JWT
JWT ・ じょっと
署名付きの JSON でクレームを運ぶトークン。受け手が発行元に問い合わせず単独で検証できる。
✦ 30秒で試す — 航海「署名なし」と名乗る通行証を、そのまま信じるサーバがある鍵を持たない人が拾ったら、中の権限(role)は読める?予想してみる概要
JWT(JSON Web Token、「ジョット」と読まれることが多い)は、「ユーザー ID は 42、権限は user、有効期限は1時間後」といった主張(クレームと呼びます)を JSON で書き、発行者の署名を付けて1本の文字列にまとめたトークンです。受け取った側は署名を確かめるだけで、「確かにその発行者が出したもので、途中で1文字も書き換えられていない」ことを自力で確認できます。
いちばんの特徴は、検証のために発行元へ問い合わせたり、データベースを引いたりしなくてよいことです。セッション方式が「状態はサーバ側に置き、ブラウザには番号札だけを渡す」のに対し、JWT は「情報そのものを、改ざんできない形で持ち主に持たせる」方式だと言えます。OAuth のアクセストークンの形式として、また OIDC(OpenID Connect)で「誰がいつログインしたか」を伝える ID トークンの形式として、認証・認可の周りで広く使われています。
ただし、よく使われる署名付きの JWT(正確には JWS 形式)は、中身を隠しません。署名が保証するのは「改ざんされていないこと」で、「読まれないこと」ではありません。この区別が、JWT を正しく使えるかどうかの分かれ目になります。
なぜ生まれたか
伝統的なセッション方式では、ログイン状態はサーバのメモリや RDBMS などのセッションストアに置かれます。サーバが1台なら単純ですが、台数を増やすスケーリングをすると、どのサーバからも同じストアを引けるようにする必要があります。API を提供するサービスが増えるほど、すべてのリクエストが共有ストアへの問い合わせを伴い、そこが性能と可用性の急所になります。さらに、別の組織が行った認証の結果を受け取る場面では、そもそもストアを共有できません。
組織をまたいで「この人はログイン済み」という主張を運ぶ仕組みとしては、先に SAML のアサーションがありました。ただし SAML は XML と XML 署名の上に作られていて、文書が大きく、署名の検証も複雑です。スマホアプリや JSON の API が主流になると、HTTP のヘッダや URL にそのまま載る、小さくて扱いやすい形式が求められました。2012年に標準化された OAuth 2.0 はアクセストークンの中身を決めていなかったので、API 側で検証できる形式を決める必要もありました。
こうして IETF で、JSON を使った署名と暗号化の一連の標準(JOSE)が作られ、2015年に JWT(RFC 7519)と、その土台となる署名形式 JWS(RFC 7515)・暗号化形式 JWE(RFC 7516)・鍵の表現 JWK(RFC 7517)がそろって標準化されました。前年の2014年にまとまった OIDC は ID トークンの形式に JWT を採用しています。受け手は発行者の鍵さえ持っていれば単独で検証できるので、この性質がマイクロサービスのようにサービス間で認証情報を受け渡す構成と噛み合い、広く普及しました。
詳細
3つのパートと、署名が覆う範囲
署名付きの JWT は ヘッダ.ペイロード.署名 のように、ドットで区切られた3つのパートでできています。1つ目のヘッダは「どのアルゴリズムで署名したか(alg)」「どの鍵で署名したか(kid)」を書いた JSON です。2つ目のペイロードはクレームを並べた JSON で、発行者 iss、主体(ユーザー ID)sub、宛先 aud、有効期限 exp、有効になる時刻 nbf、発行時刻 iat、トークン自体の ID jti などが標準の名前として登録されています。3つ目が署名です。ヘッダとペイロードは、それぞれ Base64URL という方式で URL に載せても壊れない文字列に変換されます。
ここで押さえておきたいのは、Base64URL は暗号化ではなく、ただの表記の置き換えだという点です。署名付きの JWT のペイロードは、誰でもデコードして読めます。だからパスワードや個人情報のような秘密を入れてはいけません。中身そのものを隠したい場合は、暗号化形式の JWE を使います。JWE は ヘッダ.暗号化された鍵.初期化ベクトル.暗号文.認証タグ の5つのパートからなり、見た目でも区別できます。実務で「JWT」と言えば、ほとんどは署名付きの JWS のことです。以下も JWS を前提に話を進めます。
署名の2系統と、鍵の見つけ方
署名のアルゴリズムには大きく2系統あります。HMAC(HS256 など)は、共有の秘密鍵とハッシュ化を組み合わせる方式です。発行者と検証者が同じ鍵を持つので、鍵を持つ者なら誰でも署名を作れてしまいます。RSA や楕円曲線の署名(RS256・ES256 など)は公開鍵暗号を使う方式で、署名できるのは秘密鍵を持つ発行者だけ、検証者は公開鍵で確かめるだけです。認可サーバが発行し、多くの API が検証する構成では、秘密を配り歩かなくて済む公開鍵方式が標準です。
公開鍵方式では、検証者が「どの公開鍵で確かめればよいか」を知る必要があります。発行者は自分の公開鍵の一覧を JWKS(JSON Web Key Set)として決まった URL で公開し、ヘッダの kid で「今回はこの鍵で署名した」と示します。検証者は JWKS を取得してキャッシュしておき、kid で鍵を選びます。鍵を入れ替える(ローテーションする)ときは、新しい鍵を JWKS に先に載せ、しばらくしてから新しい鍵で署名を始め、古いトークンが期限切れになった頃に古い鍵を外します。OIDC の Discovery では、この JWKS の場所(jwks_uri)も自動で見つけられます。
発行から検証までを、ブラウザを独立した参加者として並べると、「API サーバは検証のたびに認可サーバへ問い合わせない」という JWT の要点が見えます。
検証の手順: 何を、どの順に確かめるか
検証する側がすることは、上から順に門をくぐらせることです。まず、トークンのヘッダが名乗る alg が、自分があらかじめ許可したアルゴリズムかどうかを確かめます。次に kid で信頼済みの JWKS から鍵を選び、署名を計算し直して添付の署名と比べます。署名が合ったら、中身のクレームを確かめます。iss が期待した発行者か、aud が自分宛てか、exp を過ぎていないか、nbf より前でないか(サーバ間の時計のずれとして数十秒〜数分の猶予を持たせるのが一般的です)。1つでも落ちたら、そこで拒否します。
最初の門がいちばん大事です。2015年に、多くの JWT ライブラリに共通する脆弱性が報告されました。1つは、攻撃者がヘッダの alg を none(署名なし)に書き換え、署名を空にしたトークンを、ライブラリが「署名なしと書いてあるから署名は確かめない」と受け入れてしまうものです。もう1つは、RS256 用の公開鍵を持つ検証器に alg: HS256 のトークンを送り、公開鍵(誰でも入手できる値)を HMAC の共有鍵として使わせて、攻撃者が署名を偽造できてしまうものです。どちらも原因は同じで、検証の方法を、検証される側(トークン)に決めさせていたことです。いまのライブラリは対策済みですが、許可するアルゴリズムを検証側のコードで固定するのが鉄則であることは変わりません(JWT の実装上の注意は RFC 8725 にまとまっています)。
下の部品では、デコードしたクレームを書き換えて、2本の署名の帯がずれる様子を見たあと、トークンを2種類の検証器へ運んでください。ヘッダの alg を none にすると、甘い検証器と、alg を固定した検証器とで結果が分かれます。
ペイロードの role の値(金の枠)を押して書き換えてみてください。デコード結果とトークンはすぐ変わります。右の2本の署名の帯がどうなるかに注目です。
最大の弱点: 発行したら取り消しにくい
自己完結していることの裏返しとして、JWT は発行後に無効化しにくいという構造的な弱点を持ちます。セッションならサーバ側のレコードを消せばすぐにログアウトさせられますが、JWT は有効期限が来るまで、署名としては正しいまま使えてしまいます。「退職者をすぐ締め出したい」「漏れたトークンを止めたい」という要求に、そのままでは応えられません。
実務ではこれを二段構えで緩和します。アクセストークンは数分〜1時間の短命にし、長命のリフレッシュトークンで再発行します。リフレッシュトークンのほうはサーバ側で管理して取り消せるようにしておけば、被害の窓を「アクセストークンの残り寿命」に抑えられます。それでも足りなければ、jti を使った失効リストを持つか、OAuth のトークンイントロスペクション(認可サーバに有効かを問い合わせる仕組み)を使います。ただしそうすると「問い合わせずに検証できる」という利点は薄れます。即時失効が本当に必要なら、そもそもセッション方式のほうが素直ではないか、と設計を見直すことになります。
保存場所と、そのほかの落とし穴
ブラウザで JWT を扱うときは、どこに置くかが議論になります。JavaScript から読める localStorage に置くと、XSS が1つあるだけでトークンが盗まれ、持ち出された先で使われます。JavaScript から読めない HttpOnly 属性付きの Cookie に置けば盗み出しは防げますが、今度はブラウザが自動で送る性質から CSRF の対策(SameSite属性など)が要ります。SPA では、トークンをブラウザに置かずにサーバ側で持つ構成(BFF)もよく選ばれます。
ほかにも、ありがちな失敗がいくつかあります。署名を確かめずにデコードだけしてクレームを信じてしまう(多くのライブラリは「デコード」と「検証」を別の関数に分けています)。aud を確かめず、別のアプリ向けに発行されたトークンを受け入れてしまう。HMAC の鍵に短い文字列を使い、手元で総当たりされる。クレームを詰め込みすぎて、毎回のリクエストのヘッダが何 KB にも膨らむ。どれも「署名があるから安全」という思い込みから生まれます。
使い分けの目安は、単一のアプリで即時の失効が大事ならセッション、複数のサービスや組織をまたいで「誰が・何を許されているか」を運ぶなら JWT、というのが出発点です。「モダンだから JWT」ではなく、状態をどこに置くかと、失効をどこまで速くしたいかから選ぶのが本質です。
試問
画面には、発行サーバが出した JWT(role=user)の文字列と、それを Base64URL から戻して読んだヘッダと中身、添付の署名とサーバが鍵で計算し直した署名の2本の帯、そして2台の検証サーバがあります。金の枠の値は押すと書き換わり(あなたは鍵を持っていません)、トークンはどちらの検証器へも運べます。一方の検証器はトークンのヘッダに書かれた署名方式(alg)に従い、もう一方は方式を自分の側で HS256 に固定しています。
ペイロードの role の値(金の枠)を押して書き換えてみてください。デコード結果とトークンはすぐ変わります。右の2本の署名の帯がどうなるかに注目です。
