
Creamos un filtro para interceptar las peticiones HTTP entrantes a los controladores REST de una API corporativa stateless. El filtro:
- Detecta la cabecera
Authorization. - Extrae el token JWT tras el prefijo
Bearer. - Verifica la firma del token con la clave simétrica configurada.
- Decodifica el payload y extrae los claims (subject, rol, departamento).
- Registra la autenticación en el
SecurityContextHolderpara que el resto de la aplicación reconozca al empleado.
Crear filtro Spring
Clase Java que extiende de OncePerRequestFilter (garantiza una única ejecución por request, incluso en forwards internos):
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.util.List;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private static final String BEARER_PREFIX = "Bearer ";
private final SecretKey secretKey;
private final String expectedIssuer;
public JwtAuthenticationFilter(JwtKeyProvider keyProvider,
@Value("${app.jwt.issuer}") String expectedIssuer) {
this.secretKey = keyProvider.getSecretKey();
this.expectedIssuer = expectedIssuer;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(BEARER_PREFIX.length()).trim();
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.requireIssuer(expectedIssuer)
.build()
.parseSignedClaims(token)
.getPayload();
String subject = claims.getSubject();
String rol = claims.get("rol", String.class);
String departamento = claims.get("departamento", String.class);
List<SimpleGrantedAuthority> autoridades = rol != null
? List.of(new SimpleGrantedAuthority("ROLE_" + rol))
: List.of();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(subject, null, autoridades);
authentication.setDetails(new EmpleadoContexto(subject, departamento));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (JwtException ex) {
log.debug("JWT inválido: {}", ex.getMessage());
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
public record EmpleadoContexto(String empleadoId, String departamento) {}
}
Puntos relevantes:
- La clave secreta se obtiene de un bean
JwtKeyProviderque la lee deapp.jwt.secret(inyectada desde variable de entorno o Vault). Nunca hardcodeada. requireIssuer(expectedIssuer)rechaza tokens emitidos por otro sistema. Defensa básica contra confusión de audiencia.- Si el JWT es inválido (firma mal, expirado, issuer equivocado), limpiamos el contexto y dejamos que la petición continúe sin autenticación. El
SecurityFilterChainla rechazará con401si el endpoint lo requiere. EmpleadoContextose adjunta comodetailspara acceder al departamento desde controladores sin volver a parsear el JWT.
Proveedor de la clave simétrica
@Component
public class JwtKeyProvider {
private final SecretKey secretKey;
public JwtKeyProvider(@Value("${app.jwt.secret}") String secretBase64) {
byte[] keyBytes = Base64.getDecoder().decode(secretBase64);
if (keyBytes.length < 32) {
throw new IllegalStateException(
"app.jwt.secret debe ser Base64 de al menos 256 bits (32 bytes)");
}
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
public SecretKey getSecretKey() {
return secretKey;
}
}
Clase SecurityConfig
Configuración moderna con SecurityFilterChain, csrf deshabilitado (stateless), sesión STATELESS y el filtro JWT añadido antes del UsernamePasswordAuthenticationFilter:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
public SecurityConfig(JwtAuthenticationFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/empleados/register").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/facturas/**")
.hasAnyRole("ADMIN", "FINANZAS", "RRHH")
.requestMatchers(HttpMethod.POST, "/api/facturas/**").hasRole("FINANZAS")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
Observaciones:
- El
requestMatchersestá tipado por método y por rol corporativo (FINANZAS,RRHH,ADMIN). Esto refleja la estructura real de un backoffice empresarial, no un genéricoUSER/ADMIN. hasRole("FINANZAS")mapea a la authorityROLE_FINANZAS. El filtro ya añade el prefijoROLE_al rol que viene del claim.@EnableMethodSecurityhabilita@PreAuthorizeen servicios y controladores para cuando las reglas de autorización no encajen en el matcher HTTP.
Recuperar el empleado autenticado
Para acceder al empleado actual desde cualquier parte de la aplicación, centralizamos la lógica en un componente en vez de desperdigarla por controladores.
@Component
public class SeguridadContexto {
private final EmpleadoRepository empleadoRepository;
public SeguridadContexto(EmpleadoRepository empleadoRepository) {
this.empleadoRepository = empleadoRepository;
}
public Optional<Empleado> empleadoActual() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return Optional.empty();
}
try {
Long id = Long.parseLong(auth.getName());
return empleadoRepository.findById(id);
} catch (NumberFormatException ex) {
return Optional.empty();
}
}
public Empleado empleadoActualObligatorio() {
return empleadoActual().orElseThrow(() ->
new AccessDeniedException("No hay empleado autenticado"));
}
}
Ejemplo de uso en un controlador de pedidos internos:
@RestController
@RequestMapping("/api/pedidos")
public class PedidoController {
private final PedidoRepository pedidoRepository;
private final SeguridadContexto seguridadContexto;
public PedidoController(PedidoRepository pedidoRepository,
SeguridadContexto seguridadContexto) {
this.pedidoRepository = pedidoRepository;
this.seguridadContexto = seguridadContexto;
}
@PostMapping
public Pedido crear(@RequestBody @Valid NuevoPedidoRequest request) {
Empleado solicitante = seguridadContexto.empleadoActualObligatorio();
Pedido pedido = new Pedido();
pedido.setSolicitante(solicitante);
pedido.setConcepto(request.concepto());
pedido.setImporte(request.importe());
return pedidoRepository.save(pedido);
}
}
O directamente inyectando el Authentication que Spring ya resuelve por nosotros:
@PutMapping("/cuenta")
@PreAuthorize("isAuthenticated()")
public EmpleadoResumenDto actualizarCuenta(
@RequestBody @Valid ActualizarCuentaRequest request,
Authentication authentication) {
Long idAutenticado = Long.parseLong(authentication.getName());
Empleado empleado = empleadoRepository.findById(idAutenticado)
.orElseThrow(() -> new AccessDeniedException("Empleado no encontrado"));
// El propio empleado puede modificar sus datos no sensibles.
empleado.setTelefono(request.telefono());
empleado.setIbanNomina(request.ibanNomina());
return new EmpleadoResumenDto(empleado);
}
Nótese que ya no hacemos throw new RuntimeException("No puede actualizar"): la regla se expresa con @PreAuthorize o con la excepción de dominio AccessDeniedException, que Spring mapea a 403 Forbidden automáticamente.
Checklist de revisión antes de producción
- La clave secreta se lee de
APP_JWT_SECRET(variable de entorno) o de Vault. Nunca en elapplication.ymlcomiteado. app.jwt.issuerestá definido y el filtro llama arequireIssuer.- El logger no imprime el token completo; como mucho su prefijo para trazabilidad.
- Las rutas públicas (
/api/auth/login,/actuator/health) están listadas explícitamente enauthorizeHttpRequests. - Las excepciones de
JwtExceptionno se propagan al cliente con stack trace; se registran a nivel DEBUG y se devuelve401limpio. - La cabecera
Authorizationse envía solo sobre HTTPS en producción (HSTS activo, HTTP redirigido a HTTPS).
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Security
Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Crear un filtro OncePerRequestFilter que valide JWT. Usar la API moderna JJWT 0.12+ con Jwts.parser().verifyWith(). Leer la clave secreta de configuración (nunca hardcodear). Registrar la autenticación en SecurityContextHolder con los claims de rol y departamento.