“Walk me through how Spring Security processes a request” and “how would you secure an API and an admin UI differently in the same app?” are common intermediate interview questions. They test whether you understand that Spring Security is a chain of servlet filters, that you can have several chains, and that order decides everything: the order of chains and the order of rules inside each chain. Most real security bugs here are ordering bugs that make an endpoint public by accident.
Before you start
You should know what a servlet filter is, the difference between authentication (who are you) and authorization (what may you do), and how HTTP Basic, form login and bearer tokens differ. Examples target Spring Boot 3.x with Spring Security 6, which removed WebSecurityConfigurerAdapter and the non-lambda configuration style.
The short answer
Spring Boot registers one servlet filter, DelegatingFilterProxy, which hands requests to Spring Security’s FilterChainProxy. That proxy holds one or more SecurityFilterChain beans. For each request it picks the first chain whose securityMatcher matches, and only that chain’s filters run. Inside the chain, authorizeHttpRequests rules are checked top to bottom and the first matching rule decides. So you order chains with @Order from most specific to least specific, write specific rules before general ones, and finish with anyRequest().authenticated() or denyAll().
How it works
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // bearer tokens only; see the CSRF guide
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
}A request to /api/orders/5 matches apiChain’s securityMatcher, so only that chain runs: bearer token authentication, stateless sessions, no form login. A request to /account does not match /api/**, so FilterChainProxy tries the next chain; webChain has no securityMatcher, which means it matches everything, and it handles the request with form login.
Each chain is a list of filters in a fixed internal order, such as SecurityContextHolderFilter, CsrfFilter, LogoutFilter, authentication filters, ExceptionTranslationFilter and finally AuthorizationFilter. The DSL methods add or configure these filters; you rarely position them yourself.
Step-by-step walkthrough
Step 1: Map your traffic types
List who calls what and how they authenticate: a mobile app calling /api/** with JWTs, staff using /admin/** through a browser session, Kubernetes probing /actuator/health. Each distinct authentication style is a candidate for its own chain.
Step 2: Order chains from specific to general
Give chains with narrow securityMatchers low @Order values and put the catch-all chain last. If two chains could match the same path, the lower order wins and the other never sees that request.
@Bean @Order(0)
SecurityFilterChain actuator(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to("health")).permitAll()
.anyRequest().hasRole("OPS"))
.httpBasic(Customizer.withDefaults());
return http.build();
}Step 3: Order rules within a chain
The first matching rule wins, so a broad permitAll() above a specific restriction disables the restriction:
// Wrong: /api/products/** matches first, so the admin rule below never applies
.requestMatchers("/api/products/**").permitAll()
.requestMatchers("/api/products/*/price").hasRole("ADMIN")
// Right: specific first
.requestMatchers(HttpMethod.PUT, "/api/products/*/price").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()Include the HTTP method when the same path supports reads and writes. Spring Security will also refuse to start if anyRequest() is declared before other rules.
Step 4: Close the default
End every chain with anyRequest().authenticated() or anyRequest().denyAll(). A new controller added next year is then protected without anyone remembering to add a rule.
Worked scenario
A team adds a public status page at /api/status and edits their single chain:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll() // added "temporarily" for the status page
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())Every endpoint under /api, including /api/admin/users, is now public, because the broad rule matches first. A security scan finds it two weeks later.
The fix narrows the rule and adds tests that pin the policy:
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/status").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())In a related incident, another team defined a second chain with @Order(1) and securityMatcher("/api/**") but placed their existing catch-all chain, which also covered /api/** through anyRequest(), at @Order(0). The catch-all chain handled every request, and the new JWT configuration never ran; tokens were rejected with redirects to a login page. Swapping the orders fixed it. The symptom, a 302 to /login for an API call, is a reliable clue that the wrong chain answered. Recent Spring Security 6 releases catch the most blatant form of this at startup: if a chain that matches any request is ordered before another chain, the application fails with an error saying a filter chain matching any request has already been configured. Overlapping narrow matchers are still your responsibility.
Common mistake
- Thinking all chains run. Only the first match runs. The others are skipped entirely for that request.
- Forgetting
securityMatcheron a non-final chain. A chain without one matches every request and swallows traffic meant for later chains. - Broad
permitAll()rules before specific ones. - Confusing
hasRole("ADMIN")withhasAuthority("ADMIN").hasRoleadds theROLE_prefix, so it checks forROLE_ADMIN. - Using
web.ignoring()for endpoints that need headers or CSRF. Ignored paths skip the whole chain, including security headers. PreferpermitAll(). - Expecting
@ControllerAdviceto handle 401 and 403. Filters run beforeDispatcherServlet; configureauthenticationEntryPointandaccessDeniedHandler.
Verify the behavior
Write tests that assert both allowed and denied paths for each chain:
@SpringBootTest
@AutoConfigureMockMvc
class SecurityPolicyTest {
@Autowired MockMvc mvc;
@Test
void statusIsPublic() throws Exception {
mvc.perform(get("/api/status")).andExpect(status().isOk());
}
@Test
void adminApiRequiresAdminRole() throws Exception {
mvc.perform(get("/api/admin/users").with(jwt()))
.andExpect(status().isForbidden());
mvc.perform(get("/api/admin/users").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
@Test
void apiDoesNotRedirectToLogin() throws Exception {
mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized());
}
}At startup with logging.level.org.springframework.security=debug, Spring Security logs each chain and its filters, and per request it logs which chain was selected (“Securing GET /api/orders”).
Follow-up questions
What replaced WebSecurityConfigurerAdapter? Declaring SecurityFilterChain beans, and WebSecurityCustomizer beans for global settings.
What is the difference between securityMatcher and requestMatchers? securityMatcher decides whether the chain applies at all; requestMatchers inside authorizeHttpRequests decide which authorization rule applies within the chain.
Where does CORS fit? http.cors(Customizer.withDefaults()) adds CorsFilter early in the chain so preflight requests are answered before authentication. See CORS configuration.
How do method-level rules relate? @EnableMethodSecurity with @PreAuthorize adds checks on service methods; use it for rules that depend on domain data, in addition to URL rules.
Interview exercise
An application serves a public marketing site, a customer REST API with JWTs under /api/**, a staff admin UI under /admin/** with SSO login, and Actuator. Design the chains and their order.
Answer and reasoning
Four chains. @Order(0) Actuator chain with EndpointRequest.toAnyEndpoint(): health permitted, the rest requires an ops role. @Order(1) API chain with securityMatcher("/api/**"): stateless, JWT resource server, CSRF disabled, specific rules first and anyRequest().authenticated() last. @Order(2) admin chain with securityMatcher("/admin/**"): oauth2Login for SSO, CSRF enabled, anyRequest().hasRole("STAFF"). Last, a catch-all public-site chain with no matcher: static assets and pages permitted, a login for customer accounts if needed, ending with authenticated(). The reasoning: one chain per authentication style, narrow matchers first, and a deny-by-default end to every chain, verified with tests for each path.