DevKitLab Logo DevKitLab
JWT / 認証 / デバッグ / セキュリティ

JWT が無効になるのはなぜ?JSON Web Token のデコードとデバッグ

「invalid token」という 401 は、トークンが壊れていることをほとんど意味しません。あなたとサーバーの間にある、どれか一つの検証が通らなかったのです。デコードの小技を集めるより、JWT とは何かを理解しましょう——署名された平文であり、いつでも読めるが、検証してはじめて信用してよいもの。そう捉えれば、あらゆる失敗が短く順序だったチェックリストに変わります。

リクエストが 401 で返ってきて、body にはそっけなく {"error":"invalid_token"} とだけ書いてある。トークンは Authorization ヘッダーにちゃんと入っている——見るからに手がかりのない、途中に点が二つ挟まった長い文字列——なのに、どこがおかしいのかまったく見当がつかない。そこであなたは推測しはじめる。もう一度署名し直す、期限を延ばす、別の鍵を試す、認証サービスを再起動する。どれもほぼ当てずっぽうだ。エラーは何かが失敗したとは告げても、どれが失敗したのかは告げていないからだ。

推測を終わらせる捉え直しがある。あの文字列は不透明でもなければ、暗号化もされていない。 あなたは今すぐ、鍵もサーバーの助けもなしに、それが主張する内容のすべてのバイトを読める。JWT とは署名された平文だ。payload は可逆なエンコードの向こうに丸見えで座っており、鍵がなければ偽造できない唯一の部分が「誰が書いたか」を証明する署名だ(署名自体も平文でトークンと一緒に流れる。秘密なのは常に鍵のほうだ)。この事実を腹に落とせば、「トークンはなぜ無効なのか」は謎ではなくなり、短く順序だった問いに変わる。サーバーが走らせる、互いに独立したいくつかの検証のうち、どれが「ノー」と言ったのか?

この記事はデコードの小技を並べたものではない。まず数分かけて JWT が実際に何であるか——それが答え、そして厳密に分けている二つの問い——を説明し、それから一つのトークンを取り上げて、拒否されうるすべての道筋を歩く。退屈なもの(期限切れ)から、目立たないもの(JSON の空白が署名を壊した)、そして危険なもの(header がサーバーにどのアルゴリズムを信じるか指示していて、その header は攻撃者が書き換えられる)まで。読み終える頃には、どんな 401 にも当てられるチェックリストが手に入る。

たった一つの核心:デコードと信用は、別々の問い

JWT にまつわるほとんどの混乱は、標準があえて分けている二つの問いを一緒くたにすることに行き着く:

  1. このトークンは何と言っているか? —— 常に答えられる。トークンを持つ者は誰でも中身を読める。鍵は不要。
  2. その言い分を信じてよいか? —— 鍵があってはじめて答えられる——現実にはその鍵を持つ側、たいていはリクエストを受け取るサーバーだ。

JWT は、問い 1 を容易に、問い 2 を厳密にするように作られている。中身は単にエンコードされているだけ——誰でも元に戻せる可逆な変換——で、そこに問い 2 に答えるための暗号学的な署名が同乗している。署名は何も隠さない。payload をかき混ぜたりしない。それは「改ざんを検知できる封印」だ。中身の文字を一つでも変えれば封印は合わなくなるが、封印が合うかどうかに関わらず、中身は読める。

だから「JWT は安全か」は引っかけの問いだ。payload の秘匿性はハガキ程度——配達人が読める——だが、改ざん検知性は蝋の封印並みだ。誰かが書き換えれば、あなたには分かる。この「二分」を握っておいてほしい。以下のどの節も、結局はどちらか一方の話だ。トークンを読む(問い 1)か、サーバーがそれを信用しなかった理由(問い 2)か。

かたち:三つの Base64URL 区間、点で切り分ける

まずトークンを分解する。ほかは後回しだ。コンパクトな JWT はちょうど三つの塊が、二つの点でつながっている:

header . payload . signature

最初の二つはそれぞれ Base64URL エンコードされた JSON オブジェクトだ。点で切り、前の二区間をデコードすれば、目の前にあるのはただの JSON だ:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFkYSIsImV4cCI6MTcwMDAwMDAwMH0 . 3Rf...

header は概ねこうデコードされる:{"alg":"HS256","typ":"JWT"}——型と、そして肝心の署名アルゴリズム。payload はあなたのクレーム群、例えば {"sub":"user_42","name":"Ada","exp":1700000000} になる。signature は生のバイト列で、これも Base64URL 化されていて、JSON ではない——テキストとして読もうとしないこと。ハッシュや署名処理の出力だ。

