Seguridad e identidad

Proteger APIs REST con JWT: verificación, claims y revocación

Avanzado9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Un JSON Web Token vale lo que vale la verificación que se le aplica, y las vulnerabilidades de JWT más conocidas son todas fallos de esa verificación, no del formato. Esta guía cubre la estructura JWS y JWE, la lista blanca de algoritmos y la rotación de claves con JWKS, los claims que hay que comprobar sí o sí, el problema de la revocación en diseños stateless, dónde debe guardar un navegador el token y los controles que una API bien protegida sigue necesitando en su perímetro.

Lo que aprenderás

  • Explicar la estructura JWS y JWE, y qué demuestra y qué no demuestra una firma
  • Verificar un token con un algoritmo en lista blanca y una clave elegida del JWKS por su kid
  • Validar los claims iss, aud, exp, nbf y scope, y rechazar todo lo que falte
  • Comparar tokens stateless con tokens opacos y diseñar una estrategia de revocación viable
  • Decidir dónde debe guardar un token un cliente de navegador y cuánto tiempo debe durar
  • Combinar rate limiting, validación de entradas y autorización a nivel de recurso en el perímetro de la API

La seguridad de APIs basada en tokens tiene un modo de fallo muy cómodo: todo funciona. El parser rechaza un token malformado, uno válido deja pasar la petición y la integración se da por terminada. Lo que eso no pone a prueba es justo el caso para el que existe el diseño: un atacante que presenta un token fabricado por él mismo. Casi todas las vulnerabilidades de JWT publicadas son atajos en la verificación que solo salen a la luz con esa prueba: un algoritmo sin fijar, una clave elegida a partir de la propia cabecera del token, una audiencia que nunca se compara, una caducidad que se lee pero no se aplica.

Esta guía trata de hacer bien la parte de la verificación y de las decisiones de diseño que se derivan de elegir una credencial stateless: cómo revocar, cuánto debe durar un token, dónde debe guardarlo un navegador y de qué más tiene que ocuparse el perímetro de la API. Cómo se obtienen los tokens se explica en OAuth 2.0, y el ID token, específico de identidad, en OpenID Connect; aquí el token ya ha llegado en una cabecera Authorization y la pregunta es qué hacer con él.

De qué se compone un JWT

Un JWT es un conjunto de claims en JSON, serializado de forma compacta. En su forma JWS, la habitual, tiene tres segmentos base64url separados por puntos: cabecera, payload y firma.

eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6InNpZy0yMDI2LTAzIn0
.eyJpc3MiOiJodHRwczovL3Nzby5leGFtcGxlLmNvbSIsInN1YiI6InUtODgyMzEiLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSJ9
.QVhNc2lnbmF0dXJlYnl0ZXNnb2hlcmU
{ "alg": "RS256", "typ": "at+jwt", "kid": "sig-2026-03" }
{
  "iss": "https://sso.example.com",
  "sub": "u-88231",
  "aud": "https://api.example.com",
  "exp": 1789012645,
  "nbf": 1789012340,
  "iat": 1789012345,
  "jti": "3f7c1e02-77aa-4f9c-9d31-2b6c0a5e77b1",
  "scope": "orders.read orders.write",
  "client_id": "web-portal"
}

Base64url no es una medida de seguridad: un decodificador JWT que se ejecuta en el navegador, o incluso un simple decodificador base64, lee el payload al instante. Da por hecho que el cliente, y cualquiera que consiga el token, puede leer todos los claims, y no metas nunca en él identificadores internos, datos personales ni pistas de autorización que no mostrarías en pantalla. typ: at+jwt marca explícitamente el token como access token de OAuth, lo que sirve de defensa frente a que un token pensado para un propósito se acepte para otro.

JWE es la alternativa cifrada: cinco segmentos, con el payload cifrado para la clave del destinatario. Exige gestión de claves en el lado receptor y solo compensa cuando la confidencialidad de los claims importa de verdad; un término medio habitual es un access token opaco y pequeño en el perímetro y un JWT firmado dentro de la frontera de confianza.

Verificar la firma

