Qué significan realmente los nombres de alg
Un JWT es un JWS (JSON Web Signature) con una carga útil JSON: header.payload.signature, con cada parte codificada en base64url. El encabezado alg indica el esquema de firma, y el registro de valores está en el RFC 7518 (JSON Web Algorithms). Los cuatro valores que encontrarás en la práctica:
| alg | Esquema | Tipo de clave | Tamaño de la firma |
|---|---|---|---|
HS256 | HMAC con SHA-256 | Un único secreto compartido (simétrico) | 32 bytes |
RS256 | RSASSA-PKCS1-v1_5 con SHA-256 | Par de claves RSA | 256 bytes (RSA-2048) |
PS256 | RSASSA-PSS con SHA-256 | Par de claves RSA | 256 bytes (RSA-2048) |
ES256 | ECDSA sobre P-256 con SHA-256 | Par de claves EC | 64 bytes (r‖s en bruto) |
En esta tabla se esconden dos aspectos estructurales. Primero, HS256 es fundamentalmente distinto: es simétrico, así que cualquier parte que pueda verificar un token también puede emitir uno. Solo encaja cuando el emisor y el verificador son el mismo servicio. RS256, PS256 y ES256 son asimétricos: los verificadores solo tienen la clave pública, que se les puede entregar libremente, por ejemplo mediante un endpoint JWKS.
Segundo, los nombres ES fijan la curva: ES256 significa P-256 con SHA-256, ES384 significa P-384 con SHA-384, y ES512 significa —ojo con los números— la curva de 521 bits P-521 con SHA-512. PS256 usa el mismo material de clave RSA que RS256, con el relleno PSS, más moderno; si tu ecosistema lo admite, PSS es el mejor modo de RSA. (Para una comparación más profunda de los algoritmos, más allá de los JWT, consulta ECDSA frente a RSA).
Tamaño del token: 64 bytes frente a 256 bytes de firma
Una firma ES256 son 64 bytes en bruto, unos 86 caracteres base64url. Una firma RS256 con la habitual clave de 2048 bits ocupa 256 bytes —unos 342 caracteres—, y 384 bytes con RSA-3072. Como el encabezado y la carga útil son los mismos en ambos casos, pasar de RS256 a ES256 suele recortar ~250 bytes de cada token. Eso importa cuando los tokens viajan en cookies (límite de 4 KB), en encabezados Authorization en cada solicitud, en códigos QR o en tráfico móvil o IoT. La clave pública ES256 también es mucho más pequeña, lo que mantiene compactos los documentos JWKS.
La velocidad sigue el patrón descrito en la guía ECDSA frente a RSA: ES256 firma más rápido; RS256 verifica más rápido. Para un emisor de tokens que firma grandes volúmenes, ES256 es la opción más económica.
La regla que evita la mayoría de los desastres con JWT: fija el algoritmo
El error clásico de configuración de JWT es dejar que el token decida cómo se verificará. El encabezado alg es una entrada que controla el atacante: llega dentro del mismo token que aún no has verificado. Un verificador que lee alg del encabezado y actúa según su valor, con un mismo objeto de clave para varias familias de algoritmos, rompe sus propias garantías. El caso de confusión más conocido: un servicio verifica tokens RS256 con una clave pública, pero su biblioteca también acepta HS256 con el mismo objeto de clave y trata los bytes de la clave pública RSA como un secreto HMAC. Como la clave pública es pública por diseño, cualquiera puede calcular una etiqueta HS256 válida y fabricar tokens que el servicio aceptará.
La solución es mecánica y está recogida en el RFC 8725 (JWT Best Current Practices):
- Verifica con una lista explícita de algoritmos permitidos —idealmente, un único valor— configurada en el lado del servidor y nunca derivada del token.
- Vincula las claves a los algoritmos. En un JWKS, asigna a cada clave su
algy sukid; una clave publicada para ES256 nunca debe poder usarse para nada más. Las comprobaciones del tipo de clave también ayudan: una clave EC P-256 no puede verificar RS256 por su propia naturaleza, lo que es un argumento más a favor de ES256 en entornos mixtos. - Valida los claims explícitamente —emisor, audiencia, caducidad— en la misma llamada.
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",
});
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 qué «none» nunca es aceptable
El RFC 7518 también registra "alg": "none", el «JWS no protegido» (unsecured JWS): un token con la parte de la firma vacía. Existe para el caso poco frecuente en que alguna capa externa garantiza la integridad. Un verificador que acepta none acepta tokens sin firmar: cualquiera puede construir cualquier carga útil y presentarla. Es célebre el caso de las primeras bibliotecas de JWT (hacia 2015), que trataban none como un algoritmo más, con lo que los tokens sin firmar superaban la verificación por defecto. Las bibliotecas modernas lo rechazan salvo que se habilite explícitamente, y con una lista de algoritmos permitidos ["ES256"] nunca puede seleccionarse. Si auditas una base de código, cualquier ruta de código que pueda llegar a none o a una lista de permitidos vacía es un hallazgo.
La trampa de ES256: las firmas JWS son r‖s en bruto, no DER
Matemáticamente, una firma ECDSA es un par de enteros (r, s), y hay dos codificaciones habituales de ese par en bytes. El RFC 7518 §3.4 es explícito: en un JWS, la firma ES256 es r y s como dos enteros big-endian de 32 bytes, concatenados: exactamente 64 bytes, codificados después en base64url. No es la SEQUENCE ASN.1 DER que generan por defecto OpenSSL, Signature de Java y crypto.sign() de Node.
Es una de las causas más frecuentes de «invalid signature» en código JWT escrito a mano: el token lleva un bloque DER de ~70 bytes (o el verificador pasa una firma en bruto de 64 bytes a una API que espera DER), y la verificación falla aunque la clave, el mensaje y el algoritmo sean correctos. Si firmas con el módulo crypto integrado de Node, pide directamente el formato 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
¿Te encuentras en la práctica con una firma con la forma equivocada? El conversor DER ⇄ en bruto convierte en ambos sentidos y muestra los valores r y s; los detalles a nivel de bytes, junto con el matiz de high-S, se explican en Formatos de firma ECDSA: DER frente a raw. Y para comprobar un token real —encabezado, claims y verificación ES256 con tu clave pública—, pégalo en el decodificador de JWT.
Cómo elegir y migrar sin romper nada
Migrar un despliegue RS256 existente a ES256 es un ejercicio de rotación de claves, no un cambio de un día para otro:
- Publica la nueva clave EC en tu JWKS junto a la clave RSA, cada una con los valores correctos de
kidyalg, y da tiempo a los consumidores para que actualicen sus cachés de JWKS. - Empieza a emitir tokens ES256 con el nuevo
kid. Los verificadores que seleccionan las claves porkide imponen un algoritmo por clave siguen aceptando las dos generaciones de tokens, cada una con exactamente una clave. - Cuando hayan caducado todos los tokens RS256, retira la clave RSA del JWKS y vuelve a reducir la lista de permitidos del verificador a
["ES256"].
La frase que hay que recordar: el algoritmo lo elige el verificador, no el token. Si aciertas en eso, la decisión entre ES256 y RS256 se convierte en lo que debería ser: un compromiso de ingeniería menor sobre bytes y milisegundos.