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

ES256 vs RS256 em JWTs: como escolher e como não cair em armadilhas

Duas letras no cabeçalho de um JWT decidem o tamanho do token, o modelo de gerenciamento de chaves e — se a verificação for configurada sem cuidado — se os tokens podem ou não ser forjados. Veja o que ES256 e RS256 significam de fato e como usá-los com segurança.

O que os nomes de alg significam de fato

Um JWT é um JWS (JSON Web Signature) com payload JSON: header.payload.signature, cada parte codificada em base64url. O cabeçalho alg indica o esquema de assinatura, e o registro desses valores fica na RFC 7518 (JSON Web Algorithms). Os quatro valores que aparecem na prática:

algEsquemaTipo de chaveTamanho da assinatura
HS256HMAC com SHA-256Um único segredo compartilhado (simétrico)32 bytes
RS256RSASSA-PKCS1-v1_5 com SHA-256Par de chaves RSA256 bytes (RSA-2048)
PS256RSASSA-PSS com SHA-256Par de chaves RSA256 bytes (RSA-2048)
ES256ECDSA na P-256 com SHA-256Par de chaves EC64 bytes (r‖s bruto)

Há dois pontos estruturais escondidos nesta tabela. Primeiro, o HS256 é fundamentalmente diferente: ele é simétrico, então toda parte que consegue verificar um token também consegue criar um. Ele só serve quando emissor e verificador são o mesmo serviço. RS256, PS256 e ES256 são assimétricos: os verificadores guardam apenas a chave pública, que pode ser entregue livremente, por exemplo por um endpoint JWKS.

Segundo, os nomes ES fixam a curva: ES256 significa P-256 com SHA-256, ES384 significa P-384 com SHA-384, e ES512 significa — atenção aos números — a curva de 521 bits P-521 com SHA-512. O PS256 usa o mesmo material de chave RSA que o RS256, com o padding PSS, mais recente; se seu ecossistema oferece suporte a ele, o PSS é o melhor modo RSA. (Para uma comparação mais aprofundada entre os algoritmos, além dos JWTs, veja ECDSA vs RSA.)

Tamanho do token: 64 bytes vs 256 bytes de assinatura

Uma assinatura ES256 tem 64 bytes brutos — cerca de 86 caracteres em base64url. Uma assinatura RS256 com a chave comum de 2048 bits tem 256 bytes — cerca de 342 caracteres — e 384 bytes com RSA-3072. Como o cabeçalho e o payload são os mesmos nos dois casos, trocar RS256 → ES256 costuma tirar ~250 bytes de cada token. Isso importa quando os tokens viajam em cookies (limite de 4 KB), em cabeçalhos Authorization em cada requisição, em códigos QR ou em tráfego móvel/IoT. A chave pública ES256 também é bem menor, o que mantém os documentos JWKS compactos.

A velocidade segue o padrão descrito no guia ECDSA vs RSA: o ES256 assina mais rápido; o RS256 verifica mais rápido. Para um emissor de tokens que assina em grande volume, o ES256 é o lado mais barato dessa troca.

A regra que evita a maioria dos desastres com JWT: fixe o algoritmo

O erro clássico de configuração de JWT é deixar o token decidir como ele será verificado. O cabeçalho alg é uma entrada controlada pelo atacante — ele chega dentro do próprio token que você ainda não verificou. Um verificador que lê o alg do cabeçalho e escolhe o caminho de verificação com base nele, com um mesmo objeto de chave servindo a várias famílias de algoritmos, quebra as próprias garantias. O caso de confusão mais conhecido: um serviço verifica tokens RS256 com uma chave pública, mas a biblioteca dele também aceita HS256 com o mesmo objeto de chave — tratando os bytes da chave pública RSA como um segredo HMAC. Como a chave pública é pública por definição, qualquer um consegue calcular uma tag HS256 correspondente e criar tokens que o serviço vai aceitar.

A correção é mecânica e está consolidada na RFC 8725 (JWT Best Current Practices):

  • Verifique com uma lista explícita de algoritmos permitidos — de preferência com um único valor —, configurada no lado do servidor e nunca derivada do token.
  • Vincule as chaves aos algoritmos. Em um JWKS, dê a cada chave seu alg e seu kid; uma chave publicada para ES256 nunca deve poder ser usada para outra coisa. Verificar o tipo de chave também ajuda: uma chave EC P-256 fisicamente não consegue verificar RS256, o que é mais um argumento a favor do ES256 em ambientes mistos.
  • Valide as claims explicitamente — emissor, público-alvo, expiração — na mesma chamada.
