1. El rol de JwtDecoder y la configuración explícita
Si ves la palabra JwtDecoder por primera vez, es muy fácil imaginar «otro bean mágico que hay que activar en algún sitio para que deje de quejarse». Pero en realidad todo es mucho más simple e incluso agradable: JwtDecoder es un punto concreto donde decimos honestamente a la aplicación con qué reglas debe confiar en el token entrante. La configuración explícita es necesaria para no «buscar la clave por todo el proyecto», no adivinar por qué el token a veces se acepta y a veces no, y no convertir la seguridad en una lotería.
El problema clave del camino JWT incorporado es que no puede adivinar tu criptografía ni tu contrato de token. Spring Security está listo para hacer todo el trabajo de infraestructura, pero necesita saber dos cosas. Primero, con qué verificar la firma (secreto/clave pública). Segundo, qué comprobaciones básicas considerar obligatorias (por ejemplo, la fecha de caducidad exp). Por eso JwtDecoder es literalmente un «notario incorporado»: toma la cadena del token, verifica la firma, comprueba las condiciones mínimas de validez y devuelve un objeto Jwt normal, en el que ya se puede confiar a lo largo de la cadena.
Es importante no confundir JwtDecoder con lo que hacíamos al emitir el token. En nuestro proyecto, el token lo emite TokenService, mientras que JwtDecoder solo se ocupa de las solicitudes entrantes. Es útil tener esto en mente como una división del trabajo: un componente «imprime pases» (token issuing), el otro «verifica los pases a la entrada» (token validation).
A continuación, una pequeña tabla que suele curar al instante el dolor de cabeza de «¿dónde vive realmente el JWT en la aplicación?»:
| Zona | Componente | Qué hace | Qué NO hace |
|---|---|---|---|
| Emisión de token | TokenService | Recopila claims, firma el JWT, devuelve la cadena al cliente | No verifica tokens entrantes |
| Verificación de token | JwtDecoder | Verifica la firma y la validez básica, devuelve Jwt | No inicia sesión del usuario, no asigna authorities |
| Authorization | rules / method security | Decide «se puede / no se puede» basándose en Authentication y authorities | No verifica la firma del token |
2. Modelo mental de JwtDecoder en la cadena
Antes de escribir código, es útil detenerse un minuto y «ponerse una imagen en la cabeza». Spring Security en una aplicación servlet/MVC piensa a través de un escenario básico: llega una solicitud, los filtros y proveedores intentan recopilar un Authentication, luego la autorización compara el Authentication con las reglas. JwtDecoder se encuentra exactamente en el lugar donde es necesario convertir una «cadena Bearer» en un «objeto JWT verificado», y luego en un Authentication. Es decir, JwtDecoder no es un filtro ni un controlador, sino un componente de infraestructura dentro de la parte de autenticación.
Imagina que en la solicitud te llega esta cadena:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6...
En este punto, la aplicación solo tiene texto. Y necesitamos respuestas a tres preguntas aburridas pero vitales. Primero, ¿el token no está roto? Segundo, ¿está firmado con la clave en la que confiamos? Tercero, ¿no ha caducado (no ha expirado según exp)? Estas preguntas son precisamente las que resuelve el decoder (y sus validadores).
Es útil recordar la cadena en forma de esquema. Es aproximado y simplificado, pero a nivel del curso es lo que se necesita:
flowchart TD
A[HTTP Request] --> B[BearerTokenAuthenticationFilter]
B --> C[JwtAuthenticationProvider]
C --> D[JwtDecoder]
D --> E[Jwt]
E --> F[JwtAuthenticationConverter]
F --> G[JwtAuthenticationToken]
G --> H[SecurityContext]
Y aquí hay una bifurcación importante en la mente: JwtDecoder devuelve un Jwt (datos del token), no un Authentication. Es decir, se encarga de verificar y desempaquetar el token, pero no de convertir los claims en authorities — esa es tarea del converter (siguiente lección de hoy).
3. Dónde almacenar JwtDecoder y la clave
Ahora vamos a escribir código, y aquí suele esperar al principiante una trampa clásica: «bueno, voy a insertar rapidamente el secret aquí… y aquí… y aquí…». Al cabo de dos días resulta que los tokens se firman con una clave, se validan con otra, y la búsqueda del error se convierte en una excavación arqueológica. Por lo tanto, el lugar correcto para JwtDecoder es tu capa de seguridad/configuración, y la clave (o secreto) debe aparecer en la aplicación de una sola forma: como un único bean que usan tanto TokenService como JwtDecoder.
En nuestro proyecto Secure Content Platform API seguimos la estructura de paquetes de los requisitos del curso, por lo que la configuración de la rama JWT incorporada vive lógicamente aquí:
- com.example.securecontent.security.config — configuraciones de seguridad
- (opcional) com.example.securecontent.security.jwt — lo relacionado con tokens
Una buena práctica para un proyecto de aprendizaje se ve así: tienes JwtProperties (para leer la configuración de application.yml), tienes un bean SecretKey (creamos la clave una sola vez), y luego dos consumidores de esta clave: el servicio de emisión de token y el decoder de verificación de token.
Y sí, este es ese caso raro en el que quieres decir: «no tengas vergüenza de usar DI». Dependency Injection aquí realmente evita desincronizaciones accidentales. Si la clave se crea en un solo lugar, será más difícil romper el sistema (y para la seguridad eso es una ventaja).
4. Secreto de la configuración → SecretKey
Antes de crear JwtDecoder necesitamos una clave. En nuestra opción de aprendizaje usamos un secreto simétrico (HMAC), porque es más fácil de entender: el mismo secreto firma el token (en TokenService) y verifica la firma (en JwtDecoder). En sistemas más «adultos» suele haber claves asimétricas (pública/privada), JWK y todo eso, pero hoy no entramos ahí — de lo contrario la lección se convierte en una serie de 12 temporadas.
Empecemos con la configuración. Supongamos que en application.yml (o application-local.yml) tenemos esta clave:
app:
security:
jwt:
# El secreto se toma de la variable de entorno para no almacenar la clave real en el repositorio
# El valor por defecto es solo para ejecución local/proyecto de aprendizaje
secret: ${APP_SECURITY_JWT_SECRET:change-me-change-me-change-me-32-bytes}
El sentido es simple: en el repositorio no mantenemos el secreto real, y para la ejecución local damos un valor por defecto. Idealmente, el secreto por defecto debe ser suficientemente largo, de lo contrario con algoritmos HMAC se pueden obtener errores a nivel de librerías. Si ahora pensaste «¿puede ser corto, solo estoy aprendiendo?» — puedes, pero entonces aprenderás de los errores, no de JWT.
Ahora creamos la clase de propiedades. En Spring Boot esto se hace cómodamente con @ConfigurationProperties. Me gustan los records por su concisión: menos código, menos posibilidades de equivocarse.
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.security.jwt")
// Este record es un «contenedor» para la configuración JWT de application.yml
public record JwtProperties(String secret) {
// secret — representación en cadena del secreto (tal como llega de la configuración)
}
Para que este record realmente pueda inyectarse en la configuración, debe convertirse en un bean. Normalmente esto se resuelve de dos maneras habituales: o bien en la aplicación está habilitado @ConfigurationPropertiesScan, o bien la clase se registra explícitamente mediante @EnableConfigurationProperties(JwtProperties.class). Aquí nos importa el hecho mismo del registro: sin él, JwtProperties no llegará a JwtKeyConfig.
Ahora creamos una configuración que «extrae» el secreto de JwtProperties y crea un SecretKey. Aquí es importante ser coherente: si percibes el secret como una cadena, entonces tanto en TokenService como en el decoder debes usarlo de la misma manera. El error más común es que un componente toma UTF-8 bytes, otro hace Base64 decode, y las claves terminan siendo diferentes, aunque «a simple vista» la cadena es la misma.
Opción simple de aprendizaje (secreto como cadena normal):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
@Configuration
public class JwtKeyConfig {
@Bean
SecretKey jwtSecretKey(JwtProperties props) {
// Importante: fijamos una única forma de convertir la cadena en bytes
// Si aquí es UTF-8, en TokenService debe ser lo mismo.
byte[] bytes = props.secret().getBytes(StandardCharsets.UTF_8);
// "HmacSHA256" — nombre del algoritmo para SecretKeySpec (no confundir con JWT "HS256")
return new SecretKeySpec(bytes, "HmacSHA256");
}
}
Si usas este enfoque, debes usar exactamente el mismo jwtSecretKey también al firmar el token. Entonces todo coincidirá y JwtDecoder podrá verificar la firma.
Un micro-comentario aparte sobre «HmacSHA256». No es un algoritmo JWT en términos de HS256 (aunque lógicamente está relacionado), sino el nombre del algoritmo para SecretKeySpec. En este punto uno suele querer preguntar: «¿por qué tan complicado?». La respuesta: porque el mundo de la criptografía vive en sus propios estándares históricos, y aquí simplemente hacemos un puente entre el «secreto» y el «objeto clave».
5. NimbusJwtDecoder y SecurityFilterChain
Ahora tenemos la clave, y es hora de montar el decoder. En Spring Security la implementación típica de JwtDecoder es NimbusJwtDecoder. No es «mágica», simplemente usa internamente Nimbus JOSE + JWT y sabe hacer lo necesario para el escenario de resource server.
El bean mínimo de JwtDecoder se ve así:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import javax.crypto.SecretKey;
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
// Creamos un decoder que verificará la firma de los tokens entrantes con esta clave
return NimbusJwtDecoder.withSecretKey(jwtSecretKey).build();
}
}
En este punto, el decoder ya sabe verificar la firma, y eso es suficiente para que la cadena «capture» el token como JWT. El siguiente paso importante es hacer que SecurityFilterChain realmente use este decoder. Sí, a veces Spring Boot puede recoger automáticamente un bean por tipo, pero en un proyecto de aprendizaje prefiero la explicitación: el estudiante debe ver con sus propios ojos con qué la aplicación valida el token.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
// Decimos explícitamente a la parte de resource server: "usa este JwtDecoder"
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder))
);
return http.build();
}
}
Presta atención a un detalle: si en tu proyecto aún vive tu filtro JWT personalizado de días anteriores, no puedes «dejarlo por si acaso». Al final tendrás una situación de «dos guardias en la entrada discutiendo sobre cuál lista de invitados es la correcta», y el culpable de esta disputa será, como siempre, el desarrollador. En un conjunto de endpoints debe haber una única ruta activa de autenticación Bearer.
Otro matiz importante: stateless. El decoder por sí mismo no hace que la aplicación sea stateless. Stateless es la política de session management y el modelo general. Por eso en una configuración real normalmente ves junto:
import org.springframework.security.config.http.SessionCreationPolicy;
// ...
// Desactivamos las sesiones: cada solicitud debe autenticarse solo con el token
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
Pero hoy no reexplicamos stateless — simplemente fijamos que el decoder es parte de una cadena stateless, no una «casilla» independiente.
6. Cómo verificar JwtDecoder
Después de configurar el decoder, dan muchas ganas de «comprobar con las manos» que está vivo. Y aquí hay una importante trampa psicológica: puedes obtener un 401, y te parecerá que el decoder no funciona. Pero en realidad el decoder está funcionando perfectamente — simplemente el token no es válido. Por lo tanto, la verificación debe ser significativa: tomamos un token emitido por nuestro POST /api/auth/login y con él vamos a un endpoint protegido.
La prueba aplicada más simple a nivel de proyecto es hacer un endpoint «de depuración» en la zona /api/me, que devuelva exactamente lo que ha llegado al SecurityContext. Por ejemplo, a nivel de controlador puedes aceptar temporalmente un @AuthenticationPrincipal Jwt (esto es conveniente precisamente para verificar el decoder, porque muestra el token decodificado, no el header crudo).
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeDebugController {
@GetMapping("/api/me/debug/jwt-sub")
public String subject(@AuthenticationPrincipal Jwt jwt) {
// Este endpoint es útil para depuración: muestra el claim "sub" del token ya verificado
// En un proyecto real, estos endpoints normalmente se eliminan o se restringe el acceso.
return jwt.getSubject();
}
}
Si el decoder funciona, al hacer la solicitud con un token Bearer válido obtendrás el subject (sub) del token. Si el token falta, la solicitud se detendrá antes (normalmente 401). Si el token ha caducado, también 401. Si el token está firmado con otra clave, de nuevo 401. Y esto es bueno: tu controlador no debe resolver cuestiones de validez del token.
Ahora, sobre lo que el decoder no hace (es importante decirlo, de lo contrario la siguiente lección será más dolorosa). El decoder no está obligado a entender tus roles, authorities ni tu negocio. No convierte claims en draft:publish ni añade ROLE_ADMIN. Simplemente devuelve un Jwt, y luego el converter construye el Authentication. Por lo tanto, el síntoma típico de «el token es válido, pero aún así 403» suele indicar no un problema del decoder, sino un problema del mapeo de authorities.
Y una pequeña comprobación de cordura para el principiante: el decoder no tiene absolutamente nada que ver con el endpoint de login. Si el endpoint de login funciona, no significa que el decoder esté configurado. Y viceversa: el decoder puede ser perfecto incluso si tu endpoint de login está roto. Son partes diferentes del sistema.
7. Errores típicos al configurar JwtDecoder
Error n.º 1: los tokens se firman con una clave y se verifican con otra (o con la misma pero codificada de manera diferente).
Esta es la historia más común. Almacenas el secret como cadena, en TokenService haces getBytes(UTF_8), y en el decoder accidentalmente haces Base64.getDecoder().decode(secret) — y las claves se vuelven diferentes. A simple vista «el secreto es el mismo», pero en realidad la firma nunca pasará la verificación, y verás un 401 en cada solicitud con token Bearer.
Error n.º 2: el secreto es demasiado corto y obtienes errores extraños «de la nada».
HMAC tiene requisitos sobre el tamaño de la clave, y si el secret es corto, algunas librerías se quejan de manera bastante directa, y otras de forma más enigmática. El principiante suele concluir «Spring Security está roto», cuando en realidad simplemente le pidió al sistema que firmara tokens con una clave de nivel «qwerty». En un proyecto de aprendizaje es mejor adquirir el hábito desde el principio: el secret debe ser largo y almacenarse en una configuración externa.
Error n.º 3: JwtDecoder está escondido en alguna clase helper aleatoria, y al cabo de una semana nadie entiende con qué se valida el token.
La configuración de seguridad debe ser legible. Si el decoder se crea en una utilidad «porque allí ya había código», pierdes el valor principal del camino incorporado: la claridad de la arquitectura. Es mucho mejor mantener JwtDecoder en security/config y conectarlo explícitamente en el DSL mediante decoder(jwtDecoder).
Error n.º 4: activaste el camino JWT incorporado, pero olvidaste desactivar (o eliminar de la cadena) el filtro JWT personalizado.
Como resultado, parte de las solicitudes pasan por tu filtro, parte por la cadena incorporada, y obtienes efectos extraños: a veces authorities «dobles», a veces se sobrescribe el SecurityContext, a veces los errores parecen impredecibles. En un conjunto activo de endpoints debe haber un único dueño de la autenticación Bearer, de lo contrario el proyecto se convierte en una comedia de enredos donde nadie tiene la culpa pero todo arde.
Error n.º 5: esperar que JwtDecoder resuelva la autorización.
El decoder decide «si se puede confiar en el token» y «si se pueden extraer claims de forma segura». No decide «si este usuario puede publicar un borrador». Si después de conectar el decoder el token se acepta, pero obtienes un 403 en las zonas de editor/admin, eso suele significar que los claims no se convierten en las authorities necesarias. Y eso no es un bug del decoder, sino el siguiente paso: configurar el mapeo de claims a authorities.
GO TO FULL VERSION