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
| ES256 | EdDSA / Ed25519 | |
|---|---|---|
| Esquema | ECDSA na curva P-256 com SHA-256 | EdDSA na curva Edwards25519 (RFC 8032 §5.1) |
| Definição no JOSE | RFC 7518 §3.4 | RFC 8037, com o nome alterado pela RFC 9864 |
| Status no registro JOSE da IANA | Recomendado+ | EdDSA: descontinuado; Ed25519: opcional |
| JWK | "kty": "EC", crv, x, y | "kty": "OKP", "crv": "Ed25519", x |
| Assinatura | r ‖ s, 64 bytes | R ‖ S, 64 bytes |
| Aprovado no FIPS 186-5 | sim | sim |
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:
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:
// 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");
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ção | Assinar ES256 | Verificar ES256 | Assinar Ed25519 | Verificar Ed25519 |
|---|---|---|---|---|
| Node.js 24.22, OpenSSL 3.5.8 | 64.840/s | 23.163/s | 60.249/s | 23.398/s |
| Go 1.27.3, biblioteca padrão | 53.877/s | 25.778/s | 75.218/s | 35.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:
// 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.
| Biblioteca | ES256 | EdDSA | Ed25519 |
|---|---|---|---|
| jose 6.2.13 (Node.js) | sim | sim | sim |
| jsonwebtoken 9.0.3 (Node.js) | sim | não: Unknown key type "ed25519". | não |
| PyJWT 2.15.1 | sim | sim | não: Algorithm not supported |
| joserfc 1.7.7 (Python) | sim | sim, com o aviso EdDSA is deprecated via RFC 9864 | sim |
| golang-jwt v5.3.1 | sim | sim | não: signing method (alg) is unavailable |
| go-jose v4.1.6 | sim | sim | não: error in cryptographic primitive |
| jwx v3.3.0 (Go) | sim | sim | sim |
| Nimbus JOSE+JWT 10.10 (Java) | sim | sim, exige a dependência opcional Tink | sim |
| JJWT 0.13.0 (Java, JDK 25) | sim | sim | não: Unsupported JWS header 'alg' (Algorithm) value 'Ed25519'. |
| Microsoft.IdentityModel.JsonWebTokens 8.23.0 (.NET 10) | sim | não: IDX10511: Signature validation failed. | não |
| firebase/php-jwt 7.2.1 (PHP 8.5, sodium) | sim | sim | não: Algorithm not supported |
| ruby-jwt 3.3.0 com jwt-eddsa 1.0.0 | sim | sim; sem a extensão, Key type OKP not supported | sim |
jsonwebtoken 11.1.0 (Rust, rust_crypto) | sim | sim | nã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ço | P-256 (ES256) | Ed25519 |
|---|---|---|
| AWS KMS | ECC_NIST_P256, ECDSA_SHA_256 | ECC_NIST_EDWARDS25519, ED25519_SHA_512, desde novembro de 2025 |
| Google Cloud KMS | EC_SIGN_P256_SHA256 em todos os níveis de proteção, inclusive HSM | EC_SIGN_ED25519, só no nível de proteção de software |
| Azure Key Vault e Managed HSM | P-256 (EC e EC-HSM) | não oferecido |
| AWS CloudHSM | sim | hsm2m.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 ‖ sde 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 oSignaceita 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 comMessageType: 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,ES256eEdDSAcom 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ção | Escolha |
|---|---|
Tokens verificados por terceiros, por serviços .NET ou pelo pacote npm jsonwebtoken | ES256 |
| Chave de assinatura no Azure Key Vault, no Managed HSM, no Google Cloud HSM ou em um HSM no modo FIPS | ES256 |
| Você controla todos os verificadores, e a chave fica em software ou no AWS KMS | Ed25519 |
| 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:
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
- RFC 7518 §3.4: ECDSA no JWS
- RFC 8032: EdDSA, §5.1.6 (assinatura) e §5.1.7 (verificação,
S < L) - RFC 8037: EdDSA e chaves OKP no JOSE
- RFC 9864: algoritmos totalmente especificados para JOSE e COSE
- Registro de algoritmos JOSE da IANA e registro de algoritmos COSE
- RFC 6979: ECDSA determinístico
- FIPS 186-5: Digital Signature Standard, incluindo o EdDSA
- FAPI 2.0 Security Profile, §5.4.1
- OpenID Connect Core 1.0, §15.1
- Documentação dos provedores, com links na seção sobre KMS