04-advanced-spring

Spring Security: The Filter Chain in Ten Minutes

Where security sits relative to your controllers, why a @ControllerAdvice never sees a 401, and the minimum configuration for a stateless API.

October 9, 2026
spring-bootspring-securityfilter-chainauthenticationauthorisationjwt

Scope of This Guide

Spring Security is large enough to need its own phase, and it has one — phase 5 covers the filter chain in detail, authentication mechanisms, password encoding, authorisation models, OAuth2 and OIDC, and CORS, CSRF and security testing.

This guide exists to give you the structural picture now, because security interacts with everything in this phase: it decides what your tests need to simulate, what Actuator exposes, and which of your filters run first. Read it as orientation, then take the depth in phase 5.

It's a Filter Chain, Not an Interceptor

The single most useful fact about Spring Security: it runs as servlet filters, before DispatcherServlet.

This explains the thing that confuses people most, which the exception handling guide flagged: your @RestControllerAdvice never sees authentication or authorisation failures. They are produced by filters that reject the request before Spring MVC is involved. So the careful RFC 9457 error format you built does not apply to 401s and 403s unless you configure it separately:

java
http.exceptionHandling(ex -> ex
        .authenticationEntryPoint(problemDetailEntryPoint())   // 401
        .accessDeniedHandler(problemDetailAccessDeniedHandler()) // 403
);

Without that, your API returns one error shape for business failures and a different one for auth failures — a real inconsistency clients have to handle.

Minimum Configuration for a Stateless API

Adding spring-boot-starter-security secures everything immediately: every endpoint requires authentication, with a generated password logged at startup. That default is deliberately safe and deliberately unusable, so you replace it:

java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())                     // see the note below
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())                 // deny-by-default
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .build();
    }
}

Four decisions in there matter more than the syntax:

anyRequest().authenticated() last. Rules are evaluated in order and the first match wins, so the catch-all must come last — and it must exist. A chain whose last rule is a permitAll() pattern leaves anything unmatched wide open. Deny by default.

STATELESS for a token-based API: no session is created, nothing is stored server-side, and every request authenticates itself.

CSRF disabled — only because the API is stateless. CSRF attacks rely on the browser attaching credentials automatically, which is cookies. An API authenticated by an Authorization header is not vulnerable, so the protection is unnecessary. If you authenticate with cookies, CSRF protection must stay on.

oauth2ResourceServer rather than a hand-written JWT filter — covered below.

🚨

csrf.disable() is widely copied from tutorials into applications that do use cookie or session authentication, where it removes a real protection. The rule is specific: disable CSRF only when no browser-managed credential (cookie, session, HTTP Basic) can authenticate a request. If you're not certain which applies, leave it enabled.

Don't Hand-Roll JWT Validation

The roadmap mentions building a OncePerRequestFilter to extract and validate tokens, and it's worth knowing how that works — phase 5 shows it. But for standard JWT bearer tokens, use the resource server support instead:

yaml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/shop

That one property gives you signature verification, issuer and audience validation, expiry checking, JWKS key fetching with rotation and caching, and clock-skew tolerance. A hand-written filter typically gets some of those wrong, and the failure mode is accepting tokens it should reject — which you won't notice from testing the happy path.

Write the filter when you have a genuinely non-standard scheme. For JWTs from an identity provider, the library is both less code and more correct.

⚠️

Two hand-rolled JWT mistakes worth naming now, because both are common and both are complete bypasses. Not verifying the signature algorithm lets an attacker present a token signed with alg: none or with HMAC using the public key as the secret. Not validating the issuer and audience lets a valid token minted for a different service authenticate against yours. The resource server support handles both.

Method Security

URL rules cover coarse access. For rules that depend on the data, annotate methods:

java
@Service
public class OrderService {
 
    @PreAuthorize("hasRole('ADMIN')")
    public void cancelAny(Long orderId) { }
 
    @PreAuthorize("#order.customerId == authentication.name")
    public void update(Order order) { }
 
    @PostAuthorize("returnObject.customerId == authentication.name")
    public Order findById(Long id) { }
}

Enable with @EnableMethodSecurity. @PreAuthorize runs before the method, @PostAuthorize after (so it can inspect the result).

Method security is the same proxy mechanism as @Transactional and @Cacheable, which means the same two limitations apply for the third time: self-invocation bypasses it, and non-public methods aren't advised. A @PreAuthorize method called from within the same bean is not checked — and here that's a security hole rather than a performance bug. The next guide on AOP explains why this keeps recurring.

🚨

@PostAuthorize on a method with side effects is a trap: the method has already run and its writes have already happened when the check fails. The transaction will roll back if the exception propagates, but any non-transactional effect — an email sent, a message published, a file written — has occurred. Use @PreAuthorize for anything that mutates.

Check yourself

An API uses JWT bearer tokens and returns a well-structured RFC 9457 body for business errors via @RestControllerAdvice. Clients report that 401 responses have a completely different shape. Why?

What Phase 5 Covers

So you know what's deferred rather than omitted:

  • The filter chain in detail — which filters exist, their order, and SecurityContextHolder
  • Form login, HTTP Basic, and building JWT authentication from the filter up
  • Password encoding — why BCrypt or Argon2, and how to migrate hashes
  • Authorisation models — roles versus authorities, RBAC, and expression-based rules
  • OAuth2 and OIDC in all three roles: resource server, client, and authorisation server
  • CORS, CSRF, and testing security with @WithMockUser and SecurityMockMvcRequestPostProcessors

The Mental Model, Restated

  1. Security is a filter chain before DispatcherServlet — so a @ControllerAdvice never sees 401s and 403s.
  2. Order your rules, and end with anyRequest().authenticated(). Deny by default.
  3. Disable CSRF only for genuinely stateless, header-authenticated APIs.
  4. Use the resource server support for JWTs rather than a hand-written filter.
  5. Method security is proxy-based, so self-invocation bypasses it — a security hole, not just a bug.
  6. @PreAuthorize for anything that mutates; @PostAuthorize has already run the method.

What's Next

Securing an application makes testing it harder: half your endpoints now return 401 to a test that doesn't authenticate. The next guide covers Spring's testing support — the slices (@WebMvcTest, @DataJpaTest), when a full @SpringBootTest is worth its cost, @MockitoBean, and testing against real infrastructure with Testcontainers.