node — jose: lista de algoritmos permitidos fixada no lado da verificação
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://issuer.example/.well-known/jwks.json"));

const { payload } = await jwtVerify(token, jwks, {
  algorithms: ["ES256"],          // fixed by the verifier, not by the token
  issuer: "https://issuer.example",
  audience: "api://orders",
});
python — PyJWT: a mesma regra
import jwt

payload = jwt.decode(
    token,
    public_key_pem,
    algorithms=["ES256"],   # required parameter in modern PyJWT — for good reason
    audience="api://orders",
    issuer="https://issuer.example",
)

Por que “none” nunca é aceitável

A RFC 7518 também registra "alg": "none" — o “JWS não protegido”, um token com a parte da assinatura vazia. Ele existe para o caso raro em que a integridade é garantida por alguma camada externa. Um verificador que aceita none aceita tokens não assinados: qualquer um pode montar qualquer payload e apresentá-lo. As primeiras bibliotecas JWT (por volta de 2015) ficaram famosas por tratar none como mais um algoritmo, o que fazia tokens não assinados passarem na verificação por padrão. As bibliotecas modernas o rejeitam, a menos que ele seja habilitado explicitamente — e, com uma lista de algoritmos permitidos ["ES256"], ele nunca pode ser selecionado. Se você está auditando uma base de código, qualquer caminho que possa chegar a none ou a uma lista de algoritmos permitidos vazia é um achado.

A armadilha do ES256: assinaturas JWS são r‖s bruto, não DER

Matematicamente, uma assinatura ECDSA é um par de inteiros (r, s), e há duas codificações comuns em bytes para esse par. A RFC 7518 §3.4 é explícita: em um JWS, a assinatura ES256 é r e s como dois inteiros big-endian de 32 bytes, concatenados — exatamente 64 bytes, depois codificados em base64url. Ela não é a SEQUENCE ASN.1 DER que o OpenSSL, o Signature do Java e o crypto.sign() do Node produzem por padrão.

Essa é uma das causas mais comuns de “invalid signature” em código JWT feito à mão: o token carrega um blob DER de ~70 bytes (ou o verificador passa uma assinatura bruta de 64 bytes para uma API que espera DER), e a verificação falha mesmo com a chave, a mensagem e o algoritmo todos corretos. Se você assina com o crypto nativo do Node, peça diretamente o formato do JWS:

node — gerar uma assinatura ES256 de 64 bytes pronta para JWS
import { createPrivateKey, sign } from "node:crypto";

const key = createPrivateKey(pem); // P-256 private key
const signingInput = Buffer.from(`${headerB64}.${payloadB64}`);

const signature = sign("sha256", signingInput, {
  key,
  dsaEncoding: "ieee-p1363",   // raw r||s — what JWS requires (default is DER)
});
console.log(signature.length); // 64

Encontrou uma assinatura no formato errado por aí? O conversor DER ⇄ bruto converte nos dois sentidos e mostra os valores r e s; os detalhes no nível dos bytes, junto com a sutileza do high-S, estão em Formatos de assinatura ECDSA: DER vs bruto. E, para conferir um token real — cabeçalho, claims e uma verificação ES256 com sua chave pública —, cole-o no depurador de JWT.

Como escolher — e migrar sem quebrar nada

Migrar uma implantação RS256 existente para ES256 é um exercício de rotação de chaves, não uma virada geral em um único dia:

  • Publique a nova chave EC no seu JWKS ao lado da chave RSA, cada uma com os valores corretos de kid e alg, e dê tempo para os consumidores atualizarem o cache de JWKS.
  • Comece a emitir tokens ES256 com o novo kid. Verificadores que selecionam chaves pelo kid e impõem um algoritmo por chave continuam aceitando as duas gerações de tokens, cada uma com exatamente uma chave.
  • Quando todos os tokens RS256 tiverem expirado, remova a chave RSA do JWKS e reduza a lista de algoritmos permitidos do verificador de volta para ["ES256"].

A frase para guardar: quem escolhe o algoritmo é o verificador, não o token. Acerte isso e a decisão entre ES256 e RS256 vira o que deveria ser — um trade-off de engenharia modesto, em bytes e milissegundos.