1. JWT después de la extracción — sigue siendo “solo una cadena”
Cuando ya encontramos el token en el encabezado y lo llevamos al filtro JWT, es fácil caer en la peligrosa sensación: “Bueno, ya encontramos el token, entonces el usuario está autenticado”. Esta sensación es más o menos como creer en un papel con la inscripción “Soy admin”. El papel puede ser bonito, plastificado, incluso con un sello… pero mientras no verifiquemos quién lo emitió y si no se ha deteriorado en el camino, no se puede confiar en él.
JWT (en nuestro caso un token firmado JWS) está diseñado especialmente para que su payload sea fácil de leer. Esto es normal: no se trata de secreto, sino de integridad. Si simplemente “analizas el payload”, obtienes datos que cualquiera podría haber escrito. La magia real de JWT comienza justo en el momento en que verificas la firma y te aseguras de que el token fue realmente emitido por tu servidor y no ha sido modificado.
Imaginemos un mal escenario. Un atacante toma un token real, cambia en el payload la lista de permisos a ["user:manage", "draft:publish"] y lo envía. Si tu filtro “ingenuamente” confía en los claims sin verificar la firma, tu aplicación se convierte en “por favor, toma el panel de administración, estaba sobre la mesa”.
Por lo tanto, la disciplina es la siguiente: primero hacemos validation (confianza), y solo después — extraction (extracción de datos). Se puede extraer sin confianza, pero no se puede usar.
Mini-recordatorio (para no confundirse): la simple decodificación Base64 no es seguridad. Es como abrir una maleta y ver qué hay dentro. Sin verificar la firma, no sabes si han metido un ladrillo en lugar de un portátil.
2. Validación mínima de JWT para Secure Content Platform API
Cuando se dice “validar JWT”, los novatos a veces se imaginan un ritual criptográfico con pandereta. En la práctica, en nuestro curso es suficiente entender un conjunto simple de comprobaciones obligatorias: estructura, firma, tiempo de vida y claims mínimos necesarios. Esto ya da una base sólida sin convertir la lección en un tratado criptográfico.
Primero nos aseguramos de que el token se parezca a un JWT: tiene tres partes header.payload.signature. Luego verificamos la firma (para nosotros esto significa: “firmado con nuestro secreto/clave, por lo tanto emitido por nosotros y no modificado”). Después comprobamos exp (tiempo de vida). Y solo entonces leemos los claims útiles: a quién estamos autenticando (username/sub, userId) y qué permisos otorgarle (authorities).
Es útil mantener esta lógica en la cabeza como una “lista de verificación”:
| Verificación | Para qué sirve | Qué ocurre si se omite |
|---|---|---|
| Estructura a.b.c | Para no intentar parsear basura | malformed token, errores de análisis |
| Firma | Para no aceptar un payload falso | Escalada de permisos “de papel” |
| exp (expiration) | Para que los tokens no vivan para siempre | Un token filtrado funciona infinitamente |
| Claims obligatorios (username, userId) | Para construir un principal significativo | Usuario “autenticado” sin identidad |
| Authorities/roles | Para que funcionen las reglas de acceso | En todas partes será 403 o, por el contrario, “todo está permitido” |
Y una aclaración muy importante para la arquitectura de nuestro curso: en este paso no verificamos la contraseña. La contraseña se verificó en la etapa POST /api/auth/login, cuando se emitió el token. Ahora solo restauramos “quién es” y “qué permisos tiene” para la solicitud actual.
3. TokenService: validate + extract sin duplicación
Si intentas hacer en el filtro 15 líneas de split, Base64, JsonNode y “bueno, funciona”, en tres días empezarás a hacerte una pregunta filosófica: “¿Quién escribió este código y por qué me odia?”. Por eso mantenemos la disciplina: tenemos un TokenService, y es él quien se encarga de dos cosas: validar el token y extraer lo necesario.
Es importante no caer en el estilo de “servicio con 30 métodos extractXxx”, cada uno de los cuales parsea el token a su manera. En primer lugar, es duplicación de trabajo. En segundo lugar, es un riesgo: un método “verificó exp”, otro “no lo verificó”, un tercero “olvidó la firma”. Y obtienes la categoría más terrible de errores: a veces seguro, a veces no.
Es mejor hacerlo así: TokenService parsea el token una vez, verifica la confianza y devuelve un resultado normalizado. Por ejemplo, un record separado ValidatedToken.
package com.example.securecontent.security.jwt;
import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;
// Resultado en el que ya se puede confiar: token verificado (firma/exp/claims),
// y extraemos solo lo necesario para Spring Security.
public record ValidatedToken(CurrentUserPrincipal principal, List<String> authorities) {
}
Ahora la validación y la extracción están “pegadas” en una sola operación. Un contrato aproximado:
package com.example.securecontent.security.jwt;
public interface TokenService {
// Una entrada — una salida: en este paso validamos y extraemos datos.
// Importante: el método no debe devolver el payload "crudo" sin verificar la firma.
ValidatedToken parseAndValidate(String token);
}
Y aquí hay un fragmento de la lógica interna de TokenService (simplificado). Aquí lo importante no es la biblioteca, sino la idea: primero la firma y exp, luego los claims.
En este paso es conveniente distinguir de inmediato la categoría del problema, no solo el texto del mensaje: el filtro podrá reaccionar de manera predecible, sin adivinar por la cadena de error. Aquí la estructura dañada, la firma no válida y otros “este token no es confiable” es conveniente ponerlos en una misma cesta TokenProblem.MALFORMED; por separado solo necesitamos EXPIRED.
import java.time.Clock;
import java.time.Instant;
private void validateExpiration(Instant exp, Clock clock) {
// Tomamos la hora actual de Clock para que sea fácil de probar.
Instant now = clock.instant();
// Si exp es anterior a now — el token ha caducado y no debemos confiar en sus claims.
if (exp.isBefore(now)) {
throw new TokenValidationException(TokenProblem.EXPIRED, "Token expired");
}
}
Si usas una biblioteca que verifica la firma, se verá algo así (de nuevo, no como dogma, sino como referencia):
import com.nimbusds.jose.crypto.MACVerifier;
import com.nimbusds.jwt.SignedJWT;
// Parseamos JWS (JWT firmado) a partir de la cadena.
SignedJWT jwt = SignedJWT.parse(token);
// Verificamos criptográficamente la firma (no "miramos el header").
boolean signatureOk = jwt.verify(new MACVerifier(secret));
if (!signatureOk) {
// Si la firma no coincide — el token es falso o fue modificado en el camino.
throw new TokenValidationException(TokenProblem.MALFORMED, "Bad signature");
}
Una vez más: a nivel del curso es suficiente entender que “verificar la firma” no es “mirar el header”, sino realizar una verificación criptográfica a través de una biblioteca.
4. Principal a partir de claims: CurrentUserPrincipal
En JWT existe la tentación de meter todo: perfil, avatar, color favorito de botones. Pero nuestro proyecto no es una red social con esteroides, sino un backend educativo que aprende a hacer seguridad con cuidado. Por eso mantenemos el principal compacto. En la solicitud normalmente necesitamos dos cosas: identificador de usuario (para reglas basadas en el propietario y acceso a BD) y login/username (para logs y algunos escenarios).
El formato ideal para tal principal en Java es un record normal. Es pequeño, inmutable y no intenta vivir su propia vida.
package com.example.securecontent.security.auth;
// Representación mínima de seguridad del usuario para la solicitud actual.
// Importante: NO es una entidad JPA ni un "perfil completo", sino solo lo necesario en seguridad.
public record CurrentUserPrincipal(Long userId, String username) {
}
¿Por qué es importante? Porque principal es la “representación de seguridad” del usuario, no “nuestro UserAccount JPA en todo su esplendor”. Si empiezas a arrastrar entidades vinculadas a la BD en el principal, rápidamente tendrás problemas: campos perezosos, serializaciones accidentales, dependencias de la capa de seguridad con la capa de persistence. Y luego alguien querrá registrar el principal — y de repente verás medio perfil en los logs. No hagas eso.
Además, no olvides: el payload de JWT por definición no es secreto. Se lee. Por lo tanto, nada de email, teléfonos ni “apellido de soltera de la madre” en el principal del token.
Mini‑ejemplo de cómo construimos el principal después de una validación exitosa:
import com.example.securecontent.security.auth.CurrentUserPrincipal;
// Extraemos solo después de verificar firma y exp con éxito.
Long userId = tokenClaims.userId();
String username = tokenClaims.username();
// Construimos un principal compacto que vivirá en Authentication/SecurityContext.
CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);
Donde tokenClaims es tu resultado normalizado interno del análisis del token. Podemos almacenarlo como record, como map — no es relevante. Lo relevante es que en este momento el token ya ha sido verificado.
5. Permisos en cadena → GrantedAuthority
En nuestro proyecto, los permisos se expresan como cadenas del tipo draft:publish, user:manage, profile:write. En el código se ve bonito y legible. Pero Spring Security internamente no almacena “solo cadenas”. Almacena una colección de objetos GrantedAuthority. Es una interfaz con un método getAuthority(), y Spring la usa como abstracción sobre los “permisos”.
El adaptador más simple de cadena a GrantedAuthority es SimpleGrantedAuthority. Tomamos la cadena y la envolvemos.
import java.util.List;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// Convertimos las cadenas del token al tipo que entiende Spring Security.
List<SimpleGrantedAuthority> grantedAuthorities = authoritiesFromToken.stream()
.map(SimpleGrantedAuthority::new) // "draft:publish" -> new SimpleGrantedAuthority("draft:publish")
.toList();
Aquí es crítico que las cadenas de authority en el token coincidan con lo que esperan tus reglas. Si en SecurityFilterChain escribes .hasAuthority("user:manage"), y en el token por error está "USER_MANAGE" o "manage:user", entonces tendrás un eterno “¿por qué tengo 403 si soy admin?”.
Un detalle aparte: los roles y el prefijo ROLE_. Recordemos: hasRole("ADMIN") en realidad verifica la authority "ROLE_ADMIN". Si quieres seguir usando hasRole, entonces en el token debes poner exactamente "ROLE_ADMIN", no simplemente "ADMIN". En el contexto de nuestro curso, es más conveniente mantener un solo estilo: o pones en el token valores ya normalizados (ROLE_ADMIN, draft:publish), o haces la conversión en un solo lugar. Lo importante es que sea consistente, no “ayer era así, hoy es asá”.
6. Authentication desde JWT: construcción y significado
Ahora tenemos dos cosas: principal (quién es el usuario) y grantedAuthorities (qué permisos tiene). Solo falta construir el objeto Authentication. Es importante entender la idea: Authentication es el contenedor estándar de Spring Security para el resultado de la autenticación. No significa necesariamente “verificamos la contraseña ahora mismo”. Significa “para la solicitud actual tenemos un usuario autenticado”.
La clase más conveniente para nuestro escenario es UsernamePasswordAuthenticationToken. Su nombre puede ser engañoso (“¿por qué username/password si tenemos JWT?”), pero en la práctica es simplemente una implementación lista de Authentication que es fácil de usar. La contraseña (credentials) no la necesitamos en este momento, por lo que será null. Y sí, eso es normal.
import com.example.securecontent.security.auth.CurrentUserPrincipal;
import java.util.List;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
CurrentUserPrincipal principal = new CurrentUserPrincipal(userId, username);
// Convertimos los permisos del token al formato que espera Spring Security.
List<SimpleGrantedAuthority> granted = authorities.stream()
.map(SimpleGrantedAuthority::new)
.toList();
// credentials = null, porque aquí no verificamos contraseña/secreto (es un escenario JWT).
// Importante: el constructor (principal, credentials, authorities) crea un objeto authenticated.
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, granted);
Observa un detalle importante: el constructor con tres argumentos (principal, credentials, authorities) crea un objeto que se considera authenticated. Esto es lo que necesitamos: no un “intento de autenticación”, sino una “autenticación restaurada”.
Y ahora tenemos un objeto estándar que “entiende” todo el resto de Spring Security. Podrán usarlo:
— las reglas a nivel de solicitud (.anyRequest().authenticated(), .hasAuthority(...));
— method security (@PreAuthorize("hasAuthority('draft:publish')"));
— los puntos de acceso al usuario actual a través de SecurityContext.
Diagrama del flujo: token → Authentication
Ahora hemos logrado un resultado intermedio importante: sabemos tomar una cadena de token y obtener un Authentication a partir de ella. Es como reunir un pasaporte y un pase en la entrada: “aquí está quién soy” y “aquí a dónde puedo ir”. Pero todavía no hemos “entregado esta información a la seguridad”, es decir, no la hemos puesto en el SecurityContext.
Para que todo esté lo más claro posible en la cabeza, es útil mantener un esquema. Así se ve nuestra parte del pipeline:
flowchart TD
A["HTTP request Authorization: Bearer token"] --> B["resolveBearerToken() obtenemos la cadena token"]
B --> C["tokenService.parseAndValidate(token) firma + exp + claims"]
C --> D["ValidatedToken principal + authorities"]
D --> E["map -> SimpleGrantedAuthority"]
E --> F["new UsernamePasswordAuthenticationToken(...) obtenemos Authentication"]
Si puedes reproducir este flujo en palabras, entonces, básicamente, ya entiendes la autenticación stateless. Solo queda integrar este resultado en el ciclo de vida de la solicitud para que lo vean las URL-rules, method security y @AuthenticationPrincipal.
7. Límites de responsabilidad en la etapa de validación
En esta etapa es muy fácil comenzar a “mejorar” la solución hasta un estado de “nada funciona”. Por eso es útil hablar explícitamente de los límites. La verificación de JWT y la construcción de Authentication es un paso infraestructural que debe ser rápido, predecible y no contener lógica de negocio.
En primer lugar, no debemos hacer aquí comprobaciones de propiedad. “¿Es su borrador o de otro?” es una cuestión de autorización a nivel de operación de negocio y datos del dominio. El token no tiene por qué saber de un draftId concreto, y el filtro no debe convertirse en un mini-servicio de contenido.
En segundo lugar, no debemos ir a la base de datos en cada solicitud “para asegurarnos de que el usuario aún existe”. A veces se hace, pero eso ya es un eje arquitectónico aparte (y a menudo resulta más costoso de lo que parece). Aquí mantenemos un modelo puramente stateless: el token es válido, por lo tanto la solicitud puede considerarse autenticada. Luego, tus reglas y servicios deciden si se puede realizar la operación concreta.
En tercer lugar, no debemos “inflar” el principal hasta el tamaño de un modelo de dominio. El principal tiene un rol simple: ser un identificador y una representación mínima del usuario. Si pones allí una entidad, colecciones de roles, perfil y avatar, empeorarás la seguridad (datos extra en memoria y logs) y empeorarás el mantenimiento.
Y finalmente, no debemos mezclar “validación de token” y “respuesta al cliente”. Si el token es malo, nuestra tarea en la etapa de validación es señalarlo correctamente con una excepción o resultado. La formación de la respuesta JSON 401 y el contrato unificado de errores deben permanecer como lógica separada: de lo contrario, en el filtro se mezclarán JSON, logs, reglas de negocio y un poco de magia.
8. Errores típicos al verificar JWT
Error n.º 1: usar claims antes de verificar la firma.
Esta es la trampa lógica más peligrosa: “veo el username en el payload, entonces es él”. En la práctica, el payload se puede modificar, y la verificación de la firma sirve precisamente para que el payload sea confiable. La secuencia correcta es siempre la misma: primero confianza (firma/exp), luego extracción (username/userId/authorities).
Error n.º 2: verificar solo la estructura del token y olvidar exp.
Un novato a veces verifica cuidadosamente header.payload.signature, incluso la firma, y piensa que todo está bien. Pero si no se verifica el tiempo de vida, el token se convierte en un pase eterno. En un proyecto educativo también es un mal hábito: refuerzas el modelo mental de “token eterno”, y luego en un proyecto real te sorprenderás de por qué la seguridad es tan deficiente.
Error n.º 3: construir Authentication “por partes” en diferentes lugares.
Si en el filtro haces extractUsername, y authorities “en algún lugar después”, y userId “en otro método”, casi con certeza tendrás desincronización. El mejor estilo es un solo método parseAndValidate() que devuelva un ValidatedToken normalizado, y a partir de él se construya el Authentication en un solo lugar.
Error n.º 4: poner en el principal una entidad JPA o un UserAccount completo.
Es muy tentador: “bueno, como igual trabajamos con usuarios, pongamos UserAccount”. Luego empiezan las sorpresas: colecciones perezosas, campos extra, serializaciones accidentales, fugas de datos en logs. El Principal debe ser compacto e independiente de la persistence.
Error n.º 5: confundir hasRole y hasAuthority por el prefijo ROLE_.
Si usas hasRole("ADMIN"), Spring espera "ROLE_ADMIN". Si el token trae "ADMIN", entonces el acceso será denegado y obtendrás un misterioso 403 aunque “en teoría eres admin”. Para no hacer rituales chamánicos, elige un estilo de nomenclatura y mantente fiel a él: o roles en forma de "ROLE_ADMIN", o authorities puras y .hasAuthority(...).
GO TO FULL VERSION