En Spring Security 6 desapareció WebSecurityConfigurerAdapter. Si vienes de la versión 5, el primer contacto desorienta: no hay nada que extender ni métodos que sobrescribir. El nuevo modelo pide declarar beans, y una vez que se entiende, la configuración deja de tener estado oculto y se vuelve legible de arriba abajo.
Este artículo cubre el camino completo para proteger una API REST con JWT: el flujo, la cadena de filtros, el filtro de validación, la alternativa que trae el propio framework, y las decisiones de seguridad que conviene tomar de forma consciente en lugar de copiar.
El flujo, sin adornos
- El cliente envía credenciales a un endpoint público.
- El servidor las valida y devuelve un JWT firmado.
- El cliente incluye el token en
Authorization: Bearer <token>en cada petición. - El servidor verifica la firma, comprueba los claims y construye la autenticación en memoria para esa petición.
El punto 4 es el que importa: no hay sesión. Cada petición se autentica desde cero, y el SecurityContext se descarta al terminar. De ahí vienen casi todas las decisiones que siguen.
La cadena de filtros
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AuthenticationEntryPoint authenticationEntryPoint;
public SecurityConfig(JwtAuthFilter jwtAuthFilter,
AuthenticationEntryPoint authenticationEntryPoint) {
this.jwtAuthFilter = jwtAuthFilter;
this.authenticationEntryPoint = authenticationEntryPoint;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Tres decisiones merecen explicación, porque son las que se suelen copiar sin mirar:
csrf.disable() no es “desactivar una molestia”. CSRF explota que el navegador adjunta credenciales de forma automática: cookies de sesión, básicamente. Un token que el cliente pone a mano en un header no se envía solo desde otro origen, así que el ataque no aplica. En el momento en que muevas el token a una cookie, CSRF vuelve a aplicar y esta línea se convierte en un agujero.
STATELESS no es una optimización. Sin él, Spring Security crea HttpSession y guarda el contexto ahí, con lo cual terminas con dos mecanismos de autenticación conviviendo y un token que en la práctica no se revisa en cada petición.
El orden de authorizeHttpRequests importa. Se evalúa de arriba abajo y gana la primera coincidencia. Poner anyRequest() antes de una regla específica deja esa regla muerta sin ningún aviso.
El filtro
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthFilter.class);
private static final String PREFIX = "Bearer ";
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith(PREFIX)) {
chain.doFilter(request, response);
return;
}
try {
String token = header.substring(PREFIX.length());
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
var authentication = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (JwtException e) {
SecurityContextHolder.clearContext();
log.debug("Token rechazado: {}", e.getMessage());
}
chain.doFilter(request, response);
}
}
Cuatro detalles que marcan la diferencia entre un filtro de tutorial y uno que sobrevive en producción:
OncePerRequestFilter, noGenericFilterBean. Sin esa garantía, unforwardinterno vuelve a ejecutar el filtro sobre la misma petición.- Un token inválido no lanza la excepción hacia arriba. Se limpia el contexto y se deja continuar; el
AuthenticationEntryPointdecidirá el401. Si el filtro lanza, el error sale delFilterChainsin pasar por@ControllerAdvicey el cliente recibe un HTML de error en lugar de JSON. SecurityContextHolder.clearContext()es obligatorio en elcatch. El contexto vive en unThreadLocalsobre un pool de hilos reutilizados: dejarlo sucio es filtrar una identidad a la siguiente petición.- Nunca registres el token en los logs. Ni completo ni truncado: un token en un agregador de logs es una credencial en un agregador de logs.
El AuthenticationEntryPoint cierra el círculo devolviendo un error con el mismo formato que el resto de la API:
@Component
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "Credenciales ausentes o inválidas");
problem.setTitle("Unauthorized");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
objectMapper.writeValue(response.getOutputStream(), problem);
}
}
La alternativa: dejar que el framework valide
El filtro anterior tiene sentido cuando el mismo servicio emite y valida el token. En cuanto hay más de un servicio, escribir a mano la validación en cada uno es trabajo repetido y superficie de error repetida. Spring Security trae un resource server que hace exactamente eso:
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
@Bean
JwtDecoder jwtDecoder(@Value("${security.jwt.issuer}") String issuer,
@Value("${security.jwt.audience}") String audience) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(issuer + "/.well-known/jwks.json")
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(audience)),
new JwtTimestampValidator(Duration.ofSeconds(30))
));
return decoder;
}
Lo que se gana no es solo menos código: el decoder descarga las claves públicas del JWKS y las rota solo, valida iss, exp y nbf por defecto, y —esto es lo importante— usa el algoritmo declarado en el JWKS, no el del header del token. Esa es la defensa contra los ataques de confusión de algoritmo, y es fácil de olvidar cuando se valida a mano.
Los roles se mapean con un converter, sin tocar el filtro:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
Decisiones de seguridad
Expiración corta y refresh aparte. El access token entre 15 y 30 minutos; el refresh token largo, en una cookie HttpOnly + Secure + SameSite=Strict, y rotado en cada uso. Un refresh reutilizado es señal de robo: lo correcto ahí es invalidar toda la familia de tokens de ese usuario.
El access token no va a localStorage. Cualquier XSS lo lee. En memoria de la aplicación cliente, renovándolo con el refresh token de la cookie, es el compromiso razonable.
Firma asimétrica si hay más de un validador. Con HMAC, todo servicio que valide tokens tiene también la capacidad de emitirlos: comprometer el más expuesto es comprometer el sistema entero. Con ES256 o RS256, los servicios solo necesitan la clave pública. Si además la clave privada puede vivir fuera de la aplicación —un HSM, un gestor de secretos con motor de firma— mejor todavía: lo que no está en el disco no se filtra en un repositorio.
Valida aud e iss explícitamente. Un token válido emitido para otro servicio es, si no lo compruebas, un token válido para el tuyo.
Revocación: JWT no la trae. Un token firmado es válido hasta que expira, y ese es el precio de no consultar la base en cada petición. Si el dominio exige cierre de sesión inmediato —salud, banca—, la salida práctica es una denylist en Redis con TTL igual a la vida del token: solo crece con las sesiones cerradas antes de tiempo, y el coste es una lectura de Redis por petición.
Claims justos. El token viaja en cada petición, lo ve cualquier proxy del camino y va firmado, no cifrado: cualquiera puede leerlo. Identificador, roles y expiración. Nada de correos, documentos ni datos personales.
Qué elegir
Si el servicio emite sus propios tokens y es el único que los consume, el filtro propio es transparente y suficiente. Si hay varios servicios validando tokens de un emisor común, el resource server con JWKS elimina la duplicación y trae de fábrica las validaciones que más cuesta recordar.
En los dos casos, la parte que decide si el sistema es seguro no es el código de esta página: es dónde vive la clave privada, cuánto dura un token y qué pasa cuando alguien se lo roba.