Ir al contenido

Spring SecurityLección 09

APIs stateless con JWT

Un JWT es una credencial que se valida sola. Cómo configurar el OAuth2 Resource Server, de dónde saca las claves, cómo convertir claims en authorities y qué no resuelve un JWT.

Avanzado20 min de lectura

Al terminar sabrás

  • Proteger una API con oauth2ResourceServer sin escribir un filtro propio.
  • Configurar la validación de firma, emisor y audiencia.
  • Convertir scopes o roles del token en authorities.
  • Emitir y validar tus propios JWT con claves RSA.

Un JWT es una credencial que se valida sola: el servidor comprueba la firma con una clave y confía en lo que dice el token sin consultar ninguna base de datos. Eso es lo que lo hace escalable, y también lo que lo hace peligroso si se valida mal. Spring Security trae el validador hecho y auditado: el OAuth2 Resource Server. (Repasa la anatomía de un JWT en sesiones, cookies y tokens).

ClienteEmisorAuth Server o /tokenAPIresource server1credenciales (una vez)2JWT firmado (exp: 15 min)3GET /api/notes + Bearer JWT4verifica firma, exp, iss, aud5200 OK
La base de datos de usuarios solo se toca al emitir. En cada petición, la identidad sale del token.

Evita

// ¿Rechaza alg:none? ¿Valida exp, iss, aud?
// ¿Evita la confusión de algoritmos?
// ¿Responde WWW-Authenticate correctamente?
class JwtAuthFilter extends OncePerRequestFilter {
// 120 líneas de parseo a mano…
}

Prefiere

http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()));

Esa línea añade BearerTokenAuthenticationFilter, un JwtAuthenticationProvider con su JwtDecoder, y un entry point que responde 401 con WWW-Authenticate: Bearer error="invalid_token".

Dependencia (en Spring Boot 4 el nombre del starter lleva security-):

pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>

Una de estas, según tu emisor:

application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# 1) Descubre claves y valida iss a partir del emisor (lo más habitual)
issuer-uri: https://auth.example.com
# 2) …o solo el endpoint de claves, sin validar iss
# jwk-set-uri: https://auth.example.com/oauth2/jwks
# 3) …o una clave pública fija
# public-key-location: classpath:certs/public.pem
audiences: notes-api # exige que aud contenga este valor
ClaimLo valida Spring por defecto
FirmaSí. Por defecto NimbusJwtDecoder solo confía en RS256
exp / nbfSí, con 60 segundos de tolerancia de reloj
issSí, si configuras issuer-uri
audSolo si configuras audiences
typ (header)Sí desde la 7.0: debe ser JWT o no estar
subNo se valida: pasa a ser authentication.getName()

Si necesitas control total, declara tu propio JwtDecoder con validadores adicionales:

JwtConfig.java
@Bean
JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation("https://auth.example.com").build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator("https://auth.example.com"),
new JwtAudienceValidator("notes-api"),
new JwtClaimValidator<String>("tenant", "acme"::equals)));
return decoder;
}
  • HS256 (HMAC): la misma clave firma y verifica. Todo servicio que valida también podría emitir tokens. Aceptable solo si emisor y validador son la misma aplicación.
  • RS256 / ES256: el emisor firma con la privada; los demás verifican con la pública, que se publica en el endpoint JWKS. Es lo que usan Keycloak, Auth0, Google y Spring Authorization Server.

Por defecto, el claim scope (o scp) se convierte en authorities con prefijo SCOPE_: "notes:read notes:write" → SCOPE_notes:read, SCOPE_notes:write. En las reglas:

.requestMatchers(HttpMethod.GET, "/api/notes/**").hasAuthority("SCOPE_notes:read")
.requestMatchers("/api/notes/**").hasAuthority("SCOPE_notes:write")

Si tu emisor pone los roles en otro claim, personaliza el conversor:

JwtConfig.java
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // {"roles": ["ADMIN", "USER"]}
roles.setAuthorityPrefix("ROLE_"); // → ROLE_ADMIN, ROLE_USER
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}

(Para claims anidados, como realm_access.roles de Keycloak, pasa una lambda propia a setJwtGrantedAuthoritiesConverter que lea el mapa y devuelva la lista).

