Spring Framework
Fundamentos de Spring Security: filter chain, autenticación y autorización
Intermedio8 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
Spring Security es una cadena de filtros servlet que se configura con un bean SecurityFilterChain. Al terminar esta guía entenderás cómo se autentica y se autoriza una petición, cómo declarar usuarios y codificar bien las contraseñas, cómo proteger URL y métodos con el DSL lambda y @PreAuthorize, qué hace la protección CSRF y cuándo desactivarla, y cómo el mismo modelo se extiende a las API basadas en tokens.
Lo que aprenderás
- Explicar el papel de DelegatingFilterProxy, FilterChainProxy y cada uno de los filtros de seguridad
- Configurar una SecurityFilterChain con el DSL lambda para form login, HTTP Basic y API stateless
- Proporcionar usuarios mediante UserDetailsService y almacenar contraseñas con un DelegatingPasswordEncoder
- Autorizar peticiones por patrón de URL y métodos con @PreAuthorize, distinguiendo roles de authorities
- Describir contra qué protege la protección CSRF y decidir cuándo se puede desactivar
La seguridad es el aspecto que la mayoría de las aplicaciones hace mal, no porque sea difícil añadirla, sino porque es difícil añadirla en todas partes. Spring Security lo resuelve colocándose delante de cada petición como una cadena de filtros servlet y delante de los métodos que elijas como un interceptor AOP, de modo que la autenticación y la autorización se declaran una vez y se aplican de forma coherente. Esta guía cubre Spring Security 6.x con Spring Framework 6 sobre Java 17 o posterior, con el modelo de configuración Java que sustituyó a WebSecurityConfigurerAdapter; OAuth 2.0 y los formatos de token en sí se tratan en la materia de Seguridad.
Arquitectura: la filter chain
El contenedor de servlets solo conoce un filtro: DelegatingFilterProxy, registrado con el nombre springSecurityFilterChain. Existe para que un filtro gestionado por el contenedor pueda delegar en un bean de Spring. Ese bean es FilterChainProxy, que contiene una o varias instancias de SecurityFilterChain, cada una asociada a un patrón de petición. Para cada petición, la primera cadena que coincide ejecuta su lista ordenada de filtros: unos quince en una configuración típica con form login. Los que conviene conocer:
| Filtro | Responsabilidad |
|---|---|
SecurityContextHolderFilter | Carga el SecurityContext (desde la sesión, por defecto) en SecurityContextHolder durante la petición y lo limpia al terminar |
CsrfFilter | Rechaza las peticiones que cambian el estado sin un token CSRF válido |
UsernamePasswordAuthenticationFilter | Procesa el POST del formulario de login |
BasicAuthenticationFilter | Procesa la cabecera Authorization: Basic |
BearerTokenAuthenticationFilter | Procesa Authorization: Bearer en los resource servers |
ExceptionTranslationFilter | Convierte una AuthenticationException en un desafío (redirección al login, 401) y una AccessDeniedException en 403 |
AuthorizationFilter | Aplica las reglas de authorizeHttpRequests: es el último filtro antes de tu aplicación |
La autenticación es una conversación en dos pasos. Un filtro construye a partir de la petición un token Authentication sin autenticar —usuario y contraseña, un bearer token— y se lo entrega a un AuthenticationManager. La implementación estándar, ProviderManager, prueba cada AuthenticationProvider hasta que uno admite el token; DaoAuthenticationProvider carga el usuario a través de un UserDetailsService, comprueba la contraseña con un PasswordEncoder y devuelve una Authentication completa con la lista de GrantedAuthority del usuario. El filtro la guarda en el SecurityContextHolder y, en las aplicaciones con sesión, la persiste en la HttpSession para que las peticiones siguientes no tengan que pasar otra vez por el login.
La autorización llega después, y dos veces: AuthorizationFilter comprueba las reglas de URL y, si la seguridad de métodos está activada, un interceptor AOP comprueba el @PreAuthorize del método invocado. Ambos consultan a un AuthorizationManager con la Authentication actual.
Configurar una SecurityFilterChain
Desde Spring Security 5.7 la configuración es un bean, no una subclase. Con Spring Boot, basta con tener spring-boot-starter-security para que se registre el filtro; en un WAR de Spring MVC puro, además hay que declarar una clase que extienda AbstractSecurityWebApplicationInitializer para que el contenedor registre DelegatingFilterProxy.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/about", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/orders/**").hasAuthority("orders:write")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login") // your own GET /login view; POST /login is handled by the filter
.permitAll())
.logout(logout -> logout.logoutSuccessUrl("/"))
.httpBasic(Customizer.withDefaults()); // also accept Basic, for scripts
return http.build();
}
@Bean
UserDetailsService users(PasswordEncoder encoder) {
var alice = User.withUsername("alice")
.password(encoder.encode("correct horse battery staple"))
.roles("USER", "ADMIN") // stored as ROLE_USER, ROLE_ADMIN
.build();
var bob = User.withUsername("bob")
.password(encoder.encode("bob-password-2026"))
.authorities("ROLE_USER", "orders:write") // fine-grained: a role plus a permission
.build();
return new InMemoryUserDetailsManager(alice, bob);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
El estilo lambda es el único DSL en Spring Security 7; la antigua forma encadenada con .and() quedó deprecated en la 6.1, así que escribe ya la forma lambda. Las reglas de authorizeHttpRequests se evalúan en el orden en que se declaran y gana la primera que coincide, por lo que los patrones específicos deben ir antes de anyRequest(). permitAll sigue haciendo pasar la petición por la cadena —el usuario queda identificado si existe una sesión—, mientras que web.ignoring() se salta la seguridad por completo y debe reservarse para recursos verdaderamente estáticos.
hasRole("ADMIN") comprueba la authority ROLE_ADMIN; hasAuthority("orders:write") comprueba la cadena literal. Los roles son grupos de grano grueso y las authorities, permisos individuales; los sistemas grandes suelen almacenar permisos y derivarlos de los roles en el login.
Usuarios y contraseñas
El gestor en memoria de arriba es para demos y tests. Las aplicaciones en producción implementan UserDetailsService sobre su propio almacén:
@Service
public class AccountUserDetailsService implements UserDetailsService {
private final AccountRepository accounts;
public AccountUserDetailsService(AccountRepository accounts) { this.accounts = accounts; }
@Override
public UserDetails loadUserByUsername(String username) {
Account a = accounts.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException(username)); // message is not shown to the client
return User.withUsername(a.email())
.password(a.passwordHash()) // already encoded, e.g. "{bcrypt}$2a$10$…"
.authorities(a.permissions().stream().map(SimpleGrantedAuthority::new).toList())
.accountLocked(a.locked())
.disabled(!a.active())
.build();
}
}
Para esquemas estándar existen JdbcUserDetailsManager y una variante LDAP. Sea cual sea el almacén, las contraseñas deben hashearse con un algoritmo lento y con sal. PasswordEncoderFactories.createDelegatingPasswordEncoder() devuelve un DelegatingPasswordEncoder que codifica con bcrypt por defecto y antepone a cada hash un identificador —{bcrypt}, {argon2}, {pbkdf2}— para poder cambiar de algoritmo más adelante sin dejar de verificar los hashes antiguos. Nunca guardes texto plano; {noop} existe solo para tests. Como la verificación es lenta a propósito (bcrypt con la fuerza por defecto tarda decenas de milisegundos o más por comprobación), autentica una vez por sesión en lugar de en cada petición, que es justo lo que hace la seguridad basada en sesiones.
Seguridad de métodos
Las reglas de URL protegen los puntos de entrada; la seguridad de métodos protege las operaciones que hay detrás, incluidas las que se alcanzan por otros caminos: un job programado, un listener de mensajes, un segundo controlador. @EnableMethodSecurity (que sustituyó a @EnableGlobalMethodSecurity en la 6.0) activa @PreAuthorize, @PostAuthorize, @PreFilter y @PostFilter con Spring Expression Language:
@Service
public class OrderService {
@PreAuthorize("hasAuthority('orders:write')")
public Order place(CreateOrder cmd) { … }
@PreAuthorize("hasRole('ADMIN') or #customerId == authentication.name")
public List<Order> ordersOf(String customerId) { … }
@PostAuthorize("returnObject.customerId == authentication.name or hasRole('SUPPORT')")
public Order get(long id) { … }
}
Como se trata de AOP, se aplican las reglas habituales de los proxies: la comprobación solo se ejecuta cuando la llamada llega a través del proxy, no en una autoinvocación, y una llamada denegada lanza AccessDeniedException, que ExceptionTranslationFilter convierte en un 403 si llega hasta la capa web. Pon las anotaciones en la capa de servicio, donde se define la operación de negocio, y no en controladores que solo reenvían la llamada.
Protección CSRF
Un ataque de cross-site request forgery engaña al navegador de un usuario con sesión iniciada para que envíe una petición que cambia el estado —un POST de formulario hacia tu sitio desde una página que controla el atacante—, y el navegador adjunta la cookie de sesión automáticamente. Spring Security se defiende exigiendo que toda petición no segura (POST, PUT, PATCH, DELETE) lleve un token que la página del atacante no puede leer: el CsrfFilter compara el token de la petición (un campo de formulario llamado _csrf o la cabecera X-CSRF-TOKEN) con el que se guarda en el servidor. La integración de Thymeleaf con Spring y la etiqueta de formulario de JSP añaden el campo oculto automáticamente; un cliente JavaScript lo lee de la cookie XSRF-TOKEN cuando configuras CookieCsrfTokenRepository.withHttpOnlyFalse().
Spring Security 6 cambió dos valores por defecto: el token se carga de forma diferida (solo cuando una petición lo necesita de verdad) y el valor expuesto a la petición se enmascara en cada petición con XorCsrfTokenRequestAttributeHandler, el nuevo handler por defecto, para resistir ataques del tipo BREACH. Ambos cambios son transparentes para las aplicaciones basadas en formularios y algo más delicados para las single-page apps que leen el token de una cookie; la sección sobre SPA de la guía de referencia muestra la configuración exacta.
La protección solo importa cuando el navegador se autentica automáticamente. Una API autenticada con un bearer token en una cabecera es inmune, y por eso la configuración stateless de abajo la desactiva.
API stateless
Un resource server que valida bearer tokens en cada petición no necesita sesión, ni página de login, ni CSRF:
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // this chain only for the API
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.anyRequest().authenticated())
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) // validates JWTs against the issuer's JWKS
.exceptionHandling(e -> e.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()));
return http.build();
}
Cuando hay varias cadenas, se ordenan con @Order; atiende la petición la primera cuyo securityMatcher coincide, así que la cadena de la API debe ir antes que una cadena web que lo capture todo. oauth2ResourceServer().jwt() necesita los módulos spring-security-oauth2-resource-server y spring-security-oauth2-jose, además de un issuer o una JWK Set URI; los scopes del token se convierten en authorities con el prefijo SCOPE_, que puedes reasignar con un JwtAuthenticationConverter. Para decodificar un token y ver sus claims mientras depuras, usa el decodificador de JWT, pero nunca para confiar en él: eso solo lo hace la verificación de firma del resource server.
Acceder al usuario actual
@GetMapping("/me")
public ProfileResponse me(@AuthenticationPrincipal UserDetails user) {
return profiles.forUsername(user.getUsername());
}
// in a service, without web types
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean admin = auth.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"));
El contexto se guarda en un ThreadLocal mientras dura la petición. El trabajo que se delega a otro thread —@Async, un executor, un parallel stream— no lo ve, a menos que envuelvas el executor con DelegatingSecurityContextExecutor o cambies la estrategia del holder a MODE_INHERITABLETHREADLOCAL.
Errores comunes
Poner anyRequest() primero. Todas las reglas posteriores quedan inalcanzables; Spring Security 6 lanza una excepción al arrancar si hay reglas declaradas después de anyRequest(), pero un patrón amplio /** colocado demasiado pronto tapa en silencio los específicos.
Añadir el prefijo ROLE_ en hasRole. hasRole("ROLE_ADMIN") lanza IllegalArgumentException. Usa hasAuthority si quieres la cadena literal.
Codificar las contraseñas en el login en lugar de en el registro. La verificación usa PasswordEncoder.matches(raw, encoded); nunca compares encode(raw) con el hash guardado: los algoritmos con sal producen un resultado distinto cada vez.
Desactivar CSRF en una aplicación con cookie de sesión «porque molestaba». Acabas de habilitar justo el ataque que el filtro evita. Arregla la integración del formulario o de la SPA.
Revelar si un nombre de usuario existe. Por defecto, UsernameNotFoundException se convierte en un error genérico de credenciales incorrectas (hideUserNotFoundExceptions); no lo deshagas, y muestra en la página de login el mismo mensaje tanto si el usuario no existe como si la contraseña es incorrecta.
Confiar solo en las reglas de URL. Un nuevo mapping de controlador fuera de los patrones protegidos queda abierto por defecto, salvo que anyRequest().authenticated() cierre el hueco; la seguridad de métodos en el servicio atrapa lo que se le escapa a la capa de URL.
Olvidar CORS en la cadena de seguridad. Las peticiones preflight OPTIONS pasan por la filter chain antes de llegar a MVC; llama a http.cors(Customizer.withDefaults()) con un bean CorsConfigurationSource para que se permitan.
Ideas clave
- Spring Security es un
FilterChainProxyde filtros ordenados, registrado medianteDelegatingFilterProxy; los filtros de autenticación rellenanSecurityContextHolderyAuthorizationFilteraplica las reglas de URL. - La configuración se hace con beans
SecurityFilterChainy el DSL lambda;WebSecurityConfigurerAdapterdesapareció en la 6.0. - Los usuarios vienen de un
UserDetailsService; las contraseñas se guardan con unDelegatingPasswordEncoder(bcrypt por defecto) y se verifican conmatches. hasRoleañade el prefijoROLE_yhasAuthorityno;@PreAuthorizeen los servicios protege las operaciones sea cual sea el punto de entrada, con las limitaciones propias de los proxies AOP.- La protección CSRF importa siempre que el navegador se autentica automáticamente; las API stateless con bearer token la desactivan y usan
SessionCreationPolicy.STATELESS. - El contexto de seguridad está ligado al thread; propágalo de forma explícita al trabajo en segundo plano.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué ha pasado con WebSecurityConfigurerAdapter?
- Se marcó como deprecated en Spring Security 5.7 y se eliminó en la 6.0. En lugar de extender una clase base, declaras un bean SecurityFilterChain que recibe un builder HttpSecurity, y un bean UserDetailsService en vez de sobrescribir configure(AuthenticationManagerBuilder). Pueden convivir varias cadenas, cada una asociada a un prefijo de URL mediante securityMatcher.
- ¿Qué diferencia hay entre hasRole y hasAuthority?
- Ambos comparan con las cadenas GrantedAuthority de la Authentication. hasRole('ADMIN') añade automáticamente el prefijo ROLE_, así que comprueba la authority ROLE_ADMIN; hasAuthority('ROLE_ADMIN') comprueba la cadena literal. Usa hasRole para roles de grano grueso y hasAuthority para permisos finos como orders:write. Nunca pases a hasRole una cadena que ya empiece por ROLE_: lanza una excepción.
- ¿Debo desactivar la protección CSRF en una API REST?
- Solo si la API no se autentica con nada que el navegador envíe automáticamente. Si los clientes se autentican con un bearer token en la cabecera Authorization, una página de otro sitio no puede falsificar la petición y la protección CSRF no aporta nada: desactívala y pon la política de sesión en STATELESS. Si la API usa cookies de sesión —por ejemplo, una single-page app en el mismo sitio—, mantenla activada y usa el token repository basado en cookie para que JavaScript pueda leer el token.
- ¿Cómo obtengo el usuario actual en un controlador o un servicio?
- En un controlador, declara un parámetro @AuthenticationPrincipal UserDetails o un java.security.Principal. En un servicio, SecurityContextHolder.getContext().getAuthentication() devuelve la Authentication actual; SecurityContextHolderFilter vincula el contexto al thread de la petición, así que no está disponible en otros threads salvo que lo propagues de forma explícita.