本文へスキップ
ecdsa.com
← すべての記事

JWT の ES256 と RS256:選び方と、痛い目を見ないための注意点

JWT ヘッダーのわずか 2 文字の違いが、トークンのサイズや鍵管理の進め方を決め、検証の設定を誤れば、トークンを偽造できるかどうかまで左右します。ES256 と RS256 が実際に何を意味し、どうすれば安全に使えるのかを解説します。

alg の名前が実際に意味するもの

JWT は、JSON のペイロードを持つ JWS(JSON Web Signature)です。形式は header.payload.signature で、各部分は base64url でエンコードされています。alg ヘッダーが署名方式を示し、そのレジストリは RFC 7518(JSON Web Algorithms)で定められています。実際によく目にする値は次の 4 つです。

alg方式鍵の種類署名サイズ
HS256HMAC(SHA-256)単一の共有秘密(対称)32 バイト
RS256RSASSA-PKCS1-v1_5(SHA-256)RSA 鍵ペア256 バイト(RSA-2048)
PS256RSASSA-PSS(SHA-256)RSA 鍵ペア256 バイト(RSA-2048)
ES256P-256 上の ECDSA(SHA-256)EC 鍵ペア64 バイト(生形式 r‖s)

この表には、構造上の重要な点が 2 つ隠れています。1 つ目に、HS256 は根本的に異なります。対称方式なので、トークンを検証できる当事者は誰でもトークンを発行できてしまいます。HS256 が適するのは、発行者と検証者が同じサービスである場合だけです。RS256、PS256、ES256 は非対称方式です。検証者が持つのは公開鍵だけで、公開鍵は JWKS エンドポイント経由などで自由に配布できます。

2 つ目に、ES の名前は曲線を固定します。ES256 は P-256 と SHA-256、ES384 は P-384 と SHA-384、そして ES512 は(数字に注意してください)521 ビットの曲線 P-521 と SHA-512 を意味します。PS256 は RS256 と同じ RSA 鍵素材に、より新しい PSS パディングを組み合わせたものです。使っているエコシステムが対応しているなら、RSA のモードとしては PSS のほうが優れています。(JWT の枠を超えたアルゴリズムの詳しい比較は、ECDSA と RSA の比較を参照してください。)

トークンサイズ:署名 64 バイト対 256 バイト

ES256 の署名は生のバイト列で 64 バイト、base64url で約 86 文字です。一般的な 2048 ビット鍵による RS256 の署名は 256 バイト(約 342 文字)で、RSA-3072 では 384 バイトになります。ヘッダーとペイロードはどちらでも同じなので、RS256 から ES256 に切り替えると、通常はトークンごとに約 250 バイト削減できます。これは、トークンを Cookie(上限 4 KB)に入れる場合、リクエストのたびに Authorization ヘッダーで送る場合、QR コードに埋め込む場合、モバイルや IoT の通信で送る場合に効いてきます。ES256 の公開鍵もはるかに小さいため、JWKS ドキュメントもコンパクトに保てます。

速度は ECDSA と RSA の比較ガイドで説明したパターンどおりで、署名は ES256 のほうが速く、検証は RS256 のほうが速くなります。大量に署名するトークン発行者にとっては、ES256 のほうがコストの低い選択です。

JWT の惨事の大半を防ぐルール:アルゴリズムを固定する

JWT の設定ミスの典型は、トークン自身にその検証方法を決めさせてしまうことです。alg ヘッダーは攻撃者が制御できる入力で、まだ検証していないトークンそのものの中に入って届きます。ヘッダーから alg を読み取ってその値で処理を振り分け、しかも 1 つの鍵オブジェクトを複数のアルゴリズムファミリーで共用する検証者は、自らの保証を崩してしまいます。よく知られたアルゴリズム混同の例を挙げます。あるサービスは RS256 のトークンを公開鍵で検証していますが、そのライブラリは同じ鍵オブジェクトで HS256 も受け入れ、RSA の公開鍵のバイト列を HMAC のシークレットとして扱ってしまいます。公開鍵はもともと公開されるものなので、誰でも一致する HS256 のタグを計算し、そのサービスが受け入れるトークンを発行できます。

対策は機械的なもので、RFC 8725(JWT Best Current Practices)に明文化されています。

  • 明示的なアルゴリズム許可リストで検証する:許可リストは理想的には値 1 つだけにし、サーバー側で設定します。トークンから導き出してはいけません。
  • 鍵をアルゴリズムに結び付ける:JWKS では各鍵に alg と kid を付けます。ES256 用に公開した鍵が、ほかの用途に使えることがあってはなりません。鍵の種類のチェックも役立ちます。EC の P-256 鍵ではそもそも RS256 を検証できないため、これも混在環境で ES256 を選ぶ理由の 1 つです。
  • クレームを明示的に検証する:発行者、オーディエンス、有効期限を同じ呼び出しの中で検証します。
