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

JWT 署名の ES256 と EdDSA:2026年にどちらを選ぶか

どちらも 128 ビットセキュリティで 64 バイトの署名を生成します。違うのは、失敗の仕方、鍵を置ける場所、そして署名を読み取れるライブラリです。以下の記述はすべて、2026年10月に最新のライブラリバージョンでテストするか、プロバイダーのドキュメントで確認したものです。

要点:トークンが自分の管理下から外に出るなら、ES256 で署名してください。テストしたすべての JOSE ライブラリが検証でき、主要なクラウド KMS と HSM はどれも鍵を保管でき、FAPI 2.0 などの標準にも挙げられています。すべての検証者を自分で管理でき、鍵をソフトウェアか AWS KMS に置けるなら、Ed25519 を選んでください。署名は決定的で、別の有効な形に改変することもできないため、ECDSA にありがちな 2 種類のミスがなくなります。Ed25519 を選ぶ場合は、当面は "alg": "EdDSA" として公開し、検証側では EdDSA と新しい名前 Ed25519 の両方を受け入れてください。テストしたライブラリのうち、新しい名前を理解できるものはまだ半数に届きません。

このページのトークンと鍵はすべて、このページのために生成したものです。ライブラリの結果は 2026年10月20日に公式 Docker イメージで実行したもので、バージョンは各結果に併記しています。

2 つの名前の意味

ES256EdDSA / Ed25519
方式ECDSA(P-256 曲線、SHA-256)EdDSA(Edwards25519 曲線、RFC 8032 §5.1)
JOSE での定義RFC 7518 §3.4RFC 8037、RFC 9864 で改名
IANA JOSE でのステータスRecommended+(推奨+)EdDSA:Deprecated(非推奨)、Ed25519:Optional(任意)
JWK"kty": "EC"、crv、x、y"kty": "OKP"、"crv": "Ed25519"、x
署名r ‖ s、64 バイトR ‖ S、64 バイト
FIPS 186-5 での承認ありあり

この名前の変更は、見た目以上に重要です。RFC 8037 は Ed25519 と Ed448 の両方に単一の EdDSA という値を登録したため、alg だけではどちらの曲線を使うのかがわからず、それを示すのは鍵の crv です。RFC 9864(2025年10月)は、この複数の曲線を兼ねる(ポリモーフィックな)名前を非推奨とし、完全指定の Ed25519 と Ed448 を登録しました。鍵の形式は変わらず、変わるのは alg の文字列だけです。ES256 は JOSE で最初から完全指定されており、改名は必要ありません。COSE では、RFC 9864 は ECDSA にも同じ変更を加えました。ES256(-7)は非推奨となり、代わりに ESP256(-9)を使います。これは WebAuthn や COSE ベースの認証情報に関わる話で、JWT には関係しません。

サイズ:肝心な部分は同じ

どちらの署名も 64 バイトで、base64url では 86 文字です。違うのはヘッダーの文字列と公開鍵だけです。

出力:同じクレームを 3 通りの方法で署名(jose 6.2.13、Node.js 24.22)
ES256    token 253 chars, signature 64 bytes (86 chars)
EdDSA    token 253 chars, signature 64 bytes (86 chars)
Ed25519  token 256 chars, signature 64 bytes (86 chars)
es public JWK 137 chars: {"kty":"EC","x":"B8UtJ0UT7zKuYn6NgB7h0E-L1-UF85FC-aoxjO7a2AE","y":"gwHfmffA6Vv9eWTKD_XCwGoLj5JtI1DQtGVq27icAU4","crv":"P-256","kid":"es"}
ed public JWK 90 chars: {"kty":"OKP","crv":"Ed25519","x":"NzIuH6R_919ooSpy2wU9iMMhGjaS3yXnjLF0AiqAC04","kid":"ed"}

Ed25519 の公開鍵は 32 バイト、P-256 の鍵は 32 バイトの座標 2 つなので、JWKS は鍵ごとに約 47 文字小さくなります。この程度の差を理由にアルゴリズムを選ぶべきではありません。RS256 と比べると話は変わります。RSA-2048 の署名は 256 バイトあるため、Cookie やヘッダーに入れるトークンでは、どちらの曲線も RS256 より有利です(ES256 と RS256 の比較を参照)。