La firma demuestra que alguien que posee la clave de firma produjo exactamente esta secuencia de bytes y que no se ha alterado. No demuestra nada sobre si el token iba dirigido a ti, si sigue siendo válido o si el usuario todavía existe. Hacer bien el paso de la firma requiere tres decisiones concretas.

Pon el algoritmo en lista blanca. Decide en el servidor qué algoritmos aceptas y rechaza todos los demás, sin pedirle permiso a la cabecera alg del token. Dos ataques clásicos dependen de confiar en ese campo: alg: none, en el que una librería trata una firma vacía como válida, y la confusión de algoritmos, en la que un servicio configurado para RS256 recibe un token firmado con HS256 usando la clave pública RSA como secreto HMAC; al fin y al cabo, la clave pública es pública. Ambos desaparecen cuando el verificador se construye con un algoritmo fijo.

Elige la clave por kid en un conjunto de claves de confianza. No descargues nunca una clave desde una URL indicada en la propia cabecera del token (jku) ni confíes en una clave incrustada (jwk). Obtén el JWKS del emisor a partir de su documento de discovery, guárdalo en caché y refréscalo cuando aparezca un kid desconocido, con un rate limit para que un atacante no pueda forzar una descarga por petición.

{
  "keys": [
    { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "sig-2026-03",
      "n": "wV5f8Q9c2u...", "e": "AQAB" },
    { "kty": "EC", "use": "sig", "alg": "ES256", "kid": "sig-2026-09",
      "crv": "P-256", "x": "f83OJ3D2xF...", "y": "x_FEzRu9m36..." }
  ]
}

Prefiere la firma asimétrica. Con RS256 o ES256, el resource server solo tiene la clave pública, así que puede verificar pero no emitir. HS256 comparte un mismo secreto entre el emisor y cada verificador, lo que significa que cualquier verificador puede falsificar tokens para todo el parque de servicios.

@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withJwkSetUri(props.getJwt().getJwkSetUri())
            .jwsAlgorithms(algs -> { algs.add(SignatureAlgorithm.RS256); algs.add(SignatureAlgorithm.ES256); })
            .build();

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            new JwtTimestampValidator(Duration.ofSeconds(30)),      // exp y nbf, desfase de reloj acotado
            new JwtIssuerValidator("https://sso.example.com"),
            new JwtClaimValidator<List<String>>("aud",
                    aud -> aud != null && aud.contains("https://api.example.com")),
            new JwtClaimValidator<String>("typ", "at+jwt"::equals)));
    return decoder;
}

Cómo se conecta ese decoder a una filter chain se explica en los fundamentos de Spring Security; lo importante aquí es que la lista de algoritmos, el emisor, la audiencia y el desfase de reloj son explícitos en lugar de quedar en sus valores por defecto.

Validar los claims

Una firma verificada es el principio de la checklist, no el final.

ClaimRegla
issCoincidencia exacta con el emisor esperado; nada de comparar por prefijo o sufijo
audDebe contener el identificador de esta API: es lo que frena un token emitido para otro servicio
expRechaza los tokens caducados; permite como máximo 30–60 segundos de desfase de reloj
nbfRechaza los tokens que aún no son válidos, con el mismo margen de desfase
iatÚtil para rechazar tokens sospechosamente antiguos y para políticas de frescura
subEl principal; combínalo con iss antes de usarlo como clave local
scopeAplícalo al endpoint que se está llamando
jtiNecesario si implementas un solo uso o una deny list

Rechaza los claims ausentes en lugar de darles un valor por defecto. Un validador que interpreta un exp ausente como «sin caducidad» convierte un solo token filtrado en una credencial permanente. Haz todas las comprobaciones antes de que la lógica de negocio toque la petición y devuelve 401 con una cabecera WWW-Authenticate: Bearer error="invalid_token", como especifica la RFC 6750, manteniendo el motivo impreciso para quien llama y preciso en tus logs.

Tokens stateless y el problema de la revocación

