Verificar un JWT no es una comprobación, sino seis, y la firma es solo una de ellas. En resumen: es el verificador, no el token, quien decide cómo se comprueba el token. Fija el algoritmo en el servidor, busca la clave entre las claves en las que ya confías para ese emisor, exige exp, compara iss y aud de forma exacta, comprueba typ para que un tipo de token no pueda hacerse pasar por otro, admite como máximo un minuto de desfase de reloj y guarda el JWKS en caché con una actualización con límite de frecuencia para los valores de kid desconocidos. Todo lo que sigue muestra lo que falla cuando falta una de estas medidas, comprobado con bibliotecas reales.
Toda la salida de esta página procede de tokens emitidos para el artículo con una clave ES256 recién generada y verificados con jose 6.2.13 y jsonwebtoken 9.0.3 (con jwks-rsa 4.1.0) en Node.js 24.22, PyJWT 2.15.1 en Python 3.14.8 y golang-jwt 5.3.1 con keyfunc 3.8.2 en Go 1.27.3, cada uno en su imagen oficial de Docker.
Qué comprueba una biblioteca cuando solo le pasas la clave
La mayoría de los fallos con JWT no son fallos de la biblioteca. Son llamadas que verifican la firma y se quedan ahí, porque quien llama nunca le indicó a la biblioteca qué emisor, qué audiencia o qué algoritmo esperar. Esto es lo que hizo cada biblioteca con tres tokens al llamarla con una clave pública válida y sin más opciones (PyJWT se niega a ejecutarse sin algorithms, así que se le pasó ["ES256"]):
| Token | jose jwtVerify | jsonwebtoken verify | PyJWT decode | golang-jwt Parse |
|---|---|---|---|---|
Firmado con la clave correcta, pero con iss y aud de otro servicio | aceptado | aceptado | rechazado: Invalid audience | aceptado |
Firmado con la clave correcta, sin el claim exp | aceptado | aceptado | aceptado | aceptado |
alg: "none", firma vacía | rechazado | rechazado | rechazado | rechazado |
Dos lecciones. Primera: las cuatro bibliotecas rechazan por defecto los tokens sin firmar, así que el famoso problema de none vive hoy sobre todo en verificadores escritos a mano. Segunda: un token sin exp es válido para siempre en las cuatro, y tres de las cuatro aceptan un token emitido para otro servicio siempre que lo haya firmado la misma clave. exp, nbf, iss y aud son claims (declaraciones) opcionales según el RFC 7519 §4.1; las bibliotecas los comprueban cuando están presentes y cuando se han configurado, y el resto depende de ti.
Error 1: dejar que el token elija el algoritmo
El encabezado alg llega dentro del token que todavía no has verificado. Un verificador que lo lee y decide en función de él deja que el remitente elija las reglas. El RFC 8725 §3.1, las buenas prácticas actuales para JWT (JWT Best Current Practices), es tajante: las bibliotecas deben permitir que quien llama especifique los algoritmos admitidos y no deben usar ningún otro, y cada clave debe usarse con un único algoritmo.
El fallo clásico es la confusión HS/RS (o HS/ES), descrita en el RFC 8725 §2.1 y registrada para jsonwebtoken como CVE-2015-9235. Un servicio tiene una única clave pública y verifica «lo que diga el encabezado». Alguien calcula un HMAC sobre un encabezado con "alg": "HS256" usando como secreto el texto PEM de la clave pública. La clave pública es pública, así que cualquiera puede hacerlo. Aquí tienes un verificador escrito a mano con exactamente esa forma:
import { createHmac, timingSafeEqual, verify } from "node:crypto";
import { readFileSync } from "node:fs";
// Anti-pattern: the token picks the algorithm, one key object serves both families.
function naiveVerify(token, publicKeyPem) {
const [h, p, s] = token.split(".");
const { alg } = JSON.parse(Buffer.from(h, "base64url"));
const input = Buffer.from(`${h}.${p}`);
const sig = Buffer.from(s, "base64url");
if (alg === "HS256") {
const mac = createHmac("sha256", publicKeyPem).update(input).digest();
return mac.length === sig.length && timingSafeEqual(mac, sig);
}
if (alg === "ES256") {
return verify("sha256", input, { key: publicKeyPem, dsaEncoding: "ieee-p1363" }, sig);
}
throw new Error(`unsupported alg ${alg}`);
}
const pem = readFileSync("out/public.pem", "utf8");
const tokens = JSON.parse(readFileSync("out/tokens.json", "utf8"));
console.log("ES256 token from the issuer:", naiveVerify(tokens.ok, pem));
console.log("HS256 token keyed with the public PEM:", naiveVerify(tokens["HS256 keyed with the public PEM"], pem));
ES256 token from the issuer: true
HS256 token keyed with the public PEM: true
Ambas llamadas devuelven true. El segundo token se creó sin la clave privada. Fíjate en que la comparación de tiempo constante no ayuda: el código cuida los tiempos y aun así se equivoca sobre en qué confiar.
La solución es una lista de permitidos fija en el lado del verificador, idealmente con un solo valor, y claves que lleven asociado su algoritmo. Las cuatro bibliotecas rechazaron el token falsificado cuando recibieron algorithms: ["ES256"], cada una con su propio mensaje: jose "alg" (Algorithm) Header Parameter value not allowed, jsonwebtoken invalid algorithm, PyJWT The specified alg value is not allowed, golang-jwt signing method HS256 is invalid. Además, tienen segundas líneas de defensa, y por eso hoy la confusión es rara en el código de las bibliotecas:
- jsonwebtoken 9.x (desde la 9.0.0, de diciembre de 2022) deduce los algoritmos permitidos a partir del tipo de clave cuando omites
algorithms: una clave EC solo permiteES256,ES384yES512. - PyJWT se niega a usar una clave pública PEM o un certificado como secreto HMAC: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
- golang-jwt comprueba el tipo Go de la clave: el método HMAC espera
[]byte, así que un*ecdsa.PublicKeyfalla con key is of invalid type. - jose comprueba que el tipo de clave corresponda al algoritmo. Incluso sin la opción
algorithms, el token falsificado, verificado con la clave pública EC, falló conKeyObject instances must be of type "secret" for the HS256 algorithm.
Ninguna de estas defensas sustituye a la lista de permitidos. Protegen el caso habitual, no un cargador de claves propio que le pasa bytes en bruto a la biblioteca.
Error 2: aceptar «none» en código escrito a mano
El RFC 7518 §3.6 registra "alg": "none" para tokens cuya integridad protege otro mecanismo. En la tabla anterior, todas las bibliotecas lo rechazaron por defecto. El riesgo está en el código que rodea a la biblioteca: un «modo de depuración» que se salta la verificación, una rama que trata una firma vacía como «todavía sin firmar» o una lista de bloqueo que compara alg distinguiendo entre mayúsculas y minúsculas. La revisión del BCP, draft-ietf-oauth-rfc8725bis (en la cola del RFC Editor a fecha de octubre de 2026), señala justo eso: una lista de bloqueo para none que un encabezado con noNE consigue esquivar. Una lista de permitidos no tiene ese agujero.
Error 3: confiar en kid, jku, x5u o en un jwk incrustado
kid existe para elegir una entre varias claves en las que ya confías. No es un puntero a una clave que aporte el token. El RFC 8725 §3.10 indica que los encabezados recibidos deben tratarse como entrada no confiable:
- Busca
kiden un mapa, no en una consulta ni en una ruta. Unkidque llega a SQL, a LDAP o a un nombre de archivo es un punto de inyección. Las bibliotecas de JWKS de este artículo solo lo comparan con los valores dekidde las claves que descargaron de la URL que tienes configurada, y esa es la forma correcta. - Ignora
jkuyx5u. Ambos son URL que viajan dentro del token. Descargarlas permite que el remitente elija a dónde se conecta tu servidor y qué claves usa. En su lugar, configura la URL del JWKS para cada emisor; si no te queda más remedio que usarlas, el RFC 8725 sugiere compararlas con una lista de permitidos. - Nunca verifiques un token de acceso con un
jwko unx5cincrustado en su propio encabezado. Un token firmado con una clave que lleva consigo solo demuestra que el remitente tiene alguna clave. Los protocolos que sí usan una clave incrustada, como las pruebas DPoP, la vinculan a otra cosa (el claimcnfdel token de acceso). Consulta cómo funciona DPoP. - Trata de forma explícita la ausencia de
kid. jwks-rsa se niega a adivinar cuando el conjunto tiene varias claves y el token no nombra ninguna (No KID specified and JWKS endpoint returned more than 1 key); una búsqueda escrita a mano que devuelve «la primera clave» elegirá en silencio la equivocada después de una rotación.
Error 4: no comprobar aud ni iss, o comprobarlos sin rigor
Un proveedor de identidad suele firmar con la misma clave los tokens de muchos clientes y API. Si tu API no comprueba aud, un token emitido para cualquier otra aplicación del mismo proveedor funciona en la tuya: es la primera fila de la tabla. El RFC 8725 §3.9 exige que el destinatario rechace un JWT cuya audiencia falte o no sea la suya, y el RFC 9068 §4 exige una coincidencia exacta de iss para los tokens de acceso de OAuth.
En la práctica importan tres detalles:
- Comparación exacta de cadenas. Tanto
audcomoissson cadenas que distinguen entre mayúsculas y minúsculas, yhttps://id.example.comno es lo mismo quehttps://id.example.com/. Copia el emisor del documento de descubrimiento del proveedor, no de un token que tengas a mano. audpuede ser un array. Las bibliotecas aceptan el token si coincide cualquiera de sus elementos. Es correcto según el RFC 7519, pero significa que un token para["https://api.example.com", "https://admin.example.com"]funciona en ambas API. Si tu API solo debe aceptar tokens emitidos exclusivamente para ella, PyJWT tieneoptions={"strict_aud": True}; en las demás, comprueba por tu cuenta la longitud del array.- Las claves deben pertenecer al emisor. RFC 8725 §3.8: si un token tiene
iss, la clave que lo verificó debe ser una clave de ese emisor. Con varios emisores, mantén un cliente de JWKS por emisor y elígelo a partir de la configuración, nunca solo por elissdel token, que aún no está verificado. En los proveedores multiinquilino que comparten un mismo conjunto de claves entre inquilinos (Microsoft Entra ID es el caso habitual), la comprobación deisses lo único que separa tu inquilino de todos los demás; la validación del token de ID de OIDC lo explica con detalle.
Error 5: tokens sin exp y márgenes de tolerancia generosos
Una biblioteca comprueba exp cuando está presente. Cuando falta, las cuatro aceptaron el token. Si tu emisor olvida alguna vez el claim, o alguien encuentra una ruta de código que emite tokens sin él, esos tokens nunca caducan. Haz que exp sea obligatorio: requiredClaims en jose, options={"require": [...]} en PyJWT, jwt.WithExpirationRequired() en golang-jwt y una comprobación explícita en jsonwebtoken, que no tiene ninguna opción equivalente.
El desfase de reloj existe, pero es pequeño. El RFC 7519 §4.1.4 permite «algún pequeño margen, normalmente no más de unos pocos minutos». De 30 a 60 segundos bastan para servidores sincronizados por NTP. Un margen de una hora, que a menudo se añade para que pase una prueba inestable, alarga en una hora la vida de cada token, incluidos los tokens que querías dejar de aceptar. La opción es clockTolerance en jose y jsonwebtoken (en segundos), leeway en PyJWT y jwt.WithLeeway en golang-jwt. El mismo margen se aplica a nbf.
Cuando una verificación falla con un error de caducidad o de «aún no válido» (not-before) en un token que «debería» ser válido, compara el reloj del servidor con el del emisor antes de tocar el margen. Un contenedor o una máquina virtual que se reanuda tras una suspensión con el reloj desfasado produce exactamente los errores jwt not active y "nbf" claim timestamp check failed que aparecen en la salida de más abajo.
Error 6: aceptar un tipo de token equivocado
Un proveedor de OpenID Connect firma los tokens de ID y los tokens de acceso con la misma clave, a menudo con el mismo iss y a veces con valores de aud que se solapan. Una API que solo comprueba la firma, el emisor y la audiencia puede aceptar un token de ID como token de acceso. El RFC 8725 §3.11 recomienda el tipado explícito, y el RFC 9068 lo hace obligatorio para los tokens de acceso JWT: el encabezado typ debe ser at+jwt (o application/at+jwt), y los servidores de recursos deben rechazar cualquier otro valor. Si tu proveedor emite tokens RFC 9068, comprueba typ; si no, comprueba un claim que solo lleven los tokens de acceso, como scope o client_id.
Error 7: llamar a decode cuando querías verify
Todas las bibliotecas tienen una función que analiza un token sin verificarlo: jwt.decode() en jsonwebtoken, decodeJwt() en jose, jwt.decode(..., options={"verify_signature": False}) en PyJWT y ParseUnverified en golang-jwt. Existen para el enrutamiento (leer iss para elegir un cliente de JWKS) y para la depuración. En una revisión de código, cualquier uso de ellas en el código que procesa solicitudes debe considerarse sospechoso, y cualquier decisión basada en su salida, salvo la de elegir a qué verificador llamar a continuación, un fallo.
Error 8: descargar el JWKS en cada solicitud, o nunca
Descargar el conjunto de claves en cada solicitud añade un viaje de ida y vuelta por la red a cada llamada y convierte la disponibilidad del proveedor de identidad en la tuya. Guardarlo en caché para siempre significa que la primera rotación de claves rompe todas las solicitudes hasta que se reinicie el servicio. El comportamiento correcto, que implementan los clientes de JWKS de la tabla siguiente, es una caché con caducidad por tiempo más una actualización cuando un token nombra un kid desconocido, con un límite de frecuencia en esa actualización para que un flujo de tokens con valores de kid aleatorios no pueda convertir tu API en un generador de carga contra el proveedor.
| Cliente | Vida de la caché (por defecto) | Ante un kid desconocido | Tiempo de espera de la descarga |
|---|---|---|---|
jose createRemoteJWKSet | 10 minutos (cacheMaxAge) | nueva descarga, como máximo una vez cada 30 s (cooldownDuration) | 5 s |
| jwks-rsa 4.1.0 | 10 minutos por clave, 5 claves | nueva descarga en cada fallo de caché; rateLimit está desactivado salvo que lo actives (entonces, 10 por minuto) | 30 s |
PyJWT PyJWKClient | 300 s (lifespan) | nueva descarga, como máximo una vez cada 30 s (cooldown_duration) | 30 s |
keyfunc v3 NewDefault | actualización en segundo plano cada hora | nueva descarga, como máximo una vez cada 5 minutos | 1 minuto |
Los valores son los predeterminados de las versiones indicadas al principio, tomados de su código fuente. La diferencia se puede medir: 20 tokens con valores de kid aleatorios provocaron 20 descargas del conjunto de claves con la configuración predeterminada de jwks-rsa, 10 con rateLimit: true (los otros diez fallaron de inmediato con JwksRateLimitError) y una sola descarga con jose. Crea el cliente una vez por emisor y reutilízalo: un cliente de JWKS construido dentro del manejador de la solicitud tiene la caché vacía cada vez. La rotación en sí, desde el lado del emisor, se trata en Rotación de claves JWKS sin interrupciones.
Un verificador que lo hace bien, en cuatro bibliotecas
Los cuatro verificadores siguientes hacen lo mismo: solo ES256, claves desde la URL del JWKS del emisor, iss y aud exactos, exp obligatorio, typ: at+jwt y 30 segundos de margen. Cada uno se ejecutó contra los mismos ocho tokens.
import { createRemoteJWKSet, jwtVerify } from "jose";
// Created once per issuer and reused: caches the key set, re-fetches it
// when a token names an unknown kid (at most once per cooldown).
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URL), {
cacheMaxAge: 600_000, // re-fetch at least every 10 minutes
cooldownDuration: 30_000, // at most one kid-miss re-fetch per 30 s
});
export async function verifyAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["ES256"], // fixed by the verifier, never taken from the token
issuer: "https://id.example.com",
audience: "https://api.example.com",
typ: "at+jwt", // RFC 9068 access token, not an ID token
requiredClaims: ["exp", "sub"], // exp is only checked when present
clockTolerance: 30, // seconds
});
return payload;
}
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
const client = jwksClient({
jwksUri: process.env.JWKS_URL,
cache: true, // per-kid cache, 10 minutes by default
rateLimit: true, // off by default; caps JWKS fetches at 10 per minute
});
const getKey = (header, callback) =>
client.getSigningKey(header.kid, (err, key) => callback(err, key?.getPublicKey()));
export function verifyAccessToken(token) {
const options = {
algorithms: ["ES256"],
issuer: "https://id.example.com",
audience: "https://api.example.com",
clockTolerance: 30,
complete: true, // also return the header, to check typ
};
return new Promise((resolve, reject) =>
jwt.verify(token, getKey, options, (err, decoded) => {
if (err) return reject(err);
if (decoded.header.typ !== "at+jwt") return reject(new Error("unexpected typ"));
if (typeof decoded.payload.exp !== "number") return reject(new Error("exp is required"));
resolve(decoded.payload);
}),
);
}
import os
import jwt
# One client per issuer, reused across requests: caches the key set and
# re-fetches on an unknown kid (at most once per cooldown).
jwks = jwt.PyJWKClient(os.environ["JWKS_URL"], lifespan=600)
def verify_access_token(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token)
decoded = jwt.decode_complete(
token,
signing_key,
algorithms=["ES256"], # required argument in PyJWT 2.x
issuer="https://id.example.com",
audience="https://api.example.com",
leeway=30,
options={"require": ["exp", "iss", "aud", "sub"]},
)
if decoded["header"].get("typ") != "at+jwt":
raise jwt.InvalidTokenError("unexpected typ")
return decoded["payload"]
package main
import (
"context"
"errors"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type Verifier struct {
jwks keyfunc.Keyfunc
parser *jwt.Parser
}
func NewVerifier(ctx context.Context, jwksURL string) (*Verifier, error) {
// Background refresh every hour; an unknown kid triggers at most
// one extra fetch per 5 minutes (keyfunc defaults).
jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return nil, err
}
parser := jwt.NewParser(
jwt.WithValidMethods([]string{"ES256"}),
jwt.WithIssuer("https://id.example.com"),
jwt.WithAudience("https://api.example.com"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second),
)
return &Verifier{jwks: jwks, parser: parser}, nil
}
func (v *Verifier) VerifyAccessToken(raw string) (jwt.MapClaims, error) {
claims := jwt.MapClaims{}
token, err := v.parser.ParseWithClaims(raw, claims, v.jwks.Keyfunc)
if err != nil {
return nil, err
}
if token.Header["typ"] != "at+jwt" {
return nil, errors.New("unexpected typ")
}
return claims, nil
}
Los tokens de prueba: uno válido y siete que incumplen exactamente una regla cada uno. El JWKS se sirvió por HTTP desde dentro del contenedor, y cada biblioteca lo descargó mediante su cliente de JWKS.
--- jose
ok ACCEPTED sub=user-42
aud for another API ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "aud" claim value
no exp ERR_JWT_CLAIM_VALIDATION_FAILED: missing required "exp" claim
expired 1 h ago ERR_JWT_EXPIRED: "exp" claim timestamp check failed
nbf in 1 h ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed
typ JWT (an ID token) ERR_JWT_CLAIM_VALIDATION_FAILED: unexpected "typ" JWT header value
unknown kid ERR_JWKS_NO_MATCHING_KEY: no applicable key found in the JSON Web Key Set
HS256 keyed with the public PEM ERR_JOSE_ALG_NOT_ALLOWED: "alg" (Algorithm) Header Parameter value not allowed
--- jsonwebtoken
ok ACCEPTED sub=user-42
aud for another API JsonWebTokenError: jwt audience invalid. expected: https://api.example.com
no exp Error: exp is required
expired 1 h ago TokenExpiredError: jwt expired
nbf in 1 h NotBeforeError: jwt not active
typ JWT (an ID token) Error: unexpected typ
unknown kid JsonWebTokenError: error in secret or public key callback: Unable to find a signing key that matches '2026-09-z'
HS256 keyed with the public PEM JsonWebTokenError: invalid algorithm
--- PyJWT
ok ACCEPTED sub=user-42
aud for another API InvalidAudienceError: Audience doesn't match
no exp MissingRequiredClaimError: Token is missing the "exp" claim
expired 1 h ago ExpiredSignatureError: Signature has expired
nbf in 1 h ImmatureSignatureError: The token is not yet valid (nbf)
typ JWT (an ID token) InvalidTokenError: unexpected typ
unknown kid PyJWKClientError: Unable to find a signing key that matches: "2026-09-z"
HS256 keyed with the public PEM InvalidAlgorithmError: The specified alg value is not allowed
--- golang-jwt v5 + keyfunc v3
ok ACCEPTED sub=user-42
aud for another API token has invalid claims: token has invalid audience
no exp token has invalid claims: token is missing required claim: exp claim is required
expired 1 h ago token has invalid claims: token is expired
nbf in 1 h token has invalid claims: token is not valid yet
typ JWT (an ID token) unexpected typ
unknown kid token is unverifiable: error while executing keyfunc: key not found "2026-09-z"
failed keyfunc: could not read JWK from storage
HS256 keyed with the public PEM token signature is invalid: signing method HS256 is invalid
La mayoría de los mensajes de las bibliotecas que aparecen arriba tienen su propia página en el catálogo de errores, con causas y soluciones; por ejemplo, unexpected "aud" claim value, jwt not active y no applicable key found in the JSON Web Key Set.
Lista de comprobación
- Una configuración de verificador por emisor, creada al arrancar: URL del JWKS, emisor, audiencia y lista de algoritmos.
- Algoritmos: una lista de permitidos fija, normalmente con un solo valor. Nunca derivada del token y nunca con
none. - Claves: solo del JWKS o del archivo de claves configurado;
kidusado como clave de búsqueda en un mapa;jku,x5uy eljwkincrustado, ignorados en los tokens de acceso. - Claims:
expobligatorio;issyaudcomparados de forma exacta;nbfrespetado; margen de 30 a 60 segundos. - Tipo de token:
typcomprobado (at+jwten los tokens de acceso RFC 9068) u otro claim que distinga los tokens de acceso de los tokens de ID. - Caché de JWKS: un único cliente, reutilizado; actualización por tiempo; actualización ante un
kiddesconocido, con límite de frecuencia. - Ningún
decodesin verificación en el código que procesa solicitudes. - Pruebas: un token válido y, por cada regla anterior, un token que debe fallar. Los ocho tokens de este artículo son un conjunto inicial.
Fuentes
- RFC 7519: JSON Web Token, §4.1 (claims registrados), §7.2 (validación)
- RFC 7518: JSON Web Algorithms, §3.6 (
none) - RFC 8725: JSON Web Token Best Current Practices, §2.1, §3.1, §3.8 a §3.11
- draft-ietf-oauth-rfc8725bis, la actualización del RFC 8725
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens, §2.1 y §4
- Documentación de jose:
jwtVerify,createRemoteJWKSet - Archivos README de jsonwebtoken y jwks-rsa
- Documentación de PyJWT: uso,
PyJWKClient - golang-jwt/jwt v5 y keyfunc v3