Spring Boot – Configuring the Security Filter Chain

July 27, 20265 min readUpdated 8/18/2026

The filter chain is where you say which requests need what. Spring Security 7 — which comes with Boot 4 — removed the older configuration styles entirely, so a great deal of what you will find online no longer compiles.

What is gone

// Spring Security 5 and earlier. Removed. Does not compile.
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            .and()
                .formLogin();
    }
}

WebSecurityConfigurerAdapter, antMatchers and the .and() chaining are all gone. A tutorial containing any of them is at least three major versions out of date.

The modern shape

/**
 * <p>This is Spring Security 7's lambda DSL — version 7 removed the older chained-setter style
 * entirely, so examples written against Spring Security 5 will not compile here.
 *
 * <p>Rules are evaluated IN ORDER, first match wins. That is why the specific patterns
 * ({@code /api/orders/mine}) come before the general ones ({@code /api/orders/**}).
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final PizzaProperties properties;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
                .csrf(csrf -> csrf.disable())
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .addFilterBefore(
                        jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

A SecurityFilterChain bean, not a subclass. Each setting takes a lambda. requestMatchers replaces antMatchers.

⚠️ Order decides everything

Rules are evaluated top to bottom and the first match wins. Get the order wrong and you silently open an endpoint:

// WRONG — /api/orders/mine is matched by the general rule first and becomes public.
.requestMatchers(HttpMethod.GET, "/api/orders/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/mine").authenticated()

// RIGHT — specific before general.
.requestMatchers(HttpMethod.GET, "/api/orders/mine").authenticated()
.requestMatchers(HttpMethod.GET, "/api/orders/**").permitAll()

Nothing warns you about the first version. It starts up cleanly and serves other customers' order history.

Default deny

// Default deny. Anything not listed above needs authentication, so a new
// endpoint is closed until someone deliberately opens it.
.anyRequest()
.authenticated()

End with .authenticated(), never .permitAll(). The difference shows up months later when a colleague adds an endpoint and forgets a rule: with default-deny it returns 403 and they fix it; with default-permit it is public and nobody notices.

Reading a real configuration

The pizza API's rules are worth reading in full, because each one records a product decision:

// ---- documentation -------------------------------------------------
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll()

// ---- the caller's own profile --------------------------------------
// /api/me/** always means "the signed-in caller", so there is no id in
// the path that could be swapped for someone else's.
.requestMatchers("/api/me/**")
.authenticated()

// ---- Stripe callbacks ----------------------------------------------
// Stripe cannot present a JWT. This endpoint is protected instead by
// verifying the Stripe-Signature header — see StripeWebhookController.
.requestMatchers("/api/webhooks/**")
.permitAll()

// ---- public menu ---------------------------------------------------
.requestMatchers(HttpMethod.GET, "/api/products/**", "/api/toppings/**", "/api/crusts/**")
.permitAll()

// ---- cart ----------------------------------------------------------
// Public, like guest checkout — you do not need an account to fill a
// basket. The cart's unguessable UUID is what protects it.
.requestMatchers("/api/carts", "/api/carts/**")
.permitAll()

// ---- orders --------------------------------------------------------
// Specific first: order history needs a real account.
.requestMatchers(HttpMethod.GET, "/api/orders/mine")
.authenticated()
// Placing an order must NOT require a login — this is guest checkout.
.requestMatchers(HttpMethod.POST, "/api/orders")
.permitAll()

// ---- admin ---------------------------------------------------------
.requestMatchers("/api/admin/**")
.hasRole("ADMIN")

Two things worth stealing. Every permitAll has a comment saying why — a public endpoint is a decision, and an undocumented one looks like an oversight to the next reader. And the HTTP method is part of the rule: GET /api/products is public while POST /api/admin/products is not, so browsing and editing are separated without needing separate paths.

The pizza API also does the same for a rule that would otherwise be protected only by accident:

// Browsing the menu is public, and so is searching it. The reindex endpoint
// below it is NOT — it falls through to /api/search/** ... which does not
// exist, so it lands on anyRequest().authenticated(). Stated explicitly
// instead, because "protected by accident" is not protected.
.requestMatchers(HttpMethod.GET, "/api/search/products").permitAll()
.requestMatchers(HttpMethod.POST, "/api/search/reindex").hasRole("ADMIN")

CSRF — when disabling it is safe

// Safe to disable ONLY because this API is stateless and token-based: there is no
// session cookie for a cross-site request to ride on. Never do this for a
// cookie-authenticated app.
.csrf(csrf -> csrf.disable())

CSRF attacks work because browsers attach cookies automatically. A malicious page posts to your bank, the browser helpfully includes the session cookie, and the request is authenticated.

A JWT in an Authorization header is not attached automatically — the attacker's page cannot read your token or make the browser send it. So there is nothing to protect against.

That reasoning depends entirely on the token not being in a cookie. Store the JWT in a cookie and CSRF is back, and disabling it is a real vulnerability. csrf.disable() without a comment explaining which of these you are is how it gets copied into an app where it is wrong.

Stateless sessions

.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

No HttpSession is created or used. Every request stands alone, carrying its own token, which is what lets you run any number of instances behind a load balancer with no sticky sessions and no shared session store.

Filter placement

// Runs before the username/password filter so a valid token authenticates the
// request before anything tries to challenge it.
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

Order within the chain matters as much as rule order. A JWT filter placed after the authentication challenge never gets a chance to run.

Returning JSON for security errors

A JSON API that returns an HTML login page for a 401 is a bad API. As lesson 12 noted, filter-level failures never reach a @RestControllerAdvice, so they are configured here instead:

.exceptionHandling(ex -> ex
        .authenticationEntryPoint((request, response, authException) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write(
                    "{\"message\":\"Authentication required\"}");
        })
        .accessDeniedHandler((request, response, deniedException) -> {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write(
                    "{\"message\":\"You do not have permission to do that\"}");
        }))

Debugging it

logging.level.org.springframework.security=DEBUG

That prints the whole filter chain and which rule matched — which turns "why is this 403" from guesswork into reading. It is the first thing to switch on and the first thing to switch off before production, since it logs a great deal.

What to take from this

  • A SecurityFilterChain bean and the lambda DSL. WebSecurityConfigurerAdapter is gone.
  • Specific rules before general ones, and end with anyRequest().authenticated().
  • Comment every permitAll so a decision does not read as an oversight.
  • Disabling CSRF is safe only for header-based tokens, never for cookie auth.
  • Configure JSON entry points, because filter errors bypass your advice.

Next: stateless API authentication with JWT.