Saltar al contenido
ecdsa.com
← Todos los artículos

ES256 frente a EdDSA para JWT: cuál elegir en 2026

Ambos producen firmas de 64 bytes con seguridad de 128 bits. Se diferencian en cómo fallan, dónde pueden residir las claves y qué bibliotecas pueden leerlas. Cada afirmación de este artículo se probó con las versiones actuales de las bibliotecas o se contrastó con la documentación del proveedor en octubre de 2026.

Respuesta breve. Firma con ES256 cuando los tokens salen de tu control: todas las bibliotecas JOSE que probamos lo verifican, todos los grandes KMS en la nube y HSM pueden guardar la clave, y estándares como FAPI 2.0 lo incluyen. Elige Ed25519 cuando controlas todos los verificadores y puedes guardar la clave en software o en AWS KMS: sus firmas son deterministas y no se pueden alterar para obtener una segunda forma válida, lo que elimina dos clases de errores de ECDSA. Si eliges Ed25519, publícalo por ahora como "alg": "EdDSA" y acepta tanto EdDSA como el nuevo nombre Ed25519 en el lado de la verificación. Todavía menos de la mitad de las bibliotecas que probamos entienden el nuevo nombre.

Todos los tokens y las claves de esta página se generaron para ella. Los resultados de las bibliotecas proceden de ejecuciones en imágenes oficiales de Docker el 20 de octubre de 2026; las versiones figuran junto a cada resultado.

Qué significan los dos nombres

ES256EdDSA / Ed25519
EsquemaECDSA sobre P-256 con SHA-256EdDSA sobre Edwards25519 (RFC 8032 §5.1)
Definición en JOSERFC 7518 §3.4RFC 8037, renombrado por el RFC 9864
Estado en el registro JOSE de la IANARecomendado+EdDSA: obsoleto; Ed25519: opcional
JWK"kty": "EC", crv, x, y"kty": "OKP", "crv": "Ed25519", x
Firmar ‖ s, 64 bytesR ‖ S, 64 bytes
Aprobado en FIPS 186-5sísí

El cambio de nombre importa más de lo que parece. El RFC 8037 registró un único valor EdDSA para Ed25519 y Ed448, así que el alg por sí solo no indica qué curva usar; lo indica el crv de la clave. El RFC 9864 (octubre de 2025) declaró obsoleto ese nombre polimórfico y registró los nombres totalmente especificados Ed25519 y Ed448. El formato de la clave no cambia; solo cambia la cadena alg. ES256 ya estaba totalmente especificado en JOSE y no necesita cambiar de nombre. En COSE, el RFC 9864 hizo lo mismo con ECDSA: ES256 (-7) queda obsoleto en favor de ESP256 (-9), lo que afecta a WebAuthn y a las credenciales basadas en COSE, no a JWT.

Tamaño: idéntico donde importa

Ambas firmas ocupan 64 bytes, 86 caracteres base64url. Las únicas diferencias son la cadena del encabezado y la clave pública:

Salida: los mismos claims firmados de tres formas (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"}

Una clave pública Ed25519 ocupa 32 bytes y una clave P-256 son dos coordenadas de 32 bytes, así que un JWKS se reduce en unos 47 caracteres por clave. Nadie debería elegir un algoritmo por eso. Frente a RS256 el panorama cambia: una firma RSA-2048 ocupa 256 bytes, y por eso las dos curvas lo superan en tokens que viajan en cookies y encabezados (consulta ES256 frente a RS256).

Determinismo: donde ECDSA puede fallar y EdDSA no

ECDSA necesita un número secreto nuevo para cada firma. Basta con reutilizarlo una vez, o con que sea ligeramente predecible, para que la clave privada pueda calcularse a partir de firmas públicas; Reutilización del nonce en ECDSA muestra el álgebra y los incidentes reales. La mayoría de las bibliotecas JOSE toman ese número del generador aleatorio del sistema, así que la misma entrada produce una firma distinta cada vez. El RFC 6979 define una forma determinista de derivarlo, pero que tu biblioteca, tu HSM o tu KMS la usen no depende de ti.

EdDSA deriva por diseño el valor de cada firma a partir de la clave privada y del mensaje (RFC 8032 §5.1.6). Misma clave, mismo mensaje, misma firma, sin que intervenga ningún generador aleatorio. Eso elimina el modo de fallo que provocó las filtraciones de claves de la PlayStation 3 y, en 2013, de las billeteras de Android. No elimina todos los riesgos: la firma determinista está más expuesta a la inyección de fallos en hardware al que un atacante tiene acceso físico, y por eso algunos diseños más recientes vuelven a añadir aleatoriedad. Para los emisores de JWT que se ejecutan en servidores, el determinismo es una ventaja clara.

Maleabilidad: un segundo token válido gratis

Una firma ECDSA (r, s) tiene una gemela: (r, n − s) se verifica con la misma clave y el mismo mensaje, donde n es el orden de la curva. Las bibliotecas JOSE no rechazan la gemela. Ed25519, tal como lo especifica el RFC 8032, exige que el verificador rechace cualquier S que no sea menor que el orden del grupo L, así que sumar L a S no produce una segunda firma válida. Las dos afirmaciones, comprobadas:

props.mjs: determinismo y maleabilidad (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");
Salida (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

La gemela ES256 lleva los mismos claims, así que no permite a nadie falsificar un token. Lo que sí rompe es cualquier lógica que trate la cadena del token como su identidad. Si revocas un token filtrado guardando su hash, o descartas solicitudes duplicadas comparando la cadena del token, el titular puede presentar la gemela y pasar. Identifica los tokens por un claim jti, nunca por sus bytes. La normalización low-S, que Bitcoin exige, no forma parte de JOSE, y ninguna de las bibliotecas que probamos la aplica.

Velocidad: no es un factor decisivo

La afirmación habitual de que Ed25519 es «el doble de rápido» depende de la implementación. En la misma máquina (arm64, un núcleo, dentro de Docker), al firmar y verificar la entrada de firma (signing input) de un JWT:

ImplementaciónFirma ES256Verificación ES256Firma Ed25519Verificación Ed25519
Node.js 24.22, OpenSSL 3.5.864.840/s23.163/s60.249/s23.398/s
Biblioteca estándar de Go 1.27.353.877/s25.778/s75.218/s35.639/s

Con OpenSSL, la diferencia entre los dos es de apenas un pequeño porcentaje; en Go, Ed25519 es alrededor de un 40 % más rápido que P-256. En cualquier caso, un solo núcleo verifica más de veinte mil tokens por segundo, muy por encima de lo que permiten la red y el análisis del JSON que acompañan a cada verificación. Tu hardware dará otras cifras absolutas. La medición de 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`);
}

Compatibilidad de las bibliotecas, probada

Cada biblioteca verificó los mismos tres tokens: uno ES256, uno Ed25519 etiquetado como EdDSA y el token de la misma clave etiquetado como Ed25519. Cada llamada permitía solo el algoritmo del propio token.

BibliotecaES256EdDSAEd25519
jose 6.2.13 (Node.js)sísísí
jsonwebtoken 9.0.3 (Node.js)síno: Unknown key type "ed25519".no
PyJWT 2.15.1sísíno: Algorithm not supported
joserfc 1.7.7 (Python)sísí, con el aviso EdDSA is deprecated via RFC 9864sí
golang-jwt v5.3.1sísíno: signing method (alg) is unavailable
go-jose v4.1.6sísíno: error in cryptographic primitive
jwx v3.3.0 (Go)sísísí
Nimbus JOSE+JWT 10.10 (Java)sísí, requiere la dependencia opcional Tinksí
JJWT 0.13.0 (Java, JDK 25)sísíno: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'.
Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10)síno: IDX10511: Signature validation failed.no
firebase/php-jwt 7.2.1 (PHP 8.5, sodium)sísíno: Algorithm not supported
ruby-jwt 3.3.0 con jwt-eddsa 1.0.0sísí; sin la extensión, Key type OKP not supportedsí
jsonwebtoken 11.1.0 (Rust, rust_crypto)sísíno: InvalidAlgorithmName

ES256 funcionó en 13 de 13, EdDSA en 11 y Ed25519 en 5. Las dos carencias que más a menudo deciden la cuestión están en las plataformas más habituales: el paquete npm jsonwebtoken no admite EdDSA en absoluto, y Microsoft.IdentityModel, en el que se apoya la autenticación JWT Bearer de ASP.NET Core, devuelve IsSupportedAlgorithm: False para una JWK Ed25519. El fallo de .NET es el mensaje genérico IDX10511, que lleva a buscar una clave equivocada en lugar de un algoritmo que falta. Nimbus sin Tink en el classpath falla en tiempo de ejecución con ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify.

En los navegadores, Ed25519 llegó a la Web Cryptography API con Firefox 129, Safari 17 y Chrome 137 (notas de la versión de Chrome 137, mayo de 2025), así que la firma en el navegador, por ejemplo de pruebas DPoP, ya puede usar cualquiera de las dos curvas.

Compatibilidad de los KMS en la nube y los HSM

Si la clave de firma reside en un servicio de claves gestionado, el servicio decide por ti. Según la documentación de cada proveedor en octubre de 2026:

ServicioP-256 (ES256)Ed25519
AWS KMSECC_NIST_P256, ECDSA_SHA_256ECC_NIST_EDWARDS25519, ED25519_SHA_512, desde noviembre de 2025
Google Cloud KMSEC_SIGN_P256_SHA256 en todos los niveles de protección, incluido HSMEC_SIGN_ED25519, solo en el nivel de protección de software
Azure Key Vault y Managed HSMP-256 (EC y EC-HSM)no disponible
AWS CloudHSMsíhsm2m.medium, solo en modo no FIPS

Fuentes: referencia de especificaciones de clave de AWS KMS, API Sign de AWS KMS, algoritmos de Google Cloud KMS, claves de Azure Key Vault, claves de Managed HSM, versiones del Client SDK de AWS CloudHSM.

Dos detalles prácticos:

  • Codificación. AWS KMS y Google Cloud KMS devuelven las firmas ECDSA codificadas en DER. Un JWS necesita la forma r ‖ s en bruto de 64 bytes (RFC 7518 §3.4), así que un firmante ES256 construido sobre KMS tiene que convertirlas. Olvidarlo es una causa clásica de errores de firma no válida, como el error de jsonwebtoken ES256 signatures must be 64 bytes; Formatos de firma ECDSA explica la conversión, y el conversor DER ⇄ en bruto la hace para una firma suelta.
  • Tamaño del mensaje. Ed25519 en AWS KMS exige MessageType: RAW, y Sign acepta como máximo 4096 bytes. La entrada de firma del JWS es el encabezado y la carga útil codificados, así que un token con claims grandes no se puede firmar con Ed25519 en KMS. ECDSA no tiene ese límite, porque puedes enviar un resumen SHA-256 con MessageType: DIGEST.

Qué exigen los estándares y los proveedores

  • OpenID Connect exige a los proveedores admitir RS256 para los tokens de ID, con una excepción limitada (OIDC Core §15.1). Ninguna de las dos curvas lo sustituye; los proveedores que ofrecen ES256 o EdDSA lo hacen como complemento.
  • FAPI 2.0 Security Profile (versión final, febrero de 2025, §5.4.1) solo permite PS256, ES256 y EdDSA con Ed25519 para los JWT, y dice que un identificador de Ed25519 totalmente especificado se permitiría una vez registrado, cosa que el RFC 9864 ha hecho desde entonces.
  • La revisión de las buenas prácticas de JWT (draft-ietf-oauth-rfc8725bis, en la cola del RFC Editor) indica a los despliegues nuevos que prefieran los nombres totalmente especificados del RFC 9864.

Ninguna de las dos curvas es poscuántica. ML-DSA ya tiene identificadores JOSE (RFC 9964, mayo de 2026), pero para tokens que viven minutos la cuestión es cuándo tendrán que rechazar los verificadores las firmas clásicas, no si los tokens de hoy podrán leerse más adelante. Mantener los algoritmos configurables por clave, como en la siguiente sección, es lo que hace posible ese cambio.

Cómo elegir y cómo cambiar más adelante

SituaciónElige
Tokens verificados por terceros, por servicios .NET o por el paquete npm jsonwebtokenES256
Clave de firma en Azure Key Vault, Managed HSM, Google Cloud HSM o un HSM en modo FIPSES256
Controlas todos los verificadores y la clave está en software o en AWS KMSEd25519
Una API o un perfil solo enumera ciertos algoritmos (FAPI 2.0, la especificación de un socio)los que enumere; ES256 es el que aparece con más frecuencia

Si eliges Ed25519 hoy, emite "alg": "EdDSA". La declaración de obsolescencia del RFC 9864 es un SHOULD que admite obstáculos operativos documentados, y un verificador que rechaza el nuevo nombre es uno de ellos. En el lado de la verificación, acepta ambos nombres, y solo con una clave OKP cuyo crv sea Ed25519, para que un nombre obsoleto nunca seleccione una curva que no pretendías. Con jose:

Aceptar los dos nombres de Ed25519, solo para una clave
const { payload } = await jwtVerify(token, ed25519PublicKey, {
  algorithms: ["Ed25519", "EdDSA"],
  issuer: "https://id.example.com",
  audience: "https://api.example.com",
});

Ejecutado con los tokens de este artículo (jose 6.2.13), aceptó tanto el token EdDSA como el Ed25519 y rechazó el ES256 con "alg" (Algorithm) Header Parameter value not allowed. Cambiar de algoritmo más adelante es una rotación de claves: publica la clave nueva con su propio kid y su propio alg junto a la antigua, empieza a firmar con ella y retira la clave antigua cuando haya caducado el último token antiguo. Rotación de claves JWKS sin interrupciones detalla los plazos, y Errores al verificar JWT explica la fijación del algoritmo por clave para que el token nunca lo elija.

Fuentes