Pular para o conteúdo
ecdsa.com
← Todos os artigos

ES256 vs EdDSA para JWT: qual escolher em 2026

Os dois produzem assinaturas de 64 bytes com segurança de 128 bits. Eles diferem na forma como falham, em onde as chaves podem ficar e em quais bibliotecas conseguem lê-las. Cada afirmação abaixo foi testada com as versões atuais das bibliotecas ou conferida na documentação do provedor em outubro de 2026.

Resposta curta. Assine com ES256 quando os tokens saírem do seu controle: todas as bibliotecas JOSE que testamos o verificam, todos os principais KMS e HSM em nuvem conseguem guardar a chave, e padrões como o FAPI 2.0 o incluem na lista. Escolha Ed25519 quando você controlar todos os verificadores e puder manter a chave em software ou no AWS KMS: as assinaturas dele são determinísticas e não podem ser alteradas para uma segunda forma válida, o que elimina duas classes de erros do ECDSA. Se escolher Ed25519, publique-o por enquanto como "alg": "EdDSA" e aceite tanto EdDSA quanto o novo nome Ed25519 na verificação. Menos da metade das bibliotecas que testamos já entende o novo nome.

Todos os tokens e chaves desta página foram gerados para ela. Os resultados das bibliotecas vêm de execuções em imagens oficiais do Docker em 20 de outubro de 2026; as versões aparecem junto de cada resultado.

O que significam os dois nomes

ES256EdDSA / Ed25519
EsquemaECDSA na curva P-256 com SHA-256EdDSA na curva Edwards25519 (RFC 8032 §5.1)
Definição no JOSERFC 7518 §3.4RFC 8037, com o nome alterado pela RFC 9864
Status no registro JOSE da IANARecomendado+EdDSA: descontinuado; Ed25519: opcional
JWK"kty": "EC", crv, x, y"kty": "OKP", "crv": "Ed25519", x
Assinaturar ‖ s, 64 bytesR ‖ S, 64 bytes
Aprovado no FIPS 186-5simsim

A mudança de nome importa mais do que parece. A RFC 8037 registrou um único valor EdDSA para Ed25519 e Ed448, então o alg sozinho não diz qual curva usar; quem diz é o crv da chave. A RFC 9864 (outubro de 2025) descontinuou esse nome polimórfico e registrou os nomes totalmente especificados Ed25519 e Ed448. O formato da chave não muda; muda só a string do alg. O ES256 já era totalmente especificado no JOSE e não precisa de um novo nome. No COSE, a RFC 9864 fez o mesmo com o ECDSA: ES256 (-7) foi descontinuado em favor de ESP256 (-9), o que importa para o WebAuthn e para credenciais baseadas em COSE, não para JWT.

Tamanho: idêntico onde importa

As duas assinaturas têm 64 bytes, ou 86 caracteres em base64url. As únicas diferenças estão na string do cabeçalho e na chave pública:

Saída: as mesmas claims assinadas de três 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"}

Uma chave pública Ed25519 tem 32 bytes, e uma chave P-256 tem duas coordenadas de 32 bytes, então um JWKS encolhe cerca de 47 caracteres por chave. Ninguém deveria escolher um algoritmo por causa disso. Em relação ao RS256, o quadro é outro: uma assinatura RSA-2048 tem 256 bytes, e é por isso que as duas curvas levam vantagem sobre ele para tokens em cookies e cabeçalhos (veja ES256 vs RS256).

Determinismo: onde o ECDSA pode falhar e o EdDSA não

O ECDSA precisa de um número secreto novo para cada assinatura. Basta reutilizá-lo uma vez, ou deixá-lo um pouco previsível, para que a chave privada possa ser calculada a partir de assinaturas públicas; o artigo sobre reutilização de nonce no ECDSA mostra a álgebra e os incidentes reais. A maioria das bibliotecas JOSE tira esse número do gerador aleatório do sistema, então a mesma entrada gera uma assinatura diferente a cada vez. A RFC 6979 define uma forma determinística de derivá-lo, mas se sua biblioteca, seu HSM ou seu KMS usam esse método está fora do seu controle.

O EdDSA, por projeto, deriva da chave privada e da mensagem o valor usado em cada assinatura (RFC 8032 §5.1.6). Mesma chave, mesma mensagem, mesma assinatura, sem nenhum gerador aleatório envolvido. Isso elimina o modo de falha por trás do vazamento da chave do PlayStation 3 e dos vazamentos de chaves de carteiras Android de 2013. Não elimina todos os riscos: a assinatura determinística fica mais exposta à injeção de falhas em hardware ao qual um atacante tem acesso físico, e é por isso que alguns projetos mais recentes voltam a acrescentar aleatoriedade. Para emissores de JWT que rodam em servidores, o determinismo é uma vantagem clara.