En el controlador, el principal es un Jwt:

MeController.java
@GetMapping("/api/me")
Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of("sub", jwt.getSubject(), "scope", jwt.getClaimAsString("scope"));
}

Como nadie consulta la base de datos, desactivar a un usuario no invalida los tokens que ya tiene. Las mitigaciones estándar:

  • Access tokens cortos (5–15 minutos) y refresh tokens que sí se guardan y se pueden revocar.
  • Una lista de jti revocados en Redis (reintroduce estado).
  • Tokens opacos con introspección (opaqueToken(...) en lugar de jwt(...)), cuando la revocación inmediata es imprescindible.

Mini lab · 45 min

La API emite y valida sus propios JWT

Objetivo: Reemplazar HTTP Basic en /api/** por tokens Bearer RS256, manteniendo Basic solo para obtener el token.

  1. Añade spring-boot-starter-security-oauth2-resource-server y genera un par de claves (nunca las subas a git en un proyecto real):
Ventana de terminal
mkdir -p src/main/resources/certs && cd src/main/resources/certs
openssl genrsa -out keypair.pem 2048
openssl rsa -in keypair.pem -pubout -out public.pem
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.pem
  1. Enlázalas y declara encoder y decoder:
JwtConfig.java
@ConfigurationProperties(prefix = "rsa") // rsa.public-key=classpath:certs/public.pem …
record RsaKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) { }
@Bean
JwtEncoder jwtEncoder(RsaKeyProperties rsa) {
return NimbusJwtEncoder.withKeyPair(rsa.publicKey(), rsa.privateKey()).build();
}
@Bean
JwtDecoder jwtDecoder(RsaKeyProperties rsa) {
return NimbusJwtDecoder.withPublicKey(rsa.publicKey()).build();
}
  1. Un servicio que emite el token a partir de la autenticación del login:
TokenService.java
@Service
class TokenService {
private final JwtEncoder encoder;
TokenService(JwtEncoder encoder) {
this.encoder = encoder;
}
String issue(Authentication auth) {
Instant now = Instant.now();
boolean admin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://notes.local")
.issuedAt(now)
.expiresAt(now.plus(15, ChronoUnit.MINUTES))
.subject(auth.getName())
.audience(List.of("notes-api"))
.claim("scope", admin ? "notes:read notes:write notes:admin" : "notes:read notes:write")
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
  1. Dos cadenas: POST /api/auth/token con HTTP Basic que devuelve tokenService.issue(authentication), y el resto de /api/** con oauth2ResourceServer(o -> o.jwt(withDefaults())), ambas stateless y sin CSRF. Usa hasAuthority("SCOPE_notes:admin") en /api/admin/**.
  2. Prueba el flujo:
Ventana de terminal
TOKEN=$(curl -s -X POST -u ana:ana-password-123 http://localhost:8080/api/auth/token)
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notes

Preguntas:

  • Decodifica el token. ¿Qué información de ana puede leer cualquiera que lo intercepte?
  • Cambia una letra del payload y envíalo. ¿Qué dice la cabecera WWW-Authenticate?
  • Desactiva a ana en la base de datos y usa su token anterior. ¿Funciona? ¿Qué cambiarías para que no?
  • Pon la expiración en 1 minuto y espera 2. ¿Por qué a veces sigue funcionando hasta casi el minuto 2?
Pista

La última pregunta tiene que ver con un valor por defecto de la tabla de claims.

Mostrar respuestaOcultar respuesta
  • sub (ana), iss, aud, scope, iat y exp: quién es, qué puede hacer y hasta cuándo. Por eso no se ponen datos sensibles en el payload.
  • WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", con un 401. La firma ya no corresponde al contenido.
  • Sí funciona hasta que expire: la API no consulta la base de datos. Para cortarlo antes: tokens más cortos con refresh tokens revocables, una lista de tokens revocados, o comprobar el estado del usuario en un JwtClaimValidator/conversor que consulte una caché (reintroduciendo algo de estado).
  • Porque JwtTimestampValidator aplica 60 segundos de tolerancia de reloj (clock skew) para no rechazar tokens válidos por pequeñas diferencias entre servidores.