La propiedad que hace atractivos los tokens autocontenidos, que cualquier servicio pueda validarlos sin consultar al emisor, es exactamente la que dificulta la revocación. Hasta que caduca, un token válido sigue siendo válido, aunque la cuenta se haya desactivado hace un minuto. Hay cuatro respuestas viables, que normalmente se combinan:

  • Duraciones cortas. De cinco a quince minutos para un access token acotan la exposición y son la mitigación más barata. Combínalas con la rotación de refresh tokens para que la experiencia de usuario siga siendo fluida.
  • Una deny list. Guarda el jti de los tokens revocados hasta su exp y compruébalo en cada petición. Es pequeña y acotada, porque las entradas caducan solas, pero vuelve a introducir una consulta compartida.
  • Un claim de versión de token. Mantén un entero por usuario, inclúyelo en los tokens emitidos y compáralo con un valor en caché. Incrementarlo invalida con una sola escritura todo lo emitido para ese usuario: ideal para «cerrar sesión en todas partes» y para los cambios de contraseña.
  • Introspección. Pregunta al authorization server en cada llamada, con una caché opcional de unos segundos. Es la opción más precisa, a costa de añadir una dependencia en el camino crítico; un API gateway es un lugar sensato para hacerlo una sola vez para muchos servicios.

Las operaciones sensibles merecen un trato más estricto en cualquier caso: volver a autenticar, comprobar auth_time o exigir un token con prueba de posesión antes de una transferencia o de un cambio de permisos.

Bearer tokens, almacenamiento y transporte

Un bearer token es un título al portador: quien lo tiene puede usarlo. Esa sola frase dicta la mayoría de las reglas operativas. Envíalo solo sobre TLS 1.3 (o 1.2 con cifrados modernos), solo en la cabecera Authorization —nunca en una query string, donde acaba en los logs de acceso, las cachés de los proxies y las cabeceras Referer— y no lo registres nunca en logs, ni siquiera truncado en un build de depuración.

Para los clientes de navegador, la cuestión del almacenamiento no tiene una respuesta gratuita:

UbicaciónExposiciónNotas
localStorageCualquier script de la página lo leeUn solo fallo XSS equivale al robo completo del token; persiste entre pestañas y reinicios
Variable en memoriaLos scripts de la página, mientras esté cargadaSe pierde al recargar; mejor que persistirlo, pero sigue al alcance de un XSS
Cookie HttpOnlyLos scripts no pueden leerlaSe envía automáticamente, así que hacen falta defensas contra CSRF
Backend-for-frontendNo está en el navegadorLos tokens se quedan en el servidor; el navegador tiene una cookie de sesión

El patrón backend-for-frontend, un pequeño componente de servidor que hace el intercambio del código, guarda los tokens y actúa de proxy de las llamadas a la API, es lo recomendado para las aplicaciones de navegador nuevas. Cuando el token tiene que vivir en el navegador, mantenlo en memoria, con una duración corta, y trata el cross-site scripting como de gravedad crítica, porque con un token en la página lo es.

Los sender-constrained tokens eliminan de raíz el problema del portador. Con DPoP (RFC 9449), el cliente genera un par de claves y envía una cabecera de prueba firmada junto a cada petición; el token lleva una huella de la clave pública, así que un token robado sin la clave privada no sirve de nada. La vinculación a un certificado mutual-TLS (RFC 8705) consigue lo mismo para los clientes del lado del servidor. Ambas opciones merecen el esfuerzo en APIs de alto valor.

GET /accounts/1042/transactions HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...

Lo que el token no hace

La autenticación te dice quién llama. Todo lo que viene después sigue siendo responsabilidad tuya, y es donde de verdad se producen la mayoría de las brechas en APIs.

Un scope no es una autorización. Un token con orders.read indica que el cliente puede intentar leer pedidos. Si este sujeto puede leer el pedido 1042 es una pregunta que solo tu dominio puede responder, y olvidar hacerla es broken object-level authorization, el fallo grave más común en APIs. No deduzcas nunca la propiedad a partir de un identificador de la ruta o del cuerpo; dedúcela de sub y compruébala.

@GetMapping("/orders/{id}")
@PreAuthorize("hasAuthority('SCOPE_orders.read')")
public OrderView get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
    Order order = orders.findById(id).orElseThrow(NotFoundException::new);
    if (!order.customerId().equals(jwt.getSubject())) {
        throw new NotFoundException();   // 404, no 403: no confirmes que el registro existe
    }
    return OrderView.of(order);
}