Maleabilidade: um segundo token válido de graça

Uma assinatura ECDSA (r, s) tem uma gêmea: (r, n − s) passa na verificação com a mesma chave e a mesma mensagem, onde n é a ordem da curva. As bibliotecas JOSE não rejeitam a gêmea. O Ed25519, como especificado na RFC 8032, exige que o verificador rejeite qualquer S que não seja menor que a ordem do grupo L, então somar L a S não produz uma segunda assinatura válida. As duas afirmações, testadas:

props.mjs: determinismo e maleabilidade (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");
Saída (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

A gêmea do ES256 carrega as mesmas claims, então não permite que ninguém forje um token. Mas ela quebra qualquer lógica que trate a string do token como a identidade dele. Se você revoga um token vazado guardando o hash dele, ou elimina requisições duplicadas pela string do token, o portador pode apresentar a gêmea e passar. Identifique os tokens por uma claim jti, nunca pelos bytes. A normalização low-S, que o Bitcoin impõe, não faz parte do JOSE, e nenhuma biblioteca que testamos a aplica.

Velocidade: não é um fator decisivo

A afirmação comum de que o Ed25519 é “duas vezes mais rápido” depende da implementação. Na mesma máquina (arm64, um núcleo, dentro do Docker), assinando e verificando a entrada de assinatura de um JWT:

ImplementaçãoAssinar ES256Verificar ES256Assinar Ed25519Verificar Ed25519
Node.js 24.22, OpenSSL 3.5.864.840/s23.163/s60.249/s23.398/s
Go 1.27.3, biblioteca padrão53.877/s25.778/s75.218/s35.639/s

Com o OpenSSL, os dois ficam a poucos por cento um do outro; no Go, o Ed25519 é cerca de 40% mais rápido que o P-256. De todo modo, um único núcleo verifica mais de vinte mil tokens por segundo, muito além do que a rede e o parsing do JSON ao redor permitem. Seu hardware vai dar números absolutos diferentes. A medição no 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`);
}

Suporte nas bibliotecas, testado

Cada biblioteca verificou os mesmos três tokens: um ES256, um token Ed25519 rotulado como EdDSA e o token da mesma chave rotulado como Ed25519. Cada chamada permitia apenas o algoritmo do próprio token.

BibliotecaES256EdDSAEd25519
jose 6.2.13 (Node.js)simsimsim
jsonwebtoken 9.0.3 (Node.js)simnão: Unknown key type "ed25519".não
PyJWT 2.15.1simsimnão: Algorithm not supported
joserfc 1.7.7 (Python)simsim, com o aviso EdDSA is deprecated via RFC 9864sim
golang-jwt v5.3.1simsimnão: signing method (alg) is unavailable
go-jose v4.1.6simsimnão: error in cryptographic primitive
jwx v3.3.0 (Go)simsimsim
Nimbus JOSE+JWT 10.10 (Java)simsim, exige a dependência opcional Tinksim
JJWT 0.13.0 (Java, JDK 25)simsimnão: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'.
Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10)simnão: IDX10511: Signature validation failed.não
firebase/php-jwt 7.2.1 (PHP 8.5, sodium)simsimnão: Algorithm not supported
ruby-jwt 3.3.0 com jwt-eddsa 1.0.0simsim; sem a extensão, Key type OKP not supportedsim
jsonwebtoken 11.1.0 (Rust, rust_crypto)simsimnão: InvalidAlgorithmName

O ES256 funcionou em 13 de 13, o EdDSA em 11 e o Ed25519 em 5. As duas lacunas que mais costumam decidir a questão estão nas plataformas mais usadas: o pacote npm jsonwebtoken não tem EdDSA nenhum, e o Microsoft.IdentityModel, usado pela autenticação JWT Bearer do ASP.NET Core, informa IsSupportedAlgorithm: False para uma JWK Ed25519. A falha no .NET é a mensagem genérica IDX10511, que leva as pessoas a procurar uma chave errada em vez de um algoritmo ausente. O Nimbus sem o Tink no classpath falha em tempo de execução com ClassNotFoundException: com.google.crypto.tink.subtle.Ed25519Verify.

Nos navegadores, o Ed25519 na Web Cryptography API chegou no Firefox 129, no Safari 17 e no Chrome 137 (notas de versão do Chrome 137, maio de 2025), então a assinatura no navegador, por exemplo de provas DPoP, já pode usar qualquer uma das duas curvas.

Suporte em KMS e HSM na nuvem

Se a chave de assinatura fica em um serviço gerenciado de chaves, é o serviço que decide por você. Conforme a documentação de cada provedor em outubro de 2026:

ServiçoP-256 (ES256)Ed25519
AWS KMSECC_NIST_P256, ECDSA_SHA_256ECC_NIST_EDWARDS25519, ED25519_SHA_512, desde novembro de 2025
Google Cloud KMSEC_SIGN_P256_SHA256 em todos os níveis de proteção, inclusive HSMEC_SIGN_ED25519, só no nível de proteção de software
Azure Key Vault e Managed HSMP-256 (EC e EC-HSM)não oferecido
AWS CloudHSMsimhsm2m.medium, só no modo não FIPS

Fontes: referência de especificações de chave do AWS KMS, API Sign do AWS KMS, algoritmos do Google Cloud KMS, chaves do Azure Key Vault, chaves do Managed HSM, versões do Client SDK do AWS CloudHSM.

Dois detalhes práticos:

  • Codificação. O AWS KMS e o Google Cloud KMS retornam assinaturas ECDSA codificadas em DER. Um JWS precisa da forma bruta r ‖ s de 64 bytes (RFC 7518 §3.4), então quem implementa a assinatura ES256 sobre o KMS precisa fazer a conversão. Esquecer a conversão é uma causa clássica de erros de assinatura inválida, como o ES256 signatures must be 64 bytes do jsonwebtoken; o artigo sobre formatos de assinatura ECDSA explica a conversão, e o conversor de DER para bruto a faz para uma assinatura avulsa.
  • Tamanho da mensagem. O Ed25519 no AWS KMS exige MessageType: RAW, e o Sign aceita no máximo 4096 bytes. A entrada de assinatura do JWS é o cabeçalho e o payload codificados, então um token com claims grandes não pode ser assinado com Ed25519 no KMS. O ECDSA não tem esse limite, porque dá para enviar um digest SHA-256 com MessageType: DIGEST.

O que exigem os padrões e os provedores

  • O OpenID Connect exige que os provedores ofereçam suporte a RS256 para tokens de ID, com uma exceção restrita (OIDC Core §15.1). Nenhuma das duas curvas substitui isso; os provedores que oferecem ES256 ou EdDSA fazem isso além do RS256.
  • O FAPI 2.0 Security Profile (versão final, fevereiro de 2025, §5.4.1) permite apenas PS256, ES256 e EdDSA com Ed25519 para JWTs, e diz que um identificador Ed25519 totalmente especificado seria permitido depois de registrado, o que a RFC 9864 já fez.
  • A revisão das boas práticas de JWT (draft-ietf-oauth-rfc8725bis, na fila do RFC Editor) orienta novas implantações a preferir os nomes totalmente especificados da RFC 9864.

Nenhuma das duas curvas é pós-quântica. O ML-DSA já tem identificadores JOSE (RFC 9964, maio de 2026), mas, para tokens que vivem minutos, a questão é quando os verificadores vão precisar rejeitar assinaturas clássicas, e não se os tokens de hoje poderão ser lidos mais tarde. Manter os algoritmos configuráveis por chave, como na próxima seção, é o que torna essa troca possível.

Como escolher e como trocar depois

SituaçãoEscolha
Tokens verificados por terceiros, por serviços .NET ou pelo pacote npm jsonwebtokenES256
Chave de assinatura no Azure Key Vault, no Managed HSM, no Google Cloud HSM ou em um HSM no modo FIPSES256
Você controla todos os verificadores, e a chave fica em software ou no AWS KMSEd25519
Uma API ou um perfil lista só certos algoritmos (FAPI 2.0, a especificação de um parceiro)o que estiver na lista; o ES256 é o que aparece com mais frequência

Se escolher Ed25519 hoje, emita "alg": "EdDSA". A descontinuação na RFC 9864 é um SHOULD que admite obstáculos operacionais documentados, e um verificador que rejeita o novo nome é um deles. Na verificação, aceite os dois nomes, e só com uma chave OKP cujo crv seja Ed25519, para que um nome descontinuado nunca selecione uma curva que você não pretendia. Com o jose:

Aceitar os dois nomes do Ed25519, para uma única chave
const { payload } = await jwtVerify(token, ed25519PublicKey, {
  algorithms: ["Ed25519", "EdDSA"],
  issuer: "https://id.example.com",
  audience: "https://api.example.com",
});

Rodado com os tokens deste artigo (jose 6.2.13), esse código aceitou tanto o token EdDSA quanto o Ed25519 e rejeitou o ES256 com "alg" (Algorithm) Header Parameter value not allowed. Trocar de algoritmo depois é uma rotação de chaves: publique a nova chave com seus próprios kid e alg ao lado da antiga, comece a assinar com ela e remova a chave antiga depois que o último token antigo expirar. Rotação de chaves JWKS sem indisponibilidade mostra o cronograma passo a passo, e erros na verificação de JWT explica como fixar o algoritmo por chave para que o token nunca o escolha.

Fontes