決定性:ECDSA では起こりうる失敗が EdDSA では起こらない

ECDSA では、署名ごとに新しい秘密の数値が必要です。一度でも再利用したり、わずかでも予測可能にしてしまったりすると、公開されている署名から秘密鍵を計算できてしまいます。その代数計算と実際のインシデントは ECDSA の nonce の再利用で解説しています。ほとんどの JOSE ライブラリはこの数値をシステムの乱数生成器から取得するため、同じ入力でも毎回異なる署名になります。RFC 6979 はこの数値を決定的に導出する方法を定めていますが、使っているライブラリや HSM、KMS がそれを採用しているかどうかは、利用者の側では制御できません。

EdDSA は設計上、署名ごとの値を秘密鍵とメッセージから導出します(RFC 8032 §5.1.6)。鍵とメッセージが同じなら署名も同じで、乱数生成器は一切関与しません。これにより、PlayStation 3 や 2013年の Android ウォレットでの鍵漏洩の原因となった失敗パターンがなくなります。ただし、すべてのリスクがなくなるわけではありません。攻撃者が物理的に触れられるハードウェアでは、決定的な署名はフォールト注入攻撃を受けやすく、そのため新しい設計の中にはランダム性を再び取り入れているものもあります。サーバー上で動く JWT の発行者にとっては、決定性は明らかな利点です。

展性:別の有効なトークンが労せず作れる

ECDSA 署名 (r, s) には「双子」があります。n を曲線の位数とすると、(r, n − s) も同じ鍵とメッセージに対して検証に成功します。JOSE ライブラリはこの双子を拒否しません。RFC 8032 で規定された Ed25519 では、検証者は群の位数 L 以上の S をすべて拒否しなければならないため、S に L を足しても 2 つ目の有効な署名にはなりません。どちらの主張もテストで確認しました。

props.mjs:決定性と展性(jose 6.2.13、jsonwebtoken 9.0.3)
// Determinism and malleability of ES256 vs EdDSA tokens, checked with jose and jsonwebtoken.
import { readFileSync } from "node:fs";
import { SignJWT, generateKeyPair, importJWK, jwtVerify } from "jose";
import jwt from "jsonwebtoken";

const claims = { sub: "user-42", exp: 1924992000 };
const es = await generateKeyPair("ES256");
const ed = await generateKeyPair("Ed25519");
const sig = (t) => t.split(".")[2].slice(0, 16) + "...";
const signTwice = async (alg, key) => [
  await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
  await new SignJWT(claims).setProtectedHeader({ alg }).sign(key),
];
const [es1, es2] = await signTwice("ES256", es.privateKey);
const [ed1, ed2] = await signTwice("EdDSA", ed.privateKey);
console.log(`ES256 same input twice: ${sig(es1)} vs ${sig(es2)} -> identical: ${es1 === es2}`);
console.log(`EdDSA same input twice: ${sig(ed1)} vs ${sig(ed2)} -> identical: ${ed1 === ed2}`);

