Skip to content
ecdsa.com
← All articles

ES256 vs EdDSA for JWT: which to choose in 2026

Both produce 64-byte signatures at 128-bit security. They differ in how they fail, where the keys can live, and which libraries can read them. Every claim below was tested against current library versions or checked against the provider's documentation in October 2026.

Short answer. Sign with ES256 when tokens leave your control: every JOSE library we tested verifies it, every major cloud KMS and HSM can hold the key, and standards such as FAPI 2.0 list it. Choose Ed25519 when you control every verifier and can keep the key in software or in AWS KMS: its signatures are deterministic and cannot be altered into a second valid form, which removes two classes of ECDSA mistakes. If you choose Ed25519, publish it as "alg": "EdDSA" for now and accept both EdDSA and the new name Ed25519 on the verify side. Fewer than half of the libraries we tested understand the new name yet.

All tokens and keys on this page were generated for it. Library results come from runs in official Docker images on 20 October 2026; the versions are listed with each result.

What the two names mean

ES256EdDSA / Ed25519
SchemeECDSA on P-256 with SHA-256EdDSA on Edwards25519 (RFC 8032 §5.1)
JOSE definitionRFC 7518 §3.4RFC 8037, renamed by RFC 9864
IANA JOSE statusRecommended+EdDSA: Deprecated; Ed25519: Optional
JWK"kty": "EC", crv, x, y"kty": "OKP", "crv": "Ed25519", x
Signaturer ‖ s, 64 bytesR ‖ S, 64 bytes
Approved in FIPS 186-5yesyes

The name change matters more than it looks. RFC 8037 registered a single EdDSA value for both Ed25519 and Ed448, so the alg alone does not say which curve to use; the key's crv does. RFC 9864 (October 2025) deprecated that polymorphic name and registered the fully specified Ed25519 and Ed448. The key format does not change; only the alg string does. ES256 was already fully specified in JOSE and needs no rename. In COSE, RFC 9864 did the same to ECDSA: ES256 (-7) is deprecated in favor of ESP256 (-9), which matters for WebAuthn and COSE-based credentials, not for JWT.

Size: identical where it counts

Both signatures are 64 bytes, 86 base64url characters. The only differences are the header string and the public key:

