JWTの「invalid signature」エラー:原因の特定と修正方法
JWT の invalid signature エラーが意味するのは、たった一つのことだ。検証側が計算した署名と、トークンが運んできた署名が一致しない。トークンが期限切れだという意味でもなければ、ユーザーに権限がないという意味でもない。JWT ライブラリが壊れているわけでもない。HMAC に入るバイト列、あるいは検証呼び出しに渡す公開鍵が、署名した側と検証する側で食い違っている。それだけだ。
9 割方、犯人はトークンではなく鍵の側にある。どこから手を付けるかは次の図で決めればいい。
ヘッダーの alg は何か?
├─ HS256 / HS384 / HS512 → ほぼ確実に秘密鍵の問題
│ ├─ 署名側と検証側で言語が違う? → セクション 3
│ └─ 同じ言語で、ローカルは通り本番で落ちる? → セクション 4
└─ RS256 / ES256 / PS256 → ほぼ確実に鍵のフォーマットか鍵の取り違え
└─ → セクション 7
トークンがゲートウェイ・プロキシ・コピペを経由した? → セクション 6
数時間後だけ、あるいは特定のホストだけで出る? → セクション 8
各セクションの終わりには、その場で実行できる手順を置いた。最短の一手が欲しいなら、トークンを JWT デコーダーに貼り付けて alg フィールドを読むところから始めるといい。それが分かった瞬間に、上の分岐の半分は消える。
1. invalid signature が実際に意味していること
同じ失敗でも、ライブラリごとに出力される文字列は違う。自分が見ている文字列を次のリストから探してほしい。見つかれば、この記事は当たりだ。
- Node
jsonwebtoken:JsonWebTokenError: invalid signature - Python
PyJWT:InvalidSignatureError: Signature verification failed - Java
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
3 つとも、同じコードパスの同じ瞬間に発火する。ライブラリはトークンの先頭 2 セグメントを取り、渡された鍵で署名を計算し直し、その結果を 3 番目のセグメントとバイト単位で比較する。一致しなければ例外を投げる。
比較は完全一致で行われるため、2 つの値が「どれくらい」違うのかという情報は一切残らない。秘密鍵が 1 バイトずれている場合と、まったく別の鍵を渡している場合とで、エラーメッセージは同一になる。だから残りのセクションでは、エラー文字列を読み解こうとはせず、入力の候補を潰していく。
このエラーが出た時点で、まだ「起きていない」ことにも注意したい。クレーム検証は署名検証の後に走るので、exp・nbf・aud・iss はまだ一度も見られていない。署名の検証に失敗した以上、トークンの中身は診断の材料にならない。とはいえ中身は読める。JWT は暗号化ではなくエンコードされているだけだからだ。ヘッダーとペイロードのデコードには鍵が一切要らない。セグメントごとの手順は JWT トークンをデコードする方法にある。
次にどこへ進むかは、ヘッダーの 2 つのフィールドが決める。alg は追うべき対象が共有秘密鍵なのか鍵ペアなのかを教え、kid は署名側が「これを使っている」と信じていた鍵を教える。
2. 署名が守るのはエンコード済み文字列であって、オブジェクトではない
多くの開発者は、ここを逆に覚えている。
JSON Web Signature の仕様である RFC 7515 は、JWS Signing Input を次の ASCII 文字列として定義している。
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
HMAC が計算される対象はこの文字列だ。claims マップでもなければ、言語が構造化データとみなす何かでもない。本稿で一貫して使う signing input は次のもので、標準的なサンプルペイロードから取っている。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
ここから導かれる帰結に、実際に多くのチームが引っかかっている。ペイロードをデコードして再エンコードする層は、それだけで署名を壊す。JSON のシリアライズは正規形ではない。マップが言語をひと巡りすればキーの順序は変わる。空白は増えたり消えたりする。非 ASCII 文字を \uXXXX にエスケープするシリアライザもあれば、そのまま出すシリアライザもある。数値も書式が変わり、1516239022 が 1516239022.0 になって返ってくることがある。どれも base64url 文字列を変え、したがって signing input を変え、したがって署名を変える。
引き金はたいてい次のどれかだ。
- テナント ID を付け足すために JWT をパースし、トークンを作り直して送り出す API ゲートウェイ。
- ヘッダーを「正規化」して Authorization の値を書き換える、ロギングやトレーシングのミドルウェア。
- トークンを読むために整形して表示し、その整形後の文字列をそのまま貼り戻した開発者。
署名側と検証側の間にトークンを書き換えられるコンポーネントがあるなら、まずそれを疑うべきだ。転送中のトークンは不透明な文字列であり、安全な操作は保存・コピー・比較の 3 つしかない。
3. 秘密鍵は同じ、バイト列は別物
トラブルシューティング記事がほとんど扱わない原因だが、「秘密鍵は文字どおり同一だ、diff も取った」という類のバグ報告の裏には、たいていこれがいる。
HMAC が食べるのは文字列ではない。バイト列だ。一方、設定ファイルもシークレットマネージャーも環境変数も、保持しているのは文字列である。どこかで一方をもう一方へ変換する必要があり、その変換は JWT ライブラリ間で標準化されていない。2 つのサービスが 1 文字も違わない秘密鍵を持っていても、計算される署名は別物になりうる。
証拠を示す。セクション 2 の signing input に対してローカルで実際に計算したものだ。秘密鍵の文字列は 36 文字である。
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| バイト列の解釈 | バイト数 | 鍵の実体 | 得られる HS256 署名 |
|---|---|---|---|
| UTF-8 テキストとして扱う | 36 | 見えている 36 文字そのもの | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| 先に base64 デコードする | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
秘密鍵の文字列もアルゴリズムもペイロードも同じだ。それでも署名は共通点ゼロの 2 種類になる。「間違えた」側が invalid signature を出すわけだが、設定ファイルをいくら diff しても何も出てこない。設定ファイルの方は一致しているからだ。
UTF-8 解釈のときの完全なトークンは次のとおり。手元で再現したいときに使ってほしい。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
これを上の秘密鍵と一緒に JWT デコーダーに貼れば検証は通る。秘密鍵を先に base64 デコードすると通らない。
各ライブラリは文字列をどう鍵バイト列に変えるか
頼れるのはドキュメントに書かれていることだけなので、次の表は範囲を絞ってある。読むべきは最初の列より最後の列だ。
| ランタイム/ライブラリ | 文字列からバイト列への挙動 | 決めるのは誰か |
|---|---|---|
Node jsonwebtoken | 文字列の UTF-8 バイト列 | ライブラリ |
Python PyJWT | 文字列の UTF-8 バイト列 | ライブラリ |
Java jjwt、旧 String オーバーロード | プラットフォームの base64 コーデック(jwtk/jjwt#204 参照) | ライブラリ |
Go golang-jwt | []byte をそのまま受け取る | あなた(呼び出し箇所) |
| .NET | byte[] をそのまま受け取る | あなた(呼び出し箇所) |
Java の行が、言語をまたいだときの痛みの歴史的な発生源だ。古い jjwt では signWith(SignatureAlgorithm, String) とその仲間が、String の生バイト列を取るのではなく base64 コーデックに通していた。一方 byte[] を取るオーバーロードは、渡されたバイト列をそのまま使う。だから 1 つの秘密鍵を共有する Node サービスと Java サービスの結果が食い違った。この String API は jjwt 0.10 以降 deprecated であり、現在の書き方は明示的だ。
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
これは「Java の JWT はこうなっている」という話ではない。1 つのライブラリのレガシーなオーバーロードの話であり、byte[] を渡す現行の jjwt コードには曖昧さは一切ない。Node 側から見た鏡像の報告が auth0/node-jsonwebtoken#208 で、Java で署名したトークンが Node で検証できないというものだ。PHP の firebase/php-jwt にも似た報告がある(firebase/php-jwt#153)。ただしそのライブラリのバイト列の扱いを我々自身で検証したわけではないので、診断ではなく手がかりとして扱うべきだ。
Go と .NET は事情が違う。どちらのライブラリも代わりに決めてはくれない。[]byte や byte[] のパラメータを渡してきて、あとは手を引く。[]byte(secret) と Encoding.UTF8.GetBytes(secret) は UTF-8 を返し、Convert.FromBase64String(secret) はデコード後のバイト列を返す。バグが起きるとすれば、それは自分の呼び出し箇所にある。これは朗報だ。自分の diff に映るからである。
自分の JWT シークレットは base64 か、UTF-8 か
それを教えてくれるフラグはトークンの中にない。文字列そのものから推論するしかない。
- 使われている文字は
A–Z a–z 0–9 + / =(あるいは-と_)だけか。 そうなら base64 の「可能性がある」。空白や!、#を含む秘密鍵は base64 ではありえない。 - 長さは 4 の倍数か、末尾に
=のパディングがあるか。 どちらも、途中で何かが base64 エンコードした強い痕跡だ。 - base64 デコードすると意味のあるバイト列になるか。 Base64 デコーダーに通してみればいい。読める ASCII か、ちょうど 32 バイトのランダムに見える値が出てくれば base64 だ。文字化けするなら、その文字列は最初からエンコードされていない。
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm のような秘密鍵は 3 つのテストすべてに引っかかる。危険なのはまさにそこで、どちらの解釈も成り立ってしまう。- や _ を含む秘密鍵はもっと厄介な曖昧さを抱えている。base64url としては妥当だが、標準 base64 としては不正だからだ。
推論で決着がつかないなら、両方計算すればいい。signing input を HMAC ジェネレーターに入れ、HMAC-SHA256 を 2 回走らせる。1 回目は秘密鍵をテキストとして、2 回目はデコード後のバイト列として。それぞれの結果をトークンの 3 番目のセグメントと突き合わせる。どちらか一方が一致し、システムのどちら側の解釈が正しいかはそれで決まる。
文字数はバイト数ではない
関連する罠が、要件はバイト単位なのに文字数で数えてしまうことだ。RFC 7518 §3.2 は HMAC-SHA の鍵長の下限をビットで定めており、文字数では定めていない。しかもエンコードされたテキストは膨らむ。
| 書き方 | エントロピー | 相当するバイト数 | HS256(256 bit 以上が必要)では |
|---|---|---|---|
| 16 進数 32 文字 | 128 bit | 16 バイト | ❌ 下限未満 |
| base64 32 文字 | 192 bit | 24 バイト | ❌ 下限未満 |
| ランダムな 32 バイト | 256 bit | 32 バイト | ✅ 満たす(16 進数なら 64 文字、パディング込みの base64 なら 44 文字) |
「32 文字の秘密鍵」は、使う文字種によって 128 bit にも 256 bit にもなる。これは上のバイト解釈の問題とは直交する話だが、噛みつく相手は同じだ。文字数で測るチームは、たいていバイト列を一度も見ていないチームだからである。実際の選定ルール、つまり長さ・エンコーディングの選択・ローテーションについては JWT シークレット生成ツールにまとめてあるので、ここでは繰り返さない。
4. 秘密鍵そのものが汚染された
2 つのサービスはバイト解釈で一致している。それでも署名は通らない。次に確認するのは、各側が読み込んだ秘密鍵が、自分が書いたつもりの秘密鍵と同じかどうかだ。環境まわりの配管は、バイトを 1 つ足すのが驚くほど得意である。
.env の末尾改行。 JWT_SECRET=abc の後ろに改行があると、リーダーによっては abc\n として読み込まれる。1 バイト増えただけで HMAC はまったく無関係な出力を返す。「惜しい」という状態は存在しない。
引用符がデータとして読まれる。 JWT_SECRET="abc" は、ローダーによって abc にも "abc" にもなる。とくにシェルが source する場合とライブラリがパースする場合で分かれる。Docker Compose の env_file と .env パーサーが、同じファイルについて別の結論を出すことがある。
コピペで混入する不可視文字。 Slack や wiki、PDF から秘密鍵をコピーすると、ゼロ幅スペース(U+200B、バイト列 e2 80 8b)やノーブレークスペース(U+00A0、バイト列 c2 a0)を一緒に連れてくることがある。どちらもどのエディタでも見えず、どちらも HMAC を変える。
CI とコンテナによる変形。 シェルの展開を経た秘密鍵は $ が展開されたり、バックスラッシュが食われたりする。値を trim する CI もあれば、しない CI もある。Kubernetes の Secret はマニフェスト上では base64、コンテナ内では生の値であり、これ自体が二重デコードの罠になる。
対処は、秘密鍵を「眺める」のをやめて「測る」ことだ。両側で、値そのものではなく長さとフィンガープリントを出力する。
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
両方のコマンドを署名側と検証側で実行し、出力を比べる。長さもフィンガープリントも一致するなら秘密鍵は原因ではないので、セクション 3 に戻ればいい。想定より 1 だけ長ければ末尾改行、2 だけ長ければ引用符だ。
長さがずれていて、中身を正確に見たいなら、開発用の秘密鍵に対してローカルのシェルで hex ダンプを取る。
printf '%s' "$JWT_SECRET" | xxd
末尾の 0a は改行だ。先頭と末尾の 22 は引用符のペアである。途中に c2 a0 や e2 80 8b があれば不可視文字のケースだ。ターミナルの出力をどこかへ送っているマシンで、本番の秘密鍵に対してこれを実行してはいけない。
稼働中の Node や Python のプロセス内で同じことを確認するなら、次のようになる。
const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))
Python では len(s) が len(s.encode("utf-8")) より小さければ、ASCII のはずの秘密鍵に非 ASCII 文字が混ざっているということだ。
5. アルゴリズムと鍵の種類が噛み合っていない
alg ヘッダーと渡す鍵は、同じファミリーに属していなければならない。HS256 が求めるのは共有秘密鍵、つまりバイト列だ。RS256 や ES256 が求めるのは非対称鍵、つまり PEM か JWK である。ここを取り違えたとき、明確な型エラーを返すライブラリもあれば、素っ気ない invalid signature だけを返すライブラリもある。
よくあるパターンは次のとおり。
- ヘッダーは
HS256なのに、検証側がライブラリに PEM の公開鍵を渡している。PEM のテキストを HMAC にかけて署名不一致を報告するライブラリもある。 - ヘッダーは
RS256なのに、検証側が HMAC 用の秘密鍵文字列を渡している。 - 検証側がアルゴリズムのリストをまったく渡さず、
algからの推論をライブラリに任せている。この場合、署名側の設定がずれると検証側の挙動が黙って変わる。
最後の 1 つは、設定のバグがセキュリティのバグに変わる地点だ。だから検証呼び出しでは毎回、アルゴリズムを明示的に固定する。
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
固定することには、曖昧な署名エラーを具体的なエラーに変える効果もある。alg: RS256 のトークンが来て許可リストが HS256 なら、両方の値を名指しした明示的なアルゴリズムエラーが得られる。
このセクションが扱っているのは設定ミスであり、自分の 2 つのコンポーネントが食い違っているだけで、攻撃者は関与していない。よく似た形をした別の失敗として、攻撃者が alg を RS256 から HS256 に書き換え、あなたの公開鍵を HMAC の秘密鍵として署名するというものがある。こちらはアルゴリズム混同(algorithm confusion)であり、バグではなく攻撃だ。脅威モデル全体と合わせて JWT セキュリティのベストプラクティスで扱っている。防御策が「明示的な許可リスト」で同じになるのはたまたまだが、バグを追っているだけのときでも適用しておく理由にはなる。
6. トークンが転送中に変わった
鍵を疑う前に、署名側が生成したのと同じ文字列を検証側が受け取っているかを確認する。JWT は、文字列が壊れやすいのとまったく同じ意味で壊れやすい。
Bearer プレフィックス。 Authorization: Bearer eyJhbGci... はヘッダーの値であって、トークンではない。区切り方を間違えたり、1 回だけ分割して間違った側を取ったりすると、Bearer eyJhbGci... や空文字列を検証することになる。意識して剥がす。
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
空白と改行。 ターミナルからコピーしたトークンは折り返される。YAML に保存したトークンは折りたたまれる。3 番目のセグメントに \n が 1 つ紛れ込むと、パースエラーではなく署名不一致になる。base64url デコーダーは空白を読み飛ばすことが多いのに対し、文字列比較は読み飛ばさないからだ。
URL エンコード。 クエリパラメータとして流れたトークンは、. が %2E になって戻ってきたり、張り切りすぎたエンコーダーに - と _ を変換されたりする。デコードは 1 回、きっかり 1 回だけ行う。
切り詰め。 Cookie の上限は 1 つあたりおよそ 4 KB で、クレームがいくつか入った RS256 トークンは日常的にこれを超える。切り詰められたトークンはたいてい base64 デコードで失敗するが、4 文字境界で切れた場合は、見た目は正しく署名だけが違うトークンになる。
これは 2 つのコマンドで決着する。正しい形の JWT にはドットがちょうど 2 つある。
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
そしてすべての文字が base64url のアルファベットに収まっていなければならない。つまり次のコマンドは、何も出力しないはずだ。
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
2 つ目のコマンドに出力があれば、それが問題の名前だ。3d はあってはならない = のパディング、2b や 2f は base64url が - と _ を期待する位置に来た標準 base64 の + と /、20 は紛れ込んだ空白である。
7. RS256 と ES256 に固有の失敗
非対称アルゴリズムは、秘密鍵の問題を鍵管理の問題に置き換える。失敗の出方も、共有秘密鍵のときとはまるで違う。
PKCS#1 と PKCS#8。 同じ RSA 鍵に対する 2 つのコンテナ形式であり、ヘッダー行の 1 単語で見分けられる。
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
どちらを受け付けるかはライブラリによって違う。形式をきっぱり拒否してくれるなら明確なエラーが出るが、中途半端にパースされると、決して検証を通らない署名ができあがる。戦わずに変換する。
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
鍵が入れ替わっている。 公開鍵で署名する、あるいは秘密鍵で検証する。理屈のうえでは明白な間違いだが、4 文字しか違わない名前で同じディレクトリに並んでいれば簡単に起こる。どちらがどちらかを確認する。
openssl rsa -in key.pem -noout -text | head -1
秘密鍵なら、秘密鍵として法(modulus)のサイズが表示される。公開鍵は -pubin を付けない限りエラーになる。
JWKS と kid のずれ。 JWKS エンドポイントを使う場合、検証側はトークンの kid を鍵セットと突き合わせて鍵を選ぶ。ここでの壊れ方は 3 通りある。署名側がローテートしたのに、検証側のキャッシュした JWKS が古い。トークンに kid がなく、検証側がセットの先頭の鍵を選ぶ。2 つの環境が重複する kid を公開している。これを疑うときは JWKS を取り直し、トークンヘッダーの kid がそこに正確に含まれているかを確認する。
ES256 の署名エンコーディング。 ECDSA の署名は r と s という 2 つの整数の組で、シリアライズの仕方が 2 通りある。汎用の暗号スタックは、可変長の ASN.1 構造である DER を出力することが多い。一方 RFC 7518 §3.4 が要求するのは JOSE 形式、つまり r と s をそれぞれ固定長にパディングして連結したもので、P-256 なら 64 バイトになる。DER の署名を JWT に放り込むと、単に間違っているだけでなく長さが違う。つまり 3 番目のセグメントがちょうど 64 バイトにデコードされない ES256 トークンは、この変換を飛ばした何かが作ったものだ。
問題が鍵にあるのかパイプラインにあるのかを切り分けるには、同じペイロードを JWT エンコーダーで独立に署名し、その出力を自分のサービスが作ったものと比べる。署名が一致するなら、転送かクレームの扱いが疑わしい。一致しないなら鍵が疑わしい。
8. 署名エラーに見えて署名エラーではないもの
この中には、ライブラリ自身が名前を付け間違えているものがある。だから見当違いのバグ報告に紛れ込む。
| 症状 | 実際に起きていること | 見るべき場所 |
|---|---|---|
PyJWT ExpiredSignatureError | exp が過去。名前は署名だが、原因はクレーム | ホスト間の時刻ずれ、または短すぎる TTL |
PyJWT ImmatureSignatureError | nbf が未来 | 署名側の時計が検証側より進んでいる |
Node TokenExpiredError | exp が過去 | 上と同じ |
| 詳細のない 401 | フレームワークが検証失敗をすべて 1 つのレスポンスに潰している | ライブラリレベルのエラーログを有効化 |
| 数分は通り、その後落ちる | 署名ではなくトークンの期限切れ | iat と exp を両ホストの時計と突き合わせる |
| 特定のオーディエンスだけ失敗する | aud か iss の不一致 | 検証側が期待するオーディエンスのリスト |
とりわけ罠なのが PyJWT の命名だ。ExpiredSignatureError には「signature」という語が入っているが、送出されるのはクレーム検証の最中であり、署名の検証はとうに成功している。このエラー文字列で検索すると署名のトラブルシューティング記事に直行し、問題の見当違いな場所に何時間も溶ける。
いちばん混乱を招くのが時刻ずれだ。コードのどこにも相関しない断続的な失敗が出る。片方のホストの時計が進んでいると、発行したてのトークンが到着時点で nbf や iat の検証に落ち、ずれが大きくなるにつれて失敗の出方も動いていく。まず両方のマシンで date -u を比べる。たいていのライブラリは leeway パラメータを受け付ける。これは消しきれないずれに対しては正しい対処であり、本当に壊れている時計に対しては間違った対処だ。
一般則はこうだ。失敗が時刻に依存する、ホストに依存する、オーディエンスに依存するなら、それは署名の問題ではない。署名の失敗は決定的である。同じトークンと同じ鍵は、永遠に同じ落ち方をする。
9. 再現可能なトラブルシューティング手順
上から順に実行する。どのステップもバグを見つけるか、分岐を 1 本消すかのどちらかだ。途中で止まれるように並べてある。
- ヘッダーをデコードする。 トークンを JWT デコーダーに貼り、
algとkidを記録する。ここから先のすべてがこれで決まり、鍵は要らない。 - トークンの形を確認する。 ドットはちょうど 2 つ、文字は base64url のみ、
Bearerプレフィックスなし、空白なし。セクション 6 の 2 つのコマンドを使う。これで転送中の破損を除外できる。 - 検証呼び出しでアルゴリズムを固定する。
algと許可リストが食い違っていれば、一般的なエラーではなく両方を名指しした明示的なエラーが出るようになる。 - 両側で鍵のフィンガープリントを取る。 セクション 4 のとおり、署名側と検証側でバイト長と切り詰めた SHA-256 を出力する。値が違えば配管の問題であり、ステップ 5 には進まない。
- 両側の言語が違うなら、バイト解釈を決着させる。 セクション 3 の表を参照し、秘密鍵がテキストなのか base64 なのかを明示的に決め、デフォルト任せではなくコード上で両側にそう書かせる。
- 同じペイロードを独立に署名し直す。 正しいと思う鍵で JWT エンコーダーを使い、その 3 番目のセグメントを自分のトークンのものと比べる。一致するなら署名側は問題なく、検証側が原因だ。
- HMAC を手で突き合わせる。 signing input を HMAC ジェネレーターに通し、両方のバイト解釈で計算する。トークンと一致した方が、どちら側を直すべきかを教えてくれる。
7 つすべてを終えてもまだ助けが必要なら、次の情報を添えること。多くのバグ報告が止まるのは、答えを決定づける事実が抜けているからだ。
- ヘッダーの
algの値と、kidがあるかどうか - 署名側と検証側の両方について、言語・ライブラリ・正確なバージョン
- 両側の秘密鍵のバイト長と、その SHA-256 の先頭 16 文字(秘密鍵そのものは絶対に載せない)
- 秘密鍵をテキストとして保存しているか base64 として保存しているか、そして各側がどう変換しているか
- 完全な signing input。先頭 2 セグメントは機微情報ではない。トークンを持つ者はどのみち読めるからだ
- RS256 と ES256 の場合は、PEM のヘッダー行を一字一句そのまま
このリストがあれば、答えようのない「JWT の署名が一致しない」が、誰かが実際に解決できる質問に変わる。たいていは 1 往復で済む。
FAQ
同じ秘密鍵が、ある言語では通り別の言語では落ちるのはなぜか
ライブラリごとに、秘密鍵の文字列を鍵バイト列へ変える方法が違うからだ。Node jsonwebtoken と Python PyJWT は UTF-8 を使う。jjwt の旧 String オーバーロードは base64 コーデックを使っていた(jwtk/jjwt#204)。Go と .NET は判断を呼び出し箇所に委ねる。文字列としては同じでも、バイト列が違えば HMAC も違う。
署名の対象はデコード後のペイロードか、エンコード済み文字列か
エンコード済み文字列だ。RFC 7515 は signing input を、リテラルな ASCII としての base64url(header) + "." + base64url(payload) と定義している。ペイロードをデシリアライズして再シリアライズする層は、キーの順序・空白・数値の書式のいずれかを変え、別の文字列を、したがって別の署名を生む。
秘密鍵が base64 に見える。署名前にデコードすべきか
相手側もそうしている場合に限る。単独では正解は存在せず、要件は「両端が一致していること」だけだ。その文字列が base64 の文字だけで構成され、長さが 4 の倍数かどうかを確認したうえで、デフォルトに頼らず両側のコードで選択を明示する。
.env の末尾改行だけで、本当に署名が壊れるのか
壊れる。HMAC が食べるのはバイト列であり、abc\n は 4 バイト、abc は 3 バイトだ。得られる署名は正しいものと共通点を持たない。両方のホストで printf '%s' "$JWT_SECRET" | wc -c を出力してみるといい。想定より 1 だけ長ければ、ほぼこれである。
原因が秘密鍵なのかアルゴリズムなのかを見分けるには
まずヘッダーの alg を読む。HS で始まるなら共有秘密鍵が必要で、PEM を渡せば失敗する。RS・PS・ES で始まるなら鍵ペアが必要で、秘密鍵の文字列を渡せば失敗する。alg と鍵の種類が同じファミリーに揃った後に残る失敗は、鍵の中身の問題だ。
jwt.io では署名が有効なのに、自分のサーバーが弾くのはなぜか
オンラインツールと自分のサーバーとで、秘密鍵の解釈が違いうるからだ。片方は UTF-8 テキストとして、もう片方は base64 として扱う。ツールが検証しているのはツールが導出したバイト列であって、サーバーが導出したバイト列ではない。なお、本番の秘密鍵をサードパーティのサイトに貼ってはいけない。開発用の鍵を使うこと。
「invalid signature」がトークンの期限切れで起きることはあるか
ない。署名検証はクレーム検証より先に走るので、期限切れが原因になることはない。期限切れは Node なら TokenExpiredError、PyJWT なら ExpiredSignatureError として別に現れる。後者の名前は誤解を招く。署名の検証は問題なく通っていて、落ちたのは exp だけだからだ。
まとめ
署名の不一致が暗号の問題であることは、ほとんどない。HMAC-SHA256 は動く。RSA も動く。壊れるのは、文字列がバイト列になる境界だ。片側が base64 コーデックでもう片側が UTF-8 だった、設定ローダーが改行を残していた、ゲートウェイが親切にもペイロードを再シリアライズした。この記事に出てきた原因はすべて、バイト列についての意見の不一致である。
だからバイト列を明示し、デフォルトに頼るのをやめる。共有秘密鍵を生のテキストとして保存しているのか base64 として保存しているのかをチームのドキュメントに書き、各サービスにはライブラリの想定を継承させるのではなく、宣言したとおりに変換させる。複数の言語にまたがるシステムなら、秘密鍵は 16 進数か base64 で保存し、すべての呼び出し箇所で明示的にデコードする。サービスあたり 1 行で曖昧さは消える。そのうえでセクション 4 のバイト長フィンガープリントをヘルスチェックに足しておけば、次の不一致は本番の 401 ではなく起動時の警告として現れる。