「いつでも読める」に一つ但し書きを。これはコンパクト形式の署名トークン——JWS——の話で、ほぼどこでも出会うのはそれだ。暗号化された JWE トークンも存在し、そのコンパクト形式は三つではなく五つの区間(点は四つ)で、payload は鍵なしでは本当に不透明、本稿の対象外だ。だから、ある「JWT」が三つの読める区間に分かれないなら、デコードを続ける前に、まず JWE か——あるいは単なる切り詰めや書式の破損か——を疑うとよい。

ここで最初の本当の落とし穴が現れる。手でデコードする人や、間違ったヘルパーに手を伸ばす人を噛むやつだ。Base64URL は Base64 ではない。 URL や HTTP ヘッダーで安全に運ぶために設計された変種で、標準 Base64 と三点で異なる。そしてそのどれもが、あなたのデコード結果を音もなく壊す:

  • +- に、/_ になる——ハッシュに最も現れやすいこの二文字が、よりによって差のある二文字だ。
  • 末尾の = パディングが取り除かれている。標準の Base64 デコーダはしばしばそのパディングを要求し、無ければ例外を投げる。
  • 改行はない。

だからブラウザでの atob(segment) や、コマンドラインで無造作に打つ base64 -d は、完全に正当なトークンに対してもたいてい例外を投げる——-_ は標準アルファベットでは不正な文字だからだ——そして、例外を投げないほど寛容なデコーダは代わりに誤ったバイトを返す。どちらにせよ、Base64URL の文字列に標準 Base64 で話しかけているのだ。標準デコーダに応じてもらうには、まず -/_+// に戻し、4 の倍数までパディングを足す必要がある。これを具体的に見たいなら、一区間を取り出して Base64 エンコード/デコードツールで URL-safe モードと標準モードそれぞれに通してみるとよい。同じ入力を、URL-safe モードはきれいに読み、標準モードは詰まるかゴミを吐く。この一つの取り違えこそ、「自分でデコードしてみる」が最もよく転び、人を誤ったデバッグの方向へ引き込む原因だ。(Base64URL と標準 Base64 の違い、そして Base64 がデコードできない他の原因は、専用の記事で扱う。)

実際に拒否を招くクレーム

payload をきれいにデコードできたとしよう。ほとんどの invalid_token 拒否は、そもそも暗号学的なものではない——サーバーがあるクレームを検査して、気に入らなかったのだ。以下は RFC 7519 の登録済みクレームで、そのうちいくつかは特に拒否を招きやすい:

  • exp(有効期限) —— 断然の一位。Unix タイムスタンプで、単位は。その瞬間を過ぎたらトークンは拒否されなければならない。ほぼどのライブラリもこれを自動で強制するので、期限切れのトークンはあなたのコードが目にする前に落とされることが多い。
  • nbf(not before、まだ有効でない) —— 鏡像。トークンはまだ有効ではない。将来のために発行されたトークンか、あるいはもっと多いのは、署名側サーバーの時計が検証側より進んでいるときにぶつかる。
  • iat(issued at、発行時刻) —— いつ作られたか。それ自体が拒否の引き金になることは普通ないが、トークンの古さや時計のずれを考える基準点だ。
  • aud(audience、受け手) —— このトークンは誰のためか。あなたの API が https://api.example.com で、トークンの aud が別のサービスを指しているなら、正しい検証側は署名が完璧でも拒否する。あるサービス向けに発行されたトークンが別のサービスで再生されるのを止めること、それが aud の存在理由だ。OIDC でよく出会う具体例:ID トークン(その aud はあなたのクライアントアプリ)を、access トークン(aud はその API)しか受け付けない API に送る——トークン自体は本物なのに、使う場所が違うだけだ。署名は正しく、audience が違う。
  • iss(issuer、発行者) —— 誰が署名したか。検証側は信頼できる発行者の許可リストと照合し、それをもとに発行者が公開する鍵セット(その JWKS エンドポイント)を見つける。そのセットの中のどの一本を使うかは、iss ではなく header の kid が決める。

すべての時刻系クレームに潜む罠:expnbfiat は epoch 秒であって、ミリ秒ではない。 JavaScript の Date.now() はミリ秒を返すので、1000 で割り忘れた手書きの比較は、あらゆるトークンを約 5 万 4 千年後まで期限切れにならないと読む——あるいは逆に、発行したてのトークンをすべて既に失効と見なす。どちらを取り違えたか次第だ。しかも値は 1700000000 のような裸の整数なので、そのトークンが先週死んだのか来年切れるのか、目では一目で分からない。まさにここでツールが働く:トークンを JWT インスペクターに貼ると、expiatnbf人が読める実際の日付として表示され、ホバーで絶対時刻も見える——「期限切れか?」「二台のサーバーの時計は合っているか?」が、頭の中の計算ではなく目に見えるものになる。とりわけ時計のずれは、生の epoch 整数ではほぼ見えず、日付として並べた瞬間に一目瞭然になる。

