セキュリティ●●●○○

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 に載せても壊れない文字列に変換されます。

ヘッダペイロード署名eyJhbGciOiJIUzI1NiIs….eyJpc3MiOiJhdXRoLmV4YW1w….J20zTVvLrUt9dVcL…デコードデコード(鍵は要らない)“alg”: “HS256”,“typ”: “JWT”,“kid”: “2026-09""iss”: “auth.example”, “sub”: “42”,“aud”: “calendar-api”, “role”: “user”,“exp”: 1790003600HMAC-SHA256(鍵, ヘッダ.ペイロード)署名が覆う範囲 = エンコード済みの「ヘッダ.ペイロード」全体(alg も kid も含む)Base64URL は暗号化ではなく、ただの書き換え。ペイロードは誰でも読める(JWS の場合)→ パスワードや個人情報は入れない。中身を隠したいときは暗号化形式の JWE を使う
JWT(JWS)の3つのパート。ヘッダとペイロードは鍵なしで誰でもデコードできる。署名はエンコード済みの「ヘッダ.ペイロード」を鍵で計算した値なので、どちらを1文字変えても合わなくなる

ここで押さえておきたいのは、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 の要点が見えます。

APIサーバ認可サーバブラウザAPIサーバ認可サーバブラウザ検証のたびの問い合わせは発生しないJWKS を取得して公開鍵をキャッシュログイン秘密鍵でヘッダ.ペイロードに署名JWT を発行Authorization ヘッダに JWT を添えるkid で鍵を選び 署名と iss・aud・exp を検証検証が通れば処理して応答

検証の手順: 何を、どの順に確かめるか

検証する側がすることは、上から順に門をくぐらせることです。まず、トークンのヘッダが名乗る alg が、自分があらかじめ許可したアルゴリズムかどうかを確かめます。次に kid で信頼済みの JWKS から鍵を選び、署名を計算し直して添付の署名と比べます。署名が合ったら、中身のクレームを確かめます。iss が期待した発行者か、aud が自分宛てか、exp を過ぎていないか、nbf より前でないか(サーバ間の時計のずれとして数十秒〜数分の猶予を持たせるのが一般的です)。1つでも落ちたら、そこで拒否します。

検証者の設定許可 alg・期待する iss・aud発行者の JWKS公開鍵の一覧・取得してキャッシュkid で1本選ぶトークンの自己申告(alg・kid)は、検証者の持ち物に照らして使うJWTalg許可リストkid鍵を選ぶ署名再計算して比較iss発行者aud自分宛てexp・nbf期間内受理どこか1つで落ちたら 401。どの門で落ちたかはログに残し、応答には詳しく書かないalg を「トークンが名乗るまま」使うと、none(署名なし)や、公開鍵を HMAC の鍵と取り違える攻撃が通るkid は信頼済みの JWKS の中だけで引く。ヘッダの jku・x5u が指す見知らぬ URL から鍵を取りに行かない
検証者の門。トークンの自己申告(alg・kid)は、検証者が持つ許可リストと信頼済みの鍵に照らして使う。署名が合っても、自分宛てで期限内でなければ拒否する

最初の門がいちばん大事です。2015年に、多くの JWT ライブラリに共通する脆弱性が報告されました。1つは、攻撃者がヘッダの alg を none(署名なし)に書き換え、署名を空にしたトークンを、ライブラリが「署名なしと書いてあるから署名は確かめない」と受け入れてしまうものです。もう1つは、RS256 用の公開鍵を持つ検証器に alg: HS256 のトークンを送り、公開鍵(誰でも入手できる値)を HMAC の共有鍵として使わせて、攻撃者が署名を偽造できてしまうものです。どちらも原因は同じで、検証の方法を、検証される側(トークン)に決めさせていたことです。いまのライブラリは対策済みですが、許可するアルゴリズムを検証側のコードで固定するのが鉄則であることは変わりません(JWT の実装上の注意は RFC 8725 にまとまっています)。

下の部品では、デコードしたクレームを書き換えて、2本の署名の帯がずれる様子を見たあと、トークンを2種類の検証器へ運んでください。ヘッダの alg を none にすると、甘い検証器と、alg を固定した検証器とで結果が分かれます。

⚡ 体験: JWT を書き換えて、検証器に通るか試す
トークン — header . payload . signature を Base64URL で並べた文字列eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiYXVkIjoiY2FsZW5kYXItYXBpIiwiZXhwIjoxNzkwMDAzNjAwfQ.J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiUデコードすると誰でも読める(押すと書き換わる)ヘッダ"alg":"HS256"↻HMAC-SHA256"typ":"JWT"ペイロード(クレーム)"sub":"42"↻ユーザー ID"role":"user"↻"aud":"calendar-api""exp":1790003600↻1時間後署名は鍵がないと作れない添付された署名(トークンの3つ目)J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiUサーバが再計算: HMAC-SHA256(鍵, header.payload)J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiU✓ 一致(改ざんなし)鍵はサーバだけが持つ(あなたは持っていない)送って確かめる — トークンを検証器へ運ぶあなたトークンを持つ・鍵はない甘い検証器ヘッダの alg に従うalg署名expaud厳格な検証器alg は HS256 に固定alg署名expaudJWT
確かめたこと 0 / 4 — 次: 書き換えて署名のずれを見る
やり取りの記録(検証器は認証サーバに問い合わせない)
認証サーバあなた甘い検証器厳格な検証器JWT を発行 role=user

ペイロードの 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 を admin に書き換えたトークンを2台の検証器へ運び、一方だけが受け入れてしまう違いを確かめよ

  • 鍵を持たないまま、role を admin に書き換える
  • その role=admin のトークンを、どちらかの検証器が 200 で受け入れてしまう
  • 同じトークンを、もう一方の検証器は 401 で拒否する
⚡ 体験: 2台の検証器の違いを確かめる
トークン — header . payload . signature を Base64URL で並べた文字列eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiYXVkIjoiY2FsZW5kYXItYXBpIiwiZXhwIjoxNzkwMDAzNjAwfQ.J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiUデコードすると誰でも読める(押すと書き換わる)ヘッダ"alg":"HS256"↻HMAC-SHA256"typ":"JWT"ペイロード(クレーム)"sub":"42"↻ユーザー ID"role":"user"↻"aud":"calendar-api""exp":1790003600↻1時間後署名は鍵がないと作れない添付された署名(トークンの3つ目)J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiUサーバが再計算: HMAC-SHA256(鍵, header.payload)J20zTVvLrUt9dVcLAW_HOLsM9unOjI6x8vXa9srNXiU✓ 一致(改ざんなし)鍵はサーバだけが持つ(あなたは持っていない)送って確かめる — トークンを検証器へ運ぶあなたトークンを持つ・鍵はない甘い検証器ヘッダの alg に従うalg署名expaud厳格な検証器alg は HS256 に固定alg署名expaudJWT
やり取りの記録(検証器は認証サーバに問い合わせない)
認証サーバあなた甘い検証器厳格な検証器JWT を発行 role=user

ペイロードの role の値(金の枠)を押して書き換えてみてください。デコード結果とトークンはすぐ変わります。右の2本の署名の帯がどうなるかに注目です。