Valida todas las entradas. Tipos, rangos, longitudes, enumeraciones y formatos, en el servidor, rechazando en lugar de sanear siempre que sea posible. Limita el tamaño del cuerpo de las peticiones y la longitud de las colecciones para que una sola llamada no pueda agotar la memoria, y pon límites a cualquier parser que expongas.

Aplica rate limiting por identidad, no solo por dirección. Los límites asociados al sujeto o al identificador del cliente sobreviven a los proxies compartidos y a las redes móviles; los límites por endpoint protegen las operaciones costosas; un tope global protege el servicio. Devuelve 429 con Retry-After.

No filtres información por los errores ni por la forma de las respuestas. Una stack trace, un mensaje distinto para «el usuario no existe» y «contraseña incorrecta», o un 403 donde un 404 revelaría menos: todo eso da información a un atacante. Los códigos de estado que se derivan de esto se tratan en la guía general de diseño de APIs REST.

Restringe CORS a conciencia. Reflejar la cabecera Origin y permitir credenciales equivale a no tener política; enumera los orígenes a los que realmente das servicio.

Registra las decisiones, no los tokens. Anota el sujeto, el cliente, el endpoint, el resultado y el identificador de correlación. Ese rastro es lo que permite reconstruir un incidente, y no cuesta nada en tiempo de petición.

Ideas clave

  • Cualquiera que tenga un JWT firmado puede leerlo; la firma aporta integridad y origen, no confidencialidad.
  • Fija en el código los algoritmos aceptados y elige las claves por kid en un JWKS en caché, nunca a partir de alg, jku o jwk del token.
  • Prefiere las firmas asimétricas para que un resource server pueda verificar sin poder emitir tokens.
  • Valida explícitamente iss, aud, exp y nbf, y rechaza los claims ausentes en lugar de darles un valor por defecto.
  • Los tokens stateless no se pueden retirar: usa duraciones cortas más una deny list, un claim de versión de token o introspección cuando la revocación inmediata importe.
  • Mantén los tokens del navegador fuera del almacenamiento persistente (mejor un backend-for-frontend) y plantéate DPoP o la vinculación mTLS para APIs de alto valor.
  • Los scopes nunca sustituyen a la autorización a nivel de recurso, la validación de entradas, el rate limiting ni unas respuestas de error cuidadas.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Un JWT está cifrado?
Un JWT firmado, no. La cabecera y el payload están codificados en base64url, que es una codificación y no un cifrado, así que cualquiera que tenga el token puede leer todos los claims. La firma garantiza integridad y origen, nada más. Si un claim debe mantenerse confidencial frente al cliente o a los intermediarios, déjalo fuera del token o usa JWE, que cifra el payload.
¿Cómo revoco un JWT antes de que caduque?
El token en sí no se puede revocar, porque la verificación es offline por diseño. Los enfoques prácticos son duraciones cortas para que la ventana sea pequeña, una deny list de identificadores de token consultada en cada petición, una versión de token por usuario comparada con un claim o la introspección contra el emisor. La mayoría de los sistemas combinan access tokens cortos con la revocación del refresh token.
¿El token debe ir en localStorage o en una cookie?
Ninguna de las dos opciones es correcta sin condiciones. localStorage es legible por cualquier script, así que un solo fallo de cross-site scripting basta para exfiltrar el token. Una cookie puede ser HttpOnly e invisible para los scripts, pero se envía automáticamente, lo que reintroduce el cross-site request forgery salvo que configures SameSite y uses un token anti-CSRF. La opción más sólida es un backend-for-frontend que guarda los tokens en el servidor y da al navegador una cookie de sesión.
¿Por qué es arriesgado el algoritmo simétrico HS256 en una API pública?
Usa un único secreto compartido tanto para firmar como para verificar, de modo que cualquier servicio capaz de verificar un token también puede fabricarlo. En un sistema con varios resource servers, cualquier servicio comprometido o descuidado se convierte en una fábrica de tokens. La firma asimétrica con RS256 o ES256 solo entrega a los verificadores la clave pública, así que un resource server comprometido no puede falsificar tokens para los demás.