1. Stateless y Bearer token
Si está acostumbrado al mundo basado en session, allí todo es bastante acogedor: inicia sesión una vez, el servidor guarda el “estado de inicio de sesión” en HttpSession, el navegador almacena JSESSIONID, y luego las solicitudes “de alguna manera” se consideran del mismo usuario. En el modelo stateless, la comodidad termina no porque Spring Security sea perjudicial, sino porque nosotros mismos solicitamos: “servidor, no almacenes el estado de autenticación entre solicitudes”.
De aquí surge una idea simple pero inusual: cada solicitud protegida debe traer su propio contexto de autenticación. Para nuestro curso, ese contexto es el Bearer token, que el cliente coloca en el encabezado HTTP Authorization. Y si la solicitud no trae el token, el servidor literalmente no tiene de qué “recuperar” al usuario actual.
Para no perderse, es útil tener en mente un pequeño esquema:
flowchart TD
C["Client"] -->|Authorization: Bearer ...| API["Secure Content Platform API"]
API --> SC["Security layer / filter chain"]
SC --> MVC["Controllers & Services"]
En el modelo basado en session, entre la API y la capa de seguridad había “memoria” (HttpSession). En el modelo stateless no hay memoria, por lo que la única oportunidad de restaurar el usuario actual es mirar la solicitud y encontrar el token allí.
2. Encabezado Authorization: contrato
El encabezado Authorization suena como si fuera uno solo y “sobre autorización”. En la práctica, es simplemente un punto estándar en HTTP donde el cliente coloca la prueba de que puede representar a este usuario. Y luego su backend decide cómo interpretar esa prueba. Ya vimos HTTP Basic, y allí también estaba presente Authorization, solo que con otro contenido.
Se ve aproximadamente así:
| Modelo | Ejemplo de encabezado | Qué contiene en lenguaje humano |
|---|---|---|
| Basic | Authorization: Basic dXNlcjpwYXNz | base64 de username:password (codificación, no cifrado) |
| Bearer | Authorization: Bearer eyJhbGciOi... | token de acceso (generalmente JWT) que debe ser verificado |
Es importante no confundir los roles: el encabezado Authorization es el “sobre”. Y el “contenido dentro” puede ser Basic, Bearer y docenas de otros esquemas. Por lo tanto, lo primero que debemos aprender a hacer es no tomar cualquier Authorization como JWT, sino verificar: ¿es Bearer o no?
Y sí, un pequeño comentario: el nombre del encabezado en HTTP no distingue mayúsculas, pero en Java normalmente escribimos canónicamente "Authorization". El contenedor Servlet lo entiende bien así.
3. Formato de Authorization: Bearer ...
Ahora llegará el momento “parece obvio, pero es justo aquí donde más se tropiezan”. El token Bearer en HTTP se ve así: el encabezado Authorization contiene una cadena que comienza con el prefijo Bearer (nótese el espacio), y luego va el token mismo. La palabra Bearer aquí no se refiere al “oso”, aunque suene como si nos esperara un zoológico. Bearer literalmente significa “portador”: quien presente el token, será considerado el usuario (después de verificar el token, por supuesto).
Por ejemplo, para GET /api/me nuestro cliente debe enviar:
# -i: mostramos estado/encabezados de respuesta para ver 200/401 en vivo
# -H: enviamos Authorization estrictamente en formato "Bearer {token}"
curl -i http://localhost:8080/api/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Y si no hay token, el cliente simplemente no agrega este encabezado. Y aquí surge la lógica clave: la ausencia del encabezado no es un “token roto”, es simplemente la ausencia de contexto de autenticación. Para un endpoint público, esto es normal. Para un endpoint protegido, esto llevará a 401, pero no porque “el token esté roto”, sino porque “no está presente”.
Para la etapa de extracción, nos basta con una distinción: la ausencia del encabezado Bearer no es aún un “token roto”. Por ahora solo respondemos a la pregunta de si existe alguna cadena de token con la que trabajar; la verificación de la firma, la caducidad y la razón exacta del rechazo viven ya en la etapa de validación.
4. Extracción del token: solo parseo
A veces un principiante (y no solo un principiante) ve JWT y piensa: “¡Oh, allí hay roles! Ahora los leeré y decidiré si dejo pasar o no”. En ese momento, la seguridad se entristece. Porque hasta que se verifique la firma y la caducidad, el token es simplemente una cadena del cliente, más o menos como un cuerpo JSON o un parámetro de consulta. Si no ha verificado la firma, un atacante puede modificar el payload como quiera y “dibujarse” a sí mismo ADMIN.
Por lo tanto, dividimos conscientemente el proceso en etapas. Hoy, la primera etapa, la más “tonta” pero extremadamente importante: extraer la cadena del token de la solicitud HTTP. En esta etapa, simplemente analizamos el encabezado, verificamos cuidadosamente el prefijo Bearer , devolvemos la cadena del token o null, y listo. No construimos Authentication, no creamos principal, no verificamos authorities, no corremos a la base de datos.
Esta disciplina también es útil porque en el código aparecen “puntos de parada” legibles: cuando algo no funciona, comprende rápidamente si el problema es que no se pasó el token, o que el token se pasó pero no pasa la validación.
5. Límites del filtro JWT por URL
El filtro es algo poderoso: puede ejecutarse en cada solicitud. Y si usa ese poder “como salga”, rápidamente se creará un mini-infierno. Por ejemplo, de repente comenzará a exigir token en /api/auth/login o /api/auth/register (donde se emite el token o donde apenas se crea el usuario), o intentará parsear el Bearer token en /api/public/articles, donde no es necesario.
En nuestro proyecto Secure Content Platform API, es conveniente resumir esto en varias zonas:
| Zona | URL | Qué significa |
|---|---|---|
| Pública | /api/public/** | los anónimos pueden leer artículos |
| Zona Auth | /api/auth/** | login, registro y otros endpoints de autenticación de entrada |
| Protegida | /api/me/**, , , |
aquí se necesita autenticación bearer |
| Fuera de API | no /api/** | el filtro JWT no entra allí |
Para esta parte, basta una regla simple: el filtro no debe “interferir” con los endpoints públicos y de autenticación. Incluso si técnicamente pudiera ejecutarse allí, será más sencillo cuando el filtro solo funcione donde realmente se necesita bearer.
La verificación más simple de zona puede verse así:
import jakarta.servlet.http.HttpServletRequest;
private boolean isProtectedApi(HttpServletRequest request) {
// Tomamos la URI sin dominio ni parámetros de consulta (ej. "/api/me")
String uri = request.getRequestURI();
// Idea simple: la autenticación bearer solo es necesaria dentro de /api/...
// y no se necesita en zonas public/auth
return uri.startsWith("/api/")
&& !uri.startsWith("/api/public/") // public: token no obligatorio
&& !uri.startsWith("/api/auth/"); // auth: aquí a menudo el token aún no existe (login/registro)
}
Sí, no es una implementación “ideal” final. Pero de esta forma los límites se ven de inmediato, y eso es lo más importante aquí. Esto ya es suficiente para mantener el principio principal: el filtro JWT no es “para todo”, sino para la API protegida.
6. BearerTokenResolver.resolve(...)
El código más simple que extrae el Bearer token se ve casi como un “if de dos condiciones”. Y ahí está su fuerza: es comprensible, difícil de romper y no pretende “haber previsto todo”. En un proyecto real, a menudo comenzará con un código así y luego lo mejorará para casos extremos (espacios extra, mayúsculas, clientes extraños).
En el contexto de nuestra aplicación, es conveniente colocar esta función en el paquete security/jwt, para que luego el filtro JWT pueda simplemente llamarla. Por ejemplo, crearemos una pequeña clase de utilidad.
package com.example.securecontent.security.jwt;
import jakarta.servlet.http.HttpServletRequest;
public final class BearerTokenResolver {
// Prefijo estrictamente con espacio: "Bearer " + token
private static final String PREFIX = "Bearer ";
private BearerTokenResolver() {
// Clase de utilidad: no creamos instancias
}
public static String resolve(HttpServletRequest request) {
// Obtenemos el encabezado Authorization (si no está, devuelve null)
String header = request.getHeader("Authorization");
// Importante: Authorization puede no ser solo Bearer (ej. Basic)
if (header == null || !header.startsWith(PREFIX)) {
// "No hay token" es una situación normal, no una excepción
return null;
}
// Recortamos solo el prefijo, obtenemos la cadena "cruda" del token
return header.substring(PREFIX.length());
}
}
Aquí hay varias cosas importantes que a menudo no son obvias para un principiante. Devolvemos null, no lanzamos una excepción, porque “el token está ausente” es una situación normal para solicitudes públicas y, en general, para escenarios anónimos. Verificamos con startsWith("Bearer "), porque Authorization puede ser Basic, y nosotros necesitamos exactamente Bearer. Y finalmente, cortamos el prefijo por longitud, no hacemos split(" ") — así hay menos sorpresas con “espacios de más”.
Si se quiere “un poco más preciso” (por ejemplo, eliminar espacios aleatorios en los bordes), se puede agregar trim():
// trim(): eliminamos espacios en los bordes si el cliente envió "Bearer token "
String token = header.substring(PREFIX.length()).trim();
// isBlank(): nos protegemos del caso en que después de "Bearer " no hay nada
return token.isBlank() ? null : token;
Pero aquí es mejor comenzar con minimalismo. La complejización debe agregarse solo cuando realmente sea necesario.
7. Si no hay token: salida temprana
Cuando empieza a escribir código de seguridad, la mano se estira: “¡no hay token, entonces inmediatamente 401!”. Y aquí recordamos que tenemos endpoints públicos. Si exigimos token en todas partes, romperemos /api/public/articles y, con él, la idea de “zona pública”.
La lógica correcta en un flujo JWT stateless suele ser: si no se encuentra el token, no autenticamos al usuario, pero tampoco rompemos la solicitud. Simplemente la dejamos pasar. Y luego las reglas de acceso (authorizeHttpRequests) decidirán si se necesita authenticated() o permitAll(). Si se necesita, Spring Security mismo devolverá 401 a través de su AuthenticationEntryPoint (que hicimos en el bloque REST-friendly).
Incluso sin un filtro completo, se puede ver el “núcleo” de este comportamiento:
// Intentamos extraer el Bearer token de la solicitud
String token = BearerTokenResolver.resolve(request);
if (token == null) {
// No hay token: NO autenticamos, NO prohibimos, solo continuamos la cadena
filterChain.doFilter(request, response);
return;
}
// Si token no es null, entonces comenzará la validación y construcción de Authentication
Nótese el momento psicológico: no estamos tomando aquí una “decisión de acceso”. Estamos tomando la decisión de “si hay datos para intentar restaurar al usuario”. Esto reduce enormemente la posibilidad de cometer un error típico: convertir el filtro JWT en una mezcla de autenticación, autorización y lógica de negocio.
Para sentir esto con las manos, es útil imaginar dos solicitudes:
Endpoint público sin encabezado:
# Zona pública: sin token es normal
curl -i http://localhost:8080/api/public/articles
# esperamos: 200 OK (sin token es normal)
Endpoint protegido sin encabezado:
# Zona protegida: sin token, el usuario permanece como anónimo
curl -i http://localhost:8080/api/me
# esperamos: 401 Unauthorized (usuario no autenticado)
Y aquí es muy importante: la extracción JWT debe comportarse igual en ambos casos — “no hay Bearer token → no autenticamos → seguimos adelante”. La diferencia entre 200 y 401 la harán las reglas de acceso y el manejo del caso no autenticado, no un “pánico manual” dentro del filtro.
8. Errores típicos al trabajar con Authorization: Bearer ...
Error nº 1: parsear cualquier Authorization como Bearer token.
Esto es un clásico después de un día de HTTP Basic. Ayer enviaba Authorization: Basic ..., hoy Authorization: Bearer ..., y mañana alguien traerá Authorization: Digest ... o algún formato personalizado. Por lo tanto, la primera condición de extracción es verificar el prefijo Bearer . Si “se traga” cualquier Authorization, garantizado obtendrá errores extraños y una depuración muy divertida.
Error nº 2: considerar la ausencia del encabezado como “token inválido”.
La ausencia de Authorization es simplemente un escenario anónimo. Debe funcionar tranquilamente en la zona pública. Y en la zona protegida debe llevar a 401, pero eso no es un problema de “token inválido”, sino un problema de “falta de autenticación”. Si mezcla estos casos, perderá una claridad diagnóstica muy importante: ¿qué se rompió exactamente en el cliente? ¿Olvidó agregar el encabezado o agregó un token incorrecto?
Error nº 3: extraer el token en el controlador (@RequestHeader) y vivir “mientras funcione”.
Esto parece conveniente: “bueno, puedo obtener @RequestHeader("Authorization") en un @GetMapping”. Pero así empieza a dispersar la capa de seguridad por toda la aplicación. En algún lugar lo olvidará, en algún lugar lo hará diferente, en algún lugar verificará roles manualmente… y de repente resultará que no tiene Spring Security, sino un “Spring Insecurity casero”. En el modelo stateless, el lugar de la extracción del token está en la capa de seguridad, antes de los controladores.
Error nº 4: hacer la extracción JWT global para todas las URL sin zona de responsabilidad.
Técnicamente, no siempre es “fatal”, pero casi siempre empeora la vida: el filtro comienza a tocar endpoints de autenticación, recursos públicos, algún /error, etc. Se vuelve difícil entender por qué de repente “todo da 401”. Incluso una función simple como isProtectedApi(...) ya da orden en la cabeza: el filtro funciona donde esperamos autenticación bearer.
Error nº 5: confiar en el token inmediatamente después de extraerlo y leer los claims.
Si después de substring(7) se pone a extraer roles y tomar decisiones de “dejar pasar/no dejar pasar”, se ha saltado la idea principal de JWT: el payload se puede leer, pero no se puede confiar hasta que se verifique la firma y la caducidad. La disciplina “extraer → luego validar” no es un rigor académico, sino una protección real contra vulnerabilidades graves.
GO TO FULL VERSION