node(jose):検証側でアルゴリズム許可リストを固定
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json"));

const { payload } = await jwtVerify(token, jwks, {
  algorithms: ["ES256"],          // fixed by the verifier, not by the token
  issuer: "https://issuer.example",
  audience: "api://orders",
});
python(PyJWT):同じルール
import jwt

payload = jwt.decode(
    token,
    public_key_pem,
    algorithms=["ES256"],   # required parameter in modern PyJWT — for good reason
    audience="api://orders",
    issuer="https://issuer.example",
)

「none」を決して受け入れてはいけない理由

RFC 7518 は "alg": "none" も登録しています。これは「保護されていない JWS」で、署名部分が空のトークンです。完全性が何らかの外側の層で保証される、まれなケースのために存在します。none を受け入れる検証者は署名のないトークンを受け入れることになり、誰でも任意のペイロードを作って提示できます。初期の JWT ライブラリ(2015年ごろ)が none をほかのアルゴリズムと同列に扱い、署名のないトークンがデフォルトで検証を通ってしまったことはよく知られています。現在のライブラリは明示的に有効にしない限りこれを拒否します。さらに ["ES256"] という許可リストを使えば、これが選ばれることは決してありません。コードベースを監査しているなら、none や空の許可リストに到達しうるコードパスはすべて指摘事項です。

ES256 の落とし穴:JWS の署名は DER ではなく生形式の r‖s

ECDSA 署名は数学的には整数の組 (r, s) で、この組をバイト列にするエンコーディングには一般的なものが 2 つあります。RFC 7518 §3.4 は明確に定めています。JWS では、ES256 の署名は r と s をそれぞれ 32 バイトのビッグエンディアン整数として連結したもの、つまりちょうど 64 バイトで、それを base64url でエンコードします。OpenSSL、Java の Signature、Node の crypto.sign() がデフォルトで出力する ASN.1 DER の SEQUENCE ではありません。

これは、自前で実装した JWT コードで「invalid signature」が起きる最も一般的な原因の 1 つです。トークンに約 70 バイトの DER データが入っている(あるいは検証側が 64 バイトの生形式の署名を、DER を前提とする API に渡している)ため、鍵もメッセージもアルゴリズムもすべて正しいのに検証が失敗します。Node の組み込みの crypto で署名する場合は、JWS の形式を直接指定してください。

node:JWS でそのまま使える 64 バイトの ES256 署名を生成
import { createPrivateKey, sign } from "node:crypto";

const key = createPrivateKey(pem); // P-256 private key
const signingInput = Buffer.from(`${headerB64}.${payloadB64}`);

const signature = sign("sha256", signingInput, {
  key,
  dsaEncoding: "ieee-p1363",   // raw r||s — what JWS requires (default is DER)
});
console.log(signature.length); // 64

実運用で形式の違う署名に出会ったら、DER ⇄ 生形式の変換ツールで両方向に変換でき、r と s の値も確認できます。バイトレベルの詳細と high-S の微妙な問題は、ECDSA 署名の形式:DER と生形式で解説しています。実際のトークン(ヘッダー、クレーム、公開鍵による ES256 の検証)を確認するには、JWT デバッガーに貼り付けてください。

選び方と、障害を起こさない移行

既存の RS256 環境から ES256 への移行は、全体を一斉に切り替える作業(フラグデー)ではなく、鍵のローテーションの作業です。

  • 新しい EC 鍵を RSA 鍵と並べて JWKS に公開し、それぞれに正しい kid と alg の値を設定して、利用側が JWKS のキャッシュを更新する時間を確保します。
  • 新しい kid で ES256 トークンの発行を始めます。kid で鍵を選び、鍵ごとのアルゴリズムを強制する検証者は、新旧どちらの世代のトークンも、それぞれちょうど 1 つの鍵に照らして受け入れ続けます。
  • RS256 のトークンがすべて期限切れになったら、JWKS から RSA 鍵を削除し、検証者の許可リストを ["ES256"] に絞り直します。

覚えておくべきことは 1 文だけです。アルゴリズムを選ぶのはトークンではなく検証者です。これさえ正しく押さえれば、ES256 と RS256 の選択は本来あるべき姿、つまりバイト数とミリ秒をめぐるささやかなエンジニアリング上のトレードオフになります。