署名:それは検証のためのもので、「デコード」できない

ここで「二つの問い」の分かれ目が鋭くなる。「署名をデコードする」と言う人がいる——だが署名は、可読な何かに戻せるエンコードされた情報ではない。header と payload を鍵付きの処理に通した出力であり、それにできる唯一のことは、もう一度計算し直して一致を確かめることだ。署名は検証するもの。デコードすることは決してない。

その処理が具体的に何かは header の alg 次第で、その分かれ目は理解しておく価値のある断層に沿って走る:

  • HS256 / HS384 / HS512 —— 対称(HMAC)。 署名は HMAC-SHA256(secret, header.payload)同じ共有シークレットが署名も検証も行う。単純で速いが、鋭い刃がある。HS256 トークンを検証できる者は、偽造もできる。検証と署名が同一の鍵を使うからだ。ここでの HMAC は、webhook 署名や API リクエスト署名に使うのと同じ HMAC-SHA256 の原始関数そのものだ——JWT 署名は、その要約に標準化された入力形式をかぶせただけにすぎない。
  • RS256 / ES256 / PS256 / EdDSA —— 非対称。 発行者は秘密鍵で署名し、他の全員が対応する公開鍵で検証する。大規模システムが RS256 を好む本当の理由がこれだ。認証サーバーが秘密鍵を握り、他の誰もトークンを発行できない一方で、下流の百のサービスがそれぞれ自由に配布された公開鍵で検証でき、それでいて何一つ偽造できない。この鍵ペアのかたちを体感したいなら、RSA 鍵ジェネレーターで一組作ってみるとよい——秘密鍵が署名し、公開鍵が検証し、二つは交換できない。

非対称の側には、見落とされがちだが実際の障害をかなり生む要素がもう一つある。検証側はたいてい公開鍵を直接持たない——発行者の JWKS(公開された鍵の集合)を取得し、この token がその中のどれで署名されたかは header の kid が指す。発行者はその鍵を定期的にローテーションする。あなたのサービスが JWKS をキャッシュしていて、ちょうどローテーションが起きると、新しい token の kid はまだ取得していない鍵を指しうる——そして token 自体は何も悪くないのに、すべての token が一斉に検証に失敗しはじめる。一群の token が同時に無効になったら、token を疑う前に、鍵のローテーションと JWKS キャッシュの陳腐化を疑うとよい。

この節の要点、そして本題そのもの:payload をデコードできても、信用については何も証明されない。 トークンのクレームを完璧に読めても、署名はなお無価値でありうる——鍵違い、鍵なし、中身の改ざん。良いインスペクターはこの区別に誠実だ。鍵なしでトークンを貼ると、JWT インスペクターは状態を 「Decoded, not verified」(デコード済み、未検証) と表示する——あえて緑でもチェックマークでもなく——「読める」を「信用できる」と取り違えさせないためだ。対応する共有シークレットや公開鍵を与えてはじめて、「Signature verified」(署名検証済み) へ移る。このラベルの節度こそ、「二つの問い」という考えを可視化したものだ。

目立たないもの:署名は正確なバイトを覆う、あの JSON ではない

これは上のすべてを理解している人でもつまずくので、独立した一段に値する。署名は「データ構造としての header と payload」に対して計算されるのではない——前二区間の正確な Base64URL テキストに対して、一文字ずつ、間の点も含めて計算される。署名の入力は、文字どおり ASCII 文字列 base64url(header) + "." + base64url(payload) だ。

つまり:payload をデコードし、JSON を整形し、エンコードし直して、古い署名を貼り戻すと——意味は何一つ変えていなくても、そのトークンは無効になる。キーの並べ替え、空白の追加、あるいは {"a":1}{ "a": 1 } と再直列化するデコーダは、どれも異なるバイトを生み、異なるバイトは異なる署名を意味する。これは設計どおりだ——封印を改ざん検知可能にしているのがこれだ——が、本当に訳の分からない失敗の型を説明してくれる。「整形しただけなのに検証を通らなくなった」。意味は変えていないが、バイトを変えたのだ。署名は最初から最後まで、バイトについてのことだけを約束していた。だから JWT は手で編集できない。クレームを一つ誠実に変えるには署名し直す必要があり、それには鍵が要る。(これがインスペクターの sign モードの用途だ——JSON を編集し、鍵を与え、壊れたつぎはぎではなく、本物の署名し直したトークンを得る。)

危険なもの:header がサーバーにどう信用するかを指示する

さて、デバッグ記事をセキュリティ記事へと変える部分であり、この連載が次に向かう先の種でもある。alg がどこにあるかをもう一度見よう。header の中だ。header はトークンの一部だ。そしてトークンは、それを送ってきた者が用意する。 「どうやって信用を検証するか」の指示が、まさに信用が問われているそのものの内側に座っている。

