1. Introducción
El problema ya es evidente: un endpoint REST sin autenticación no debería enviar al cliente a /login ni alimentarlo con HTML. Y ya hemos diferenciado entre 401 y 403: ahora nos interesa solo la primera rama, para la solicitud actual no hay autenticación exitosa.
Por lo tanto, no necesitamos un if en el controlador, sino un componente de la capa de seguridad que actúe aún en la cadena de filtros y devuelva un 401 normal en JSON. Ahora montaremos la primera versión funcional de esta rama: sin extraer código común, pero con una mecánica lo más transparente posible.
2. Modelo mental: AuthenticationEntryPoint
AuthenticationEntryPoint es el punto al que Spring Security llega cuando la solicitud se topa precisamente con la falta de autenticación. No verifica roles ni decide sobre 403; su tarea es más simple: dar una respuesta a la pregunta “¿quién eres?” de manera que el cliente entienda que primero debe autenticarse.
En pocas palabras, la relación aquí es la siguiente: la capa de seguridad detecta una solicitud anónima a un endpoint protegido, ExceptionTranslationFilter intercepta esta situación e invoca al entry point. Para 403 existe una rama separada a través de AccessDeniedHandler; aquí nos centramos solo en la rama 401.
Mini‑esquema de la invocación de AuthenticationEntryPoint
Para no preguntarse “¿quién lo invoca realmente?”, es útil tener una imagen. A nivel de aplicación (sin profundizar en todo el catálogo de filtros) se vería aproximadamente así:
flowchart TD
A[HTTP request: GET /api/me] --> B[Security Filter Chain]
B --> C["Verificación de acceso: el endpoint requiere authenticated()"]
C -->|usuario anonymous| D[ExceptionTranslationFilter]
D --> E["AuthenticationEntryPoint.commence(...)"]
E --> F[HTTP 401 + JSON body]
No necesitamos conocer todos los detalles internos de ExceptionTranslationFilter a nivel de código fuente, pero la idea clave es esta: hay un filtro que intercepta las excepciones de seguridad y decide cómo responder. Si el problema es la falta de autenticación, invoca al entry point.
3. Implementación: RestAuthenticationEntryPoint
Ahora daremos un paso muy práctico: escribiremos un componente que forma el 401 y el cuerpo de la respuesta en JSON. Nuestro objetivo es que cualquier cliente de la API (Postman, curl, aplicación móvil, otro servicio backend) pueda basarse en tres cosas: el estado correcto, Content-Type: application/json y un cuerpo predecible.
Tomamos la forma más directa y funcional: un DTO separado y la escritura de JSON directamente dentro de commence(...). Esto es suficiente para ver la mecánica de la rama 401 sin abstracciones innecesarias.
Mini‑DTO para el cuerpo de la respuesta
Empecemos con algo simple: necesitamos un objeto que Jackson pueda serializar a JSON. Por ahora es un DTO local específico para la respuesta 401, por lo que un record en Java 25 encaja perfectamente: breve, claro, sin extenderse en toneladas de getters.
package com.example.securecontent.security.handler;
// DTO local para la primera versión funcional de la respuesta 401
public record ApiSecurityError(
int status, // Estado HTTP (por ejemplo, 401)
String error, // Código de error corto (por ejemplo, UNAUTHORIZED)
String message,// Mensaje legible para humanos (sin detalles innecesarios)
String path // Ruta de la solicitud donde ocurrió el error
) {
}
Sí, los campos aquí son mínimos: estado, código de error corto, mensaje y ruta. Esto es suficiente para que el cliente “entienda lo que sucedió”, sin ensamblaje manual de JSON y sin detalles superfluos.
Implementación de AuthenticationEntryPoint
Ahora escribimos el componente. El cuerpo de la respuesta también lo ensamblamos directamente aquí: así se ve claramente qué hace exactamente commence(...). Inyectaremos ObjectMapper de Spring Boot, es conveniente porque ya está configurado (módulos, fechas, ajustes de serialización) y no creamos un mapper “externo” que se comporte diferente en otros lugares del proyecto.
package com.example.securecontent.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
// Primera versión funcional: en lugar de HTML/redirecciones devolvemos JSON con 401
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
// Tomamos ObjectMapper del contexto de Spring Boot para que la serialización sea uniforme en todo el proyecto
this.objectMapper = objectMapper;
}
}
Por ahora es solo un “esqueleto”: Spring verá @Component, creará un bean y podrá usarlo en la configuración.
Ahora añadimos el método commence(...). Es este método el que formará la respuesta.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import java.io.IOException;
Y la implementación:
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex) throws IOException {
// 1) Establecemos explícitamente el estado HTTP: no hay autenticación -> 401
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 2) Indicamos explícitamente al cliente que el cuerpo es JSON (de lo contrario, algunos clientes “adivinan” incorrectamente)
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 3) Formamos un cuerpo de respuesta predecible (sin revelar detalles internos)
var body = new ApiSecurityError(
401,
"UNAUTHORIZED",
"Authentication is required",
request.getRequestURI()
);
// 4) Serializamos el objeto a JSON a través de ObjectMapper (no manualmente con una cadena)
objectMapper.writeValue(response.getOutputStream(), body);
}
Observa tres cosas.
Primero, establecemos manualmente el estado 401. No “lanzamos la excepción más arriba”, no “esperamos que Spring lo adivine”. Somos los dueños del contrato y respondemos.
Segundo, establecemos explícitamente el Content-Type. Si olvidamos hacerlo, el cliente podría intentar interpretar la respuesta como texto/HTML/cualquier cosa, y obtendríamos un comportamiento extraño en el lado de la UI o la integración.
Tercero, escribimos JSON a través de ObjectMapper, no mediante response.getWriter().println("{...}"). Esto último funciona… hasta el primer carácter especial, hasta el primer requisito de escape, hasta la primera localización y hasta el primer cambio de estructura. Escribir JSON manualmente es como construir un avión con cerillas: sorprende que vuele, pero es mejor no hacerlo.
4. Conexión en SecurityFilterChain
Uno de los momentos más gratificantes de Spring Security es cuando te das cuenta de que gran parte de la “magia” se conecta con una sola línea, solo hay que escribirla en el lugar correcto. El entry point se configura a través de exceptionHandling en la configuración de HttpSecurity.
Hagamos esto en nuestra configuración de seguridad. Supongamos que tenemos una clase SecurityConfig en el paquete com.example.securecontent.security.config.
Esta es la primera compilación funcional: el 401 ya se controla de forma centralizada en la capa de seguridad, sin redirecciones y sin intentar arreglar todo en el controlador.
package com.example.securecontent.security.config;
import com.example.securecontent.security.handler.RestAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
RestAuthenticationEntryPoint entryPoint) throws Exception {
// Importante: este método @Bean debe estar dentro de una clase de configuración (por ejemplo, @Configuration)
// Conectamos la primera rama funcional 401: cuando falte autenticación, obtendremos nuestro JSON, no una redirección/HTML
http.exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint));
return http.build();
}
Este fragmento por sí solo aún no dice qué endpoints están protegidos. Pero fija algo importante: cuando Spring Security decida que se necesita autenticación, no redirigirá a la página de inicio de sesión (si no has dejado el entry point por defecto), sino que invocará nuestro entry point y devolveremos JSON.
Para mostrar “todo junto” de forma un poco más realista, añadamos reglas mínimas de acceso.
http.authorizeHttpRequests(auth -> auth
// Endpoints públicos: accesibles sin autenticación
.requestMatchers("/api/public/**", "/api/auth/register").permitAll()
// Todo lo demás: solo para usuarios autenticados
.anyRequest().authenticated()
);
Y junto con el entry point:
http
.authorizeHttpRequests(auth -> auth
// Endpoints públicos: accesibles sin autenticación
.requestMatchers("/api/public/**", "/api/auth/register").permitAll()
// Todo lo demás: solo para usuarios autenticados
.anyRequest().authenticated()
)
// Si no hay autenticación, devolvemos 401 en JSON (a través de nuestro EntryPoint)
.exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint));
Observa que aquí no discutimos si tienes habilitado formLogin o httpBasic. Ese es un tema aparte (y ya has tenido lecciones sobre form login y HTTP Basic). AuthenticationEntryPoint se encarga específicamente de la reacción cuando no hay autenticación, independientemente de cómo planees hacerla en principio.
5. Verificación en Secure Content Platform API
Cualquier configuración de seguridad sin verificación es como un cinturón de seguridad que “está aproximadamente por algún lado en el coche”. Técnicamente existe, pero solo ayuda en el mundo de las fantasías. Por lo tanto, verifiquemos los “síntomas”: cómo se comporta /api/me cuando el usuario no está autenticado.
Supongamos que tenemos un controlador:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class MeController {
@GetMapping("/api/me")
public Map<String, String> me() {
// Este endpoint es privado: al no haber autenticación, debería activarse el EntryPoint y devolver 401 JSON
return Map.of("message", "private zone");
}
}
Ahora comprobamos sin autenticación:
# Solicitud sin token/sesión: esperamos 401 desde la capa de seguridad
curl -i http://localhost:8080/api/me
Esperamos algo como esto (simplificado):
HTTP/1.1 401
Content-Type: application/json
...
{"status":401,"error":"UNAUTHORIZED","message":"Authentication is required","path":"/api/me"}
Y esto ya es “REST‑friendly”: el cliente ve 401, ve JSON, puede procesarlo fácilmente y mostrar al usuario “inicie sesión”, o iniciar el proceso de login, o simplemente decir honestamente “sin acceso”.
Una disciplina pequeña pero importante
Incluso si crees que “es obvio por qué 401”, el campo path facilita mucho la vida durante la depuración, especialmente cuando tienes una colección de 40 solicitudes en Postman, algunas parametrizadas, y accidentalmente golpeas el lugar equivocado.
6. Límites de responsabilidad de AuthenticationEntryPoint
Es muy fácil empezar a esperar del entry point que haga de todo: “que también arregle el login, los errores de autorización, la validación, y que además prepare café”. Pero el modelo mental correcto aquí ahorra tiempo y nervios.
AuthenticationEntryPoint se encarga de la reacción ante el problema de “no hay autenticación”. Es el lugar donde debes hacer predecible la respuesta de la API, pero no debes convertirlo en un manejador de errores de negocio. No debe acceder a la base de datos, recopilar traceId complejos, arrastrar todos los campos del usuario y contarle al cliente la política de seguridad interna.
Además, el entry point no resuelve el problema del 403. Si el usuario ya está autenticado e intenta acceder a /api/admin/**, el entry point no debe “fingir que es un 401”. Son escenarios fundamentalmente diferentes. Y precisamente el siguiente paso del día es un manejador separado para los escenarios de forbidden.
Otro límite son los “errores demasiado detallados”. Si en el message empiezas a escribir “User is disabled” o “Account is locked”, puedes convertir sin querer tu API en un servicio de información para el atacante: probará logins y sabrá qué cuentas existen y en qué estado están. Incluso en un proyecto de aprendizaje es útil acostumbrarse desde el principio a una redacción neutral.
7. Errores típicos al configurar AuthenticationEntryPoint
Error nº1: devolver 403 desde el entry point “porque el acceso está denegado”.
La lógica es comprensible desde el punto de vista humano, pero incorrecta según el significado del protocolo: 403 significa que el usuario es conocido (autenticado), pero no tiene permiso. Si devuelves 403 a un usuario anónimo, el cliente empieza a pensar “he iniciado sesión, pero no es aquí”, cuando en realidad no ha iniciado sesión en absoluto.
Error nº2: olvidar el Content-Type y obtener “un JSON que no es JSON”.
Sin Content-Type: application/json, muchos clientes se comportarán de forma extraña: algunos mostrarán el cuerpo como texto plano, otros intentarán parsearlo como HTML, y otros (especialmente en SDK móviles) simplemente no encontrarán el manejador adecuado. En los contratos de API, los detalles no son detalles menores.
Error nº3: escribir JSON manualmente como cadena.
Hoy escribiste "{\"error\":\"UNAUTHORIZED\"}", mañana añades una comilla en el texto, pasado mañana llega una localización con un carácter de salto de línea, y de repente estás devolviendo un JSON no válido al cliente. ObjectMapper no existe solo por estética.
Error nº4: revelar demasiada información interna.
AuthenticationException internamente puede contener detalles que son útiles para ti en los logs, pero no para el exterior. No devuelvas ex.getMessage() directamente al cliente. Esta es una práctica “cómoda” frecuente que luego se convierte en una vulnerabilidad desagradable y en un contrato inestable.
Error nº5: intentar resolver el problema en el controlador mediante try/catch.
Si la solicitud no pasó los filtros de seguridad, tu controlador no la verá. Por lo tanto, el manejo de “no hay autenticación” debe vivir exactamente donde ocurre: en la capa de seguridad, a través de AuthenticationEntryPoint.
GO TO FULL VERSION