Several filter chains in one application

Spring Security · lesson 26 of 31 · 6 min read

A stateless API and a session-backed UI need different rules, and order decides which applies.

Open this lesson in the learning hub

Key points

  • An application usually has more than one security model: a token-authenticated API, a form-login admin UI, and public actuator endpoints. One chain cannot express all three cleanly.
  • You can declare several SecurityFilterChain beans, each with a securityMatcher narrowing it to a path. The first chain whose matcher matches wins, and the rest are never consulted.
  • That makes @Order load-bearing. A chain with a broad matcher declared first swallows every request, and the specific chains below it become dead configuration that looks correct in review.
  • A chain without a securityMatcher matches everything, so it must always be last. Putting it first is the single most common way this goes wrong.
  • Within a chain the rules are also first-match: anyRequest().permitAll() placed above a specific authenticated() rule silently opens the endpoint.
  • Verify with the filter chain listing rather than by reading configuration. Spring logs each chain and its matcher at DEBUG, and that output is the ground truth about which requests go where.

Example

@Configuration
@EnableWebSecurity
class SecurityConfig {

    // FIRST: the API. Stateless, token-authenticated, no CSRF.
    @Bean
    @Order(1)
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/api/**")            // this chain ONLY sees /api/**
                .csrf(csrf -> csrf.disable())          // safe: no cookie auth here
                .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
                .authorizeHttpRequests(a -> a
                        .requestMatchers("/api/public/**").permitAll()
                        .anyRequest().authenticated())  // specific rules FIRST
                .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
                .build();
    }

    // SECOND: the admin UI. Sessions and CSRF are both appropriate here.
    @Bean
    @Order(2)
    SecurityFilterChain admin(HttpSecurity http) throws Exception {
        return http
                .securityMatcher("/admin/**")
                .authorizeHttpRequests(a -> a.anyRequest().hasRole("ADMIN"))
                .formLogin(Customizer.withDefaults())
                .sessionManagement(s -> s
                        .sessionFixation().newSession()   // rotate id on login
                        .maximumSessions(1))
                .build();
    }

    // LAST: everything else. No securityMatcher, so it matches all.
    @Bean
    @Order(3)
    SecurityFilterChain defaults(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests(a -> a.anyRequest().permitAll()).build();
    }
}

// Confirm the ordering rather than trusting it:
//   logging.level.org.springframework.security=DEBUG
//
//   Will secure Or [Ant [pattern='/api/**']] with filters: ...
//   Will secure Or [Ant [pattern='/admin/**']] with filters: ...
//   Will secure any request with filters: ...
//
// If the "any request" line appears FIRST, everything below it is dead.

First matching chain wins and an unmatched chain matches everything - so the catch-all must be last, or the rest is dead configuration.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Spring Security course, and every lesson in it is listed on the Spring Security contents page.