二つの古典的攻撃がここから直接こぼれ落ちる:

  • alg: none 仕様は署名を一切持たない「無保護」の JWT を定義している。header から alg を読んで言われたとおりにする素朴な検証側は、"alg":"none" を見て署名検査を丸ごと飛ばし、攻撃者が手書きしたトークンを受け入れる。直し方は、サーバーが自身の設定から、どのアルゴリズムを許容するかを決めなければならない——alg をトークンからの指示として受け取ってはならない。
  • RS256 → HS256 混同。 サーバーが「alg の言うとおり」に検証し、かつ RSA 公開鍵を持っているなら、攻撃者は algHS256 に差し替えたトークンを作り、その公開鍵を HMAC シークレットとして署名できる。公開鍵は公開だ——だから攻撃者は偽造に必要なすべてを持っている。ライブラリは HMAC をせよと言われ、律儀に検証を通す。直し方はやはり同じ:期待するアルゴリズムをサーバー側で固定し、トークンに選ばせない。

分量を見誤らないように。主流のライブラリはこの二つに何年も前から対処している。特別に許可しない限り alg: none を拒否し、token から alg を読むのではなく、受け入れるアルゴリズムを前もって明示させる。だからこれは、あらゆる JWT に怯えろという話ではない——手書きの検証ロジック、とうに時代遅れのライブラリ、そして token 自身の alg を受け入れリストに流し込むような設定を疑え、という話だ。

貫く筋は記事全体と同じだ。トークンは何でも言える——algexpsubadmin: true——payload と header は誰でも書ける平文だからだ。安全性はまるごと、サーバーがトークン自身のクレームを指示として扱うことを拒み、自らが握る署名検査を行うことから来る。この継ぎ目こそ、本連載の次回がこじ開ける場所だ。なぜ検証していない payload を決して信用してはならないか、そしてその信用が回避されるあらゆる手口。 上で扱ったのはすべて「トークンをどう読むか」だ。次回は「トークンにどう騙されないか」だ。

実際の 401 に向けたチェックリスト

次にトークンが拒否されたら、闇雲に再署名するのはやめよう。順に走らせる——ほぼすべての場合を切り分けられる:

  1. まず読む。 トークンを JWT インスペクターに貼り、デコードされた header と payload を見る。中身を推測しない——見る。たいてい答えはどれかのクレームの中にある。
  2. exp(と nbf)を確認。 過去/未来にないか? 整数ではなく日付として読み、署名側と検証側の時計を突き合わせる——ずれが静かな元凶だ。忘れずに:ミリ秒ではなく秒。
  3. audiss を確認。 audience はあなたのサービスを名指しているか? issuer はサーバーが信頼する相手か? 署名が完璧でも、この二つは落とせる。
  4. 転送経路を確認。 余計な Bearer 接頭辞、末尾の空白、ヘッダー値にコピーされ込んだ改行はないか? ツールで検証を通るトークンが、目に見えない一文字のせいで通信路で失敗しうる。
  5. 意識して署名を検証する。 対応するシークレット(HS)や公開鍵(RS/ES/EdDSA)を与え、「Decoded, not verified」から「Signature verified」へ移ることを確かめる。通らないなら、よくある原因は:鍵違い、header の alg があなたの鍵ファミリーと合っていない、あるいは署名後にバイトが変えられた(上の整形の落とし穴を参照)。RS/ES/EdDSA では容疑者をもう一つ加える:署名鍵がローテーションされ、JWKS キャッシュが古いままのケース——header の kid が手元のどの鍵にも対応しなくなっていると、この token 自体とは無関係の理由で検証が失敗する。
  6. alg が期待どおりか確認する——しかもトークンから読むのではなくサーバーが固定していること。none と書いてあったり、RSA を期待した場所に HMAC アルゴリズムがあったりしたら、それは回避すべきバグではなく、赤信号だ。

二つの規則がこの六歩すべてを支える。トークンを読むのに鍵は要らず、トークンを信じるには常に鍵が要る——この二つを分ければ、混乱の半分は蒸発する。そして payload は誰でも読めるので、決して秘密を入れない——JWT は署名であって暗号化ではない。あらゆるクレームを公開のものとして扱おう。RS/ES の検証用公開鍵は多くの場合、発行者の JWKS から来るが、ときには X.509 証明書として手渡されることもある——そのときは 証明書デコーダーでその証明書の主体と有効期間を読める。(証明書を読んで分かるのは中身が何かであって、その発行者を信用してよいということではない——それを決めるのはあくまであなたの許可リストだ。)だがすべての出発点は同じだ。トークンを再署名するしかない不透明な塊として扱うのをやめ、最初から目の前にあった平文を読みはじめること。