The security filter chain
An office building has a security desk at the entrance and badge readers on some doors. The desk checks who you are; each reader checks whether your badge opens that door. The lobby is open to everyone, the offices need any employee badge, and the server room needs a special one.
Spring Security is that building’s security. Before a request reaches your controller, it passes a chain of filters that figures out who sent it (from a token or a session) and whether they may use that URL. You describe the rules in one place: a SecurityFilterChain.
Spring Security installs a DelegatingFilterProxy that routes each request through the first matching SecurityFilterChain: authentication filters populate the SecurityContext, the AuthorizationFilter evaluates authorizeHttpRequests rules (first match wins), and exception translation turns failures into 401 (via the AuthenticationEntryPoint) or 403 (via the AccessDeniedHandler). Method security (@PreAuthorize) adds checks at the service layer. Defaults are secure (everything authenticated, CSRF on, sessions as needed); a stateless token API changes the last two deliberately.
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun api(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() } // bearer tokens, no browser session
sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
authorizeHttpRequests {
authorize("/actuator/health/**", permitAll)
authorize("/api/products/**", permitAll)
authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
authorize(anyRequest, authenticated) // everything else needs a token
}
oauth2ResourceServer { jwt { } }
}
return http.build()
}
}@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // bearer tokens, no browser session
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**", "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().authenticated()) // everything else needs a token
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
}
// application.yml
// spring.security.oauth2.resourceserver.jwt.issuer-uri: https://auth.example.com/realms/shopReading it top to bottom:
- Public: the health check (for the load balancer) and browsing products.
- Admin only: anything under
/api/admin/needs theadminscope in the token. - Everything else: any valid token.
oauth2ResourceServer { jwt }: tokens come in theAuthorization: Bearer …header and are checked against the login server configured inissuer-uri(signature, expiry, issuer).- CSRF off, no sessions: this API is called by apps sending tokens, not by browsers with cookies, so it doesn’t need either.
Tap the line with the bug.
@Bean
fun api(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/api/**", authenticated)
authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
authorize(anyRequest, permitAll)
}
oauth2ResourceServer { jwt { } }
}
return http.build()
}@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.anyRequest().permitAll())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}Hint: A normal user sends DELETE /api/admin/products/7. Which rule is checked first, and does it match?
Rules are checked in order, and the first match wins. /api/admin/products/7 matches /api/** first, which only requires being authenticated, so the admin rule below it is never reached: every logged-in user can use the admin endpoints. And anyRequest().permitAll() makes every other path public by default.
Most specific first, and deny by default:
authorizeHttpRequests {
authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
authorize("/api/products/**", permitAll)
authorize(anyRequest, authenticated)
}http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.requestMatchers("/api/products/**").permitAll()
.anyRequest().authenticated());Then write a test per rule (anonymous, user, admin against each protected path); see Testing.
Predict the output
With the corrected config: (1) a request to /api/orders without a token, (2) a request to /api/admin/products with a normal user's valid token. Which status codes come back?
(1) has no credentials, so Spring Security doesn't know who it is: 401 Unauthorized (with a WWW-Authenticate: Bearer header). (2) has a valid token, so the user is known, but lacks the admin scope: 403 Forbidden. Apps react differently: on 401, refresh the token or show the login screen; on 403, show 'you don't have access', because logging in again won't help.
Rules for a shop API
Write the authorizeHttpRequests rules for: anyone can GET /api/products/**; only users with the catalog:write scope can POST, PUT or DELETE products; /api/cart/** and /api/orders/** need any logged-in user; /api/admin/** needs the admin scope; everything else is denied.
›
requestMatchers can take an HTTP method first: requestMatchers(HttpMethod.GET, "/api/products/**"). “Everything else is denied” is anyRequest().denyAll().
SolutionTry it yourself first
authorizeHttpRequests {
authorize("/api/admin/**", hasAuthority("SCOPE_admin"))
authorize(HttpMethod.GET, "/api/products/**", permitAll)
authorize("/api/products/**", hasAuthority("SCOPE_catalog:write"))
authorize("/api/cart/**", authenticated)
authorize("/api/orders/**", authenticated)
authorize(anyRequest, denyAll)
}http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/products/**").hasAuthority("SCOPE_catalog:write")
.requestMatchers("/api/cart/**", "/api/orders/**").authenticated()
.anyRequest().denyAll());The GET rule comes before the general products rule, so reading stays public while every other method on products needs the write scope. denyAll at the end means a new endpoint is closed until someone adds a rule for it, instead of open by accident.
CSRF, CORS and sessions
- CSRF protects cookie-based browser sessions from cross-site forms. A stateless API that only accepts
Authorization: Bearerheaders isn’t vulnerable and can disable it; an API that also accepts session cookies (a web app’s BFF) must keep it on. - CORS is a browser rule, not authentication: configure allowed origins for browser clients (
http.cors { }with aCorsConfigurationSource); mobile apps and servers ignore it. - Several chains: use
securityMatcher("/api/**")on oneSecurityFilterChainand another chain for the admin web UI with form login and sessions; order them with@Order.
SecurityFilterChainbeans;authorizeHttpRequestsrules are first-match-wins: specific → general, end withauthenticated()ordenyAll().- Stateless token APIs:
STATELESSsessions, CSRF off only if no cookie auth; CORS for browser clients only. - 401 from the entry point (no/invalid credentials), 403 from the access-denied handler (insufficient authority).
- Multiple chains via
securityMatcher+@Orderfor APIs vs web UIs. Test each rule with anonymous/user/admin.
Check this part
In authorizeHttpRequests, which rule applies when several match?
A request with a valid token lacks the admin scope. What status?
When is it safe to disable CSRF protection?