// Second valid signature for the same ES256 token: (r, n - s).
const n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n;
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
const keys = JSON.parse(readFileSync("out/keys.json", "utf8"));
const flipS = (token) => {
  const [h, p, s] = token.split(".");
  const raw = Buffer.from(s, "base64url");
  const sValue = BigInt("0x" + raw.subarray(32).toString("hex"));
  const flipped = Buffer.from((n - sValue).toString(16).padStart(64, "0"), "hex");
  return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), flipped]).toString("base64url")}`;
};
// For Ed25519, S + L encodes the same scalar mod L; RFC 8032 requires S < L.
const L = 2n ** 252n + 27742317777372353535851937790883648493n;
const addL = (token) => {
  const [h, p, s] = token.split(".");
  const raw = Buffer.from(s, "base64url");
  const S = BigInt("0x" + Buffer.from(raw.subarray(32)).reverse().toString("hex"));
  const bumped = Buffer.from((S + L).toString(16).padStart(64, "0"), "hex").reverse();
  return `${h}.${p}.${Buffer.concat([raw.subarray(0, 32), bumped]).toString("base64url")}`;
};

const check = async (label, token, alg, keyName) => {
  const out = [];
  try {
    await jwtVerify(token, await importJWK(keys[keyName].jwk, alg), { algorithms: [alg] });
    out.push("jose: accepted");
  } catch (e) {
    out.push(`jose: ${e.code}`);
  }
  if (alg === "ES256") {
    try {
      jwt.verify(token, keys[keyName].pem, { algorithms: [alg] });
      out.push("jsonwebtoken: accepted");
    } catch (e) {
      out.push(`jsonwebtoken: ${e.message}`);
    }
  }
  console.log(`${label.padEnd(30)} ${out.join(" | ")}`);
};
const esFlipped = flipS(tokens.ES256);
const edBumped = addL(tokens.EdDSA);
console.log(`ES256 token and its (r, n-s) twin differ: ${tokens.ES256 !== esFlipped}`);
await check("ES256 original", tokens.ES256, "ES256", "es");
await check("ES256 with s replaced by n-s", esFlipped, "ES256", "es");
await check("EdDSA original", tokens.EdDSA, "EdDSA", "ed");
await check("EdDSA with S replaced by S+L", edBumped, "EdDSA", "ed");
出力(node:24.22.0-bookworm-slim)
ES256 same input twice: b7-5MuAwyrsT26t_... vs ct-P2K38kCt5LlK4... -> identical: false
EdDSA same input twice: riZF8DpT02Grlv3U... vs riZF8DpT02Grlv3U... -> identical: true
ES256 token and its (r, n-s) twin differ: true
ES256 original                 jose: accepted | jsonwebtoken: accepted
ES256 with s replaced by n-s   jose: accepted | jsonwebtoken: accepted
EdDSA original                 jose: accepted
EdDSA with S replaced by S+L   jose: ERR_JWS_SIGNATURE_VERIFICATION_FAILED

ES256 の双子は同じクレームを持つので、これを使ってトークンを偽造できるわけではありません。ただし、トークン文字列そのものをトークンの識別子として扱うロジックは破綻します。漏洩したトークンのハッシュ値を保存して取り消している場合や、トークン文字列でリクエストの重複を排除している場合、トークンの所持者は双子を提示してチェックを通過できます。トークンはバイト列ではなく、必ず jti クレームで識別してください。Bitcoin が強制している low-S 正規化は JOSE には含まれておらず、テストしたライブラリにもこれを適用するものはありませんでした。

速度:決め手にはならない

Ed25519 は「2 倍速い」とよく言われますが、これは実装次第です。同じマシン(arm64、1 コア、Docker 内)で JWT の署名入力を署名・検証した結果は次のとおりです。

実装ES256 署名ES256 検証Ed25519 署名Ed25519 検証
Node.js 24.22、OpenSSL 3.5.864,840/s23,163/s60,249/s23,398/s
Go 1.27.3 標準ライブラリ53,877/s25,778/s75,218/s35,639/s

OpenSSL では両者の差は数パーセント以内で、Go では Ed25519 が P-256 より約 40% 高速です。いずれにしても、1 コアで毎秒 20,000 件を超えるトークンを検証でき、その前後にあるネットワーク処理や JSON の解析が許す処理量をはるかに上回ります。絶対値はハードウェアによって異なります。Node での計測コードは次のとおりです。

bench.mjs(node:24.22.0-bookworm-slim)
// Single-thread sign and verify throughput of a JWS signing input, ES256 vs Ed25519, node:crypto (OpenSSL).
import { generateKeyPairSync, sign, verify } from "node:crypto";

const input = Buffer.from("eyJhbGciOiJFUzI1NiJ9." + Buffer.from(JSON.stringify({ sub: "user-42", exp: 1924992000 })).toString("base64url"));
const algs = {
  ES256: { keys: generateKeyPairSync("ec", { namedCurve: "P-256" }), hash: "sha256", opts: { dsaEncoding: "ieee-p1363" } },
  Ed25519: { keys: generateKeyPairSync("ed25519"), hash: null, opts: {} },
};
const rate = (fn) => {
  for (let i = 0; i < 500; i++) fn(); // warm-up
  let count = 0;
  const start = process.hrtime.bigint();
  while (process.hrtime.bigint() - start < 2_000_000_000n) {
    fn();
    count++;
  }
  return Math.round(count / 2);
};
console.log(`node ${process.version}, OpenSSL ${process.versions.openssl}, ${process.arch}`);
for (const [name, { keys, hash, opts }] of Object.entries(algs)) {
  const signature = sign(hash, input, { key: keys.privateKey, ...opts });
  const s = rate(() => sign(hash, input, { key: keys.privateKey, ...opts }));
  const v = rate(() => verify(hash, input, { key: keys.publicKey, ...opts }, signature));
  console.log(`${name.padEnd(8)} sign ${String(s).padStart(6)}/s   verify ${String(v).padStart(6)}/s`);
}

ライブラリの対応状況(テスト結果)

各ライブラリで、同じ 3 つのトークンを検証しました。ES256 のトークン、EdDSA とラベル付けした Ed25519 のトークン、そして同じ鍵で Ed25519 とラベル付けしたトークンです。どの呼び出しでも、許可したのはそのトークン自身のアルゴリズムだけです。

ライブラリES256EdDSAEd25519
jose 6.2.13(Node.js)対応対応対応
jsonwebtoken 9.0.3(Node.js)対応非対応:Unknown key type "ed25519".非対応
PyJWT 2.15.1対応対応非対応:Algorithm not supported
joserfc 1.7.7(Python)対応対応(EdDSA is deprecated via RFC 9864 という警告あり)対応
golang-jwt v5.3.1対応対応非対応:signing method (alg) is unavailable
go-jose v4.1.6対応対応非対応:error in cryptographic primitive
jwx v3.3.0(Go)対応対応対応
Nimbus JOSE+JWT 10.10(Java)対応対応(オプションの依存関係 Tink が必要)対応
JJWT 0.13.0(Java、JDK 25)対応対応非対応:Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'.
Microsoft.IdentityModel.JsonWebTokens 8.23.0(.NET 10)対応非対応:IDX10511: Signature validation failed.非対応
firebase/php-jwt 7.2.1(PHP 8.5、sodium)対応対応非対応:Algorithm not supported
ruby-jwt 3.3.0(jwt-eddsa 1.0.0 を併用)対応対応(拡張なしでは Key type OKP not supported)対応
jsonwebtoken 11.1.0(Rust、rust_crypto)対応対応非対応:InvalidAlgorithmName

ES256 は 13 のライブラリすべてで、EdDSA は 11、Ed25519 は 5 のライブラリで動作しました。判断を左右することが多い 2 つの欠落は、最も一般的なスタックにあります。npm パッケージの jsonwebtoken には EdDSA がまったくなく、ASP.NET Core の JWT ベアラー認証が使う Microsoft.IdentityModel は、Ed25519 の JWK に対して IsSupportedAlgorithm: False を返します。.NET での失敗は汎用的な IDX10511 メッセージとして現れるため、アルゴリズムの未対応ではなく鍵の誤りを探しに行ってしまいがちです。Nimbus は、クラスパスに Tink がないと実行時に ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify が発生して失敗します。

ブラウザーでは、Web Cryptography API の Ed25519 が Firefox 129、Safari 17、Chrome 137 で提供されました(Chrome 137 のリリースノート、2025年5月)。そのため、DPoP プルーフなどのブラウザー内での署名にも、今ではどちらの曲線も使えます。

クラウド KMS と HSM の対応状況

署名鍵をマネージドな鍵サービスに置く場合、使えるアルゴリズムはサービスによって決まります。2026年10月時点の各プロバイダーのドキュメントによると、次のとおりです。

サービスP-256(ES256)Ed25519
AWS KMSECC_NIST_P256、ECDSA_SHA_256ECC_NIST_EDWARDS25519、ED25519_SHA_512(2025年11月から)
Google Cloud KMSEC_SIGN_P256_SHA256(HSM を含むすべての保護レベル)EC_SIGN_ED25519(ソフトウェア保護レベルのみ)
Azure Key Vault と Managed HSMP-256(EC と EC-HSM)提供なし
AWS CloudHSM対応hsm2m.medium の非 FIPS モードのみ

出典:AWS KMS の鍵仕様リファレンス、AWS KMS の Sign API、Google Cloud KMS のアルゴリズム、Azure Key Vault の鍵、Managed HSM の鍵、AWS CloudHSM Client SDK のリリース。

実務上の詳細を 2 つ補足します。

  • エンコーディング:AWS KMS と Google Cloud KMS は、ECDSA 署名を DER エンコードで返します。JWS には 64 バイトの生形式 r ‖ s(RFC 7518 §3.4)が必要なので、KMS の上に構築した ES256 の署名処理では変換しなければなりません。この変換を忘れるのは、jsonwebtoken の ES256 signatures must be 64 bytes のような署名無効エラーの典型的な原因です。変換方法は ECDSA 署名の形式で解説しており、1 つの署名なら DER ⇄ 生形式の変換ツールで変換できます。
  • メッセージサイズ:AWS KMS の Ed25519 では MessageType: RAW が必須で、Sign が受け付けるのは最大 4096 バイトです。JWS の署名入力はエンコードしたヘッダーとペイロードなので、大きなクレームを含むトークンは KMS の Ed25519 では署名できません。ECDSA なら MessageType: DIGEST で SHA-256 のダイジェストを送れるため、このような制限はありません。

標準とプロバイダーの要件

  • OpenID Connect は、限られた例外を除き、ID トークンでの RS256 への対応をプロバイダーに求めています(OIDC Core §15.1)。どちらの曲線もこれに代わるものではなく、ES256 や EdDSA を提供するプロバイダーは、RS256 に加えて提供しています。
  • FAPI 2.0 Security Profile(最終版、2025年2月、§5.4.1)は、JWT に PS256、ES256、Ed25519 による EdDSA だけを認めており、完全指定の Ed25519 識別子も登録されれば認めるとしています。その登録は、その後 RFC 9864 で行われました。
  • JWT ベストプラクティスの改訂版(draft-ietf-oauth-rfc8725bis、RFC Editor キューで公開待ち)は、新規の導入では RFC 9864 の完全指定の名前を優先するよう求めています。

どちらの曲線もポスト量子暗号ではありません。ML-DSA にはすでに JOSE の識別子があります(RFC 9964、2026年5月)。ただし、有効期間が数分のトークンにとっての問題は、今日のトークンが後で読まれるかどうかではなく、検証者がいつ従来型の署名を拒否しなければならなくなるかです。その切り替えを可能にするのは、次のセクションのように、鍵ごとにアルゴリズムを設定できるようにしておくことです。

選び方と、後からの切り替え

状況選択
第三者、.NET のサービス、または npm パッケージの jsonwebtoken がトークンを検証するES256
署名鍵を Azure Key Vault、Managed HSM、Google Cloud HSM、または FIPS モードの HSM に置くES256
すべての検証者を自分で管理し、鍵はソフトウェアか AWS KMS にあるEd25519
API やプロファイル(FAPI 2.0、パートナーの仕様など)が特定のアルゴリズムしか挙げていない挙げられているもの(最もよく含まれているのは ES256)

今 Ed25519 を選ぶなら、"alg": "EdDSA" を出力してください。RFC 9864 による非推奨化は SHOULD であり、文書化された運用上の支障があれば従わなくてもかまいません。新しい名前を拒否する検証者の存在は、まさにその支障に当たります。検証側では両方の名前を受け入れ、ただし crv が Ed25519 の OKP 鍵に限ってください。そうすれば、非推奨の名前が意図しない曲線を選ぶことはありません。jose では次のようにします。

Ed25519 の 2 つの名前を、1 つの鍵に限って受け入れる
const { payload } = await jwtVerify(token, ed25519PublicKey, {
  algorithms: ["Ed25519", "EdDSA"],
  issuer: "https://id.example.com",
  audience: "https://api.example.com",
});

この記事のトークンで実行したところ(jose 6.2.13)、EdDSA と Ed25519 のトークンはどちらも受け入れられ、ES256 のトークンは "alg" (Algorithm) Header Parameter value not allowed で拒否されました。後からアルゴリズムを切り替えるのは、鍵のローテーションにほかなりません。新しい鍵を独自の kid と alg 付きで古い鍵と並べて公開し、新しい鍵で署名を始め、最後の古いトークンが期限切れになってから古い鍵を削除します。タイミングについてはダウンタイムなしの JWKS 鍵ローテーションで、トークンにアルゴリズムを選ばせないよう鍵ごとにアルゴリズムを固定する方法については JWT 検証のよくある間違いで解説しています。

出典