Output: the same claims signed three ways (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"}

An Ed25519 public key is 32 bytes, a P-256 key is two 32-byte coordinates, so a JWKS shrinks by about 47 characters per key. Nobody should choose an algorithm over that. Against RS256 the picture is different: an RSA-2048 signature is 256 bytes, which is why both curves beat it for tokens in cookies and headers (see ES256 vs RS256).

Determinism: where ECDSA can fail and EdDSA cannot

ECDSA needs a fresh secret number for every signature. Reuse it once, or let it be slightly predictable, and the private key can be computed from public signatures; ECDSA nonce reuse shows the algebra and the real incidents. Most JOSE libraries draw that number from the system random generator, so the same input gives a different signature each time. RFC 6979 defines a deterministic way to derive it, but whether your library, your HSM or your KMS uses it is outside your control.

EdDSA derives the per-signature value from the private key and the message by design (RFC 8032 §5.1.6). Same key, same message, same signature, with no random generator involved. That removes the failure mode behind the PlayStation 3 and the 2013 Android wallet key leaks. It does not remove every risk: deterministic signing is more exposed to fault injection on hardware an attacker can touch, which is why some newer designs add randomness back in. For JWT issuers running on servers, determinism is a clear win.

Malleability: a second valid token for free

An ECDSA signature (r, s) has a twin: (r, n − s) verifies against the same key and message, where n is the order of the curve. JOSE libraries do not reject the twin. Ed25519 as specified in RFC 8032 requires the verifier to reject any S that is not smaller than the group order L, so adding L to S does not produce a second valid signature. Both claims, tested:

props.mjs: determinism and malleability (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");
Output (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

The ES256 twin carries the same claims, so it does not let anyone forge a token. It does break any logic that treats the token string as its identity. If you revoke a leaked token by storing its hash, or deduplicate requests by token string, the holder can present the twin and pass. Identify tokens by a jti claim, never by their bytes. Low-S normalization, which Bitcoin enforces, is not part of JOSE, and no library we tested applies it.

Speed: not a deciding factor

The common claim that Ed25519 is "twice as fast" depends on the implementation. On the same machine (arm64, one core, inside Docker), signing and verifying a JWT signing input:

ImplementationES256 signES256 verifyEd25519 signEd25519 verify
Node.js 24.22, OpenSSL 3.5.864,840/s23,163/s60,249/s23,398/s
Go 1.27.3 standard library53,877/s25,778/s75,218/s35,639/s

With OpenSSL the two are within a few percent of each other; Go's Ed25519 is about 40% faster than its P-256. Either way a single core verifies more than twenty thousand tokens per second, far beyond what the network and JSON parsing around it allow. Your hardware will give different absolute numbers. The Node measurement:

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`);
}

Library support, tested

Each library verified the same three tokens: one ES256, one Ed25519 token labelled EdDSA, and the same key's token labelled Ed25519. Every call allowed only the token's own algorithm.

LibraryES256EdDSAEd25519
jose 6.2.13 (Node.js)yesyesyes
jsonwebtoken 9.0.3 (Node.js)yesno: Unknown key type "ed25519".no
PyJWT 2.15.1yesyesno: Algorithm not supported
joserfc 1.7.7 (Python)yesyes, warns EdDSA is deprecated via RFC 9864yes
golang-jwt v5.3.1yesyesno: signing method (alg) is unavailable
go-jose v4.1.6yesyesno: error in cryptographic primitive
jwx v3.3.0 (Go)yesyesyes
Nimbus JOSE+JWT 10.10 (Java)yesyes, needs the optional Tink dependencyyes
JJWT 0.13.0 (Java, JDK 25)yesyesno: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'.
Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10)yesno: IDX10511: Signature validation failed.no
firebase/php-jwt 7.2.1 (PHP 8.5, sodium)yesyesno: Algorithm not supported
ruby-jwt 3.3.0 with jwt-eddsa 1.0.0yesyes; without the extension Key type OKP not supportedyes
jsonwebtoken 11.1.0 (Rust, rust_crypto)yesyesno: InvalidAlgorithmName

ES256 worked in 13 of 13, EdDSA in 11, Ed25519 in 5. The two gaps that most often decide the question are in the most common stacks: the jsonwebtoken npm package has no EdDSA at all, and Microsoft.IdentityModel, which ASP.NET Core's JWT bearer authentication uses, reports IsSupportedAlgorithm: False for an Ed25519 JWK. The .NET failure is the generic IDX10511 message, which sends people looking for a wrong key rather than a missing algorithm. Nimbus without Tink on the classpath fails at runtime with ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify.

In browsers, Ed25519 in the Web Cryptography API shipped in Firefox 129, Safari 17 and Chrome 137 (Chrome 137 release notes, May 2025), so in-browser signing, for example of DPoP proofs, can now use either curve.

Cloud KMS and HSM support

If the signing key lives in a managed key service, the service decides for you. As documented by each provider in October 2026:

ServiceP-256 (ES256)Ed25519
AWS KMSECC_NIST_P256, ECDSA_SHA_256ECC_NIST_EDWARDS25519, ED25519_SHA_512, since November 2025
Google Cloud KMSEC_SIGN_P256_SHA256 at every protection level, including HSMEC_SIGN_ED25519, software protection level only
Azure Key Vault and Managed HSMP-256 (EC and EC-HSM)not offered
AWS CloudHSMyeshsm2m.medium in non-FIPS mode only

Sources: AWS KMS key spec reference, AWS KMS Sign API, Google Cloud KMS algorithms, Azure Key Vault keys, Managed HSM keys, AWS CloudHSM Client SDK releases.

Two practical details:

  • Encoding. AWS KMS and Google Cloud KMS return ECDSA signatures DER-encoded. A JWS needs the raw 64-byte r ‖ s form (RFC 7518 §3.4), so an ES256 signer built on KMS has to convert. Forgetting to is a classic cause of invalid-signature errors, such as jsonwebtoken's ES256 signatures must be 64 bytes; ECDSA signature formats covers the conversion, and the DER to raw converter does it for a single signature.
  • Message size. Ed25519 in AWS KMS requires MessageType: RAW, and Sign accepts at most 4096 bytes. The JWS signing input is the encoded header and payload, so a token with large claims cannot be signed with Ed25519 in KMS. ECDSA has no such limit, because you can send a SHA-256 digest with MessageType: DIGEST.

What standards and providers require

  • OpenID Connect requires providers to support RS256 for ID tokens, with a narrow exception (OIDC Core §15.1). Neither curve replaces that; providers that offer ES256 or EdDSA do so in addition.
  • FAPI 2.0 Security Profile (final, February 2025, §5.4.1) allows only PS256, ES256 and EdDSA with Ed25519 for JWTs, and says a fully specified Ed25519 identifier would be allowed once registered, which RFC 9864 has since done.
  • The JWT best practices revision (draft-ietf-oauth-rfc8725bis, in the RFC Editor queue) tells new deployments to prefer the fully specified names from RFC 9864.

Neither curve is post-quantum. ML-DSA now has JOSE identifiers (RFC 9964, May 2026), but for tokens that live minutes the question is when verifiers will need to reject classical signatures, not whether today's tokens can be read later. Keeping algorithms configurable per key, as in the next section, is what makes that switch possible.

Choosing, and switching later

SituationChoose
Tokens verified by third parties, .NET services or the jsonwebtoken npm packageES256
Signing key in Azure Key Vault, Managed HSM, Google Cloud HSM or a FIPS-mode HSMES256
You control every verifier, the key is in software or AWS KMSEd25519
An API or profile lists only certain algorithms (FAPI 2.0, a partner spec)what it lists; ES256 is the one most often present

If you choose Ed25519 today, emit "alg": "EdDSA". RFC 9864's deprecation is a SHOULD that allows documented operational obstacles, and a verifier that rejects the new name is one. On the verify side, accept both names, and only with an OKP key whose crv is Ed25519, so that a deprecated name never selects a curve you did not intend. With jose:

Accept both Ed25519 names, for one key only
const { payload } = await jwtVerify(token, ed25519PublicKey, {
  algorithms: ["Ed25519", "EdDSA"],
  issuer: "https://id.example.com",
  audience: "https://api.example.com",
});

Run against this article's tokens (jose 6.2.13), it accepted both the EdDSA and the Ed25519 token and rejected the ES256 one with "alg" (Algorithm) Header Parameter value not allowed. Switching algorithms later is a key rotation: publish the new key with its own kid and alg next to the old one, start signing with it, and remove the old key after the last old token has expired. JWKS key rotation without downtime walks through the timing, and JWT verification mistakes covers pinning the algorithm per key so that the token never chooses it.

Sources