Security Filter Chain — Practical Demo
Hands-on examples for Security Filter Chain. We start with the minimal working config, then layer in custom filters and multiple chains.
Before running these examples, understand the Security Filter Chain concepts — particularly how FilterChainProxy routes requests, and what each built-in filter does.
Example 1: Minimal Stateless REST API Config
The most common starting point: a stateless REST API that expects a JWT Bearer token.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // [8] No CSRF needed for stateless APIs
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // no HTTP sessions
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll() // [12] open endpoints
.anyRequest().authenticated() // all others require auth
)
.oauth2ResourceServer(oauth2 -> oauth2 // [15] validates Bearer JWTs
.jwt(Customizer.withDefaults()));
return http.build();
}
}
spring:
security:
oauth2:
resourceserver:
jwt:
secret-key: my-super-secret-key-that-is-at-least-32-chars
What to observe:
GET /api/public/ping→200 OK(no auth needed)GET /api/orderswithoutAuthorizationheader →401 UnauthorizedGET /api/orderswith validAuthorization: Bearer <token>→200 OK
SessionCreationPolicy.STATELESS + csrf.disable() + oauth2ResourceServer is the standard three-part recipe for a stateless REST API.
Example 2: Inspecting Which Filters Run
Enable TRACE logging to see the full filter chain in action during development.
logging:
level:
org.springframework.security: TRACE
When you hit GET /api/orders, the log output looks like:
TRACE o.s.s.web.FilterChainProxy - Securing GET /api/orders
TRACE o.s.s.w.c.SecurityContextHolderFilter - Set SecurityContextHolder to empty SecurityContext
TRACE o.s.s.o.s.r.a.BearerTokenAuthenticationFilter - Found bearer token in Authorization header. Attempting authentication
TRACE o.s.s.a.ProviderManager - Authenticating request with JwtAuthenticationProvider
TRACE o.s.s.w.a.AuthorizationFilter - Authorizing GET /api/orders
TRACE o.s.s.w.a.AuthorizationFilter - Authorized GET /api/orders with authenticated
TRACE logging shows you exactly which filters run and in what order. Always enable this before debugging a security 403 or 401 to understand the failure point.
Example 3: Multiple Security Filter Chains (URL Namespace Security)
Separate security policies for the API, actuator, and admin areas:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean @Order(1) // [5] Evaluated FIRST — most specific
public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().hasRole("OPS")
)
.httpBasic(Customizer.withDefaults()); // ← HTTP Basic for ops tools
return http.build();
}
@Bean @Order(2) // [17] Evaluated SECOND
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean @Order(3) // [31] Catch-all — least specific
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().denyAll()); // ← deny unmatched paths
return http.build();
}
}
Expected behavior:
GET /actuator/health→200 OK(no auth)GET /actuator/metrics→401unlessROLE_OPSHTTP Basic credential suppliedGET /api/auth/login→200 OK(permit all)GET /api/orders+ valid JWT →200 OKGET /some-unknown-path→403 Forbidden(denyAll)
Example 4: Custom OncePerRequestFilter — API Key Validation
Add your own filter that validates a custom X-API-Key header before authentication:
public class ApiKeyFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private final Set<String> validKeys;
public ApiKeyFilter(Set<String> validKeys) {
this.validKeys = validKeys;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String key = request.getHeader(API_KEY_HEADER); // [12] read custom header
if (key == null || !validKeys.contains(key)) { // [16] reject if invalid
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid or missing API key\"}");
return; // [20] stop the chain — don't call filterChain.doFilter
}
filterChain.doFilter(request, response); // ← valid key — continue chain
}
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
ApiKeyFilter apiKeyFilter = new ApiKeyFilter(Set.of("key-abc-123", "key-xyz-789"));
http
.addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class)
// ← insert our filter BEFORE Spring's own auth filters
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); // ← API key IS the auth
return http.build();
}
Testing:
# Without API key → 401
curl http://localhost:8080/api/data
# With invalid key → 401
curl -H "X-API-Key: wrong-key" http://localhost:8080/api/data
# With valid key → 200
curl -H "X-API-Key: key-abc-123" http://localhost:8080/api/data
Example 5: Accessing the Current User Anywhere in Code
public class CurrentUserUtil {
/** Use in any bean that needs the logged-in user's identity. */
public static String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new IllegalStateException("No authenticated user in current thread");
}
return auth.getName();
}
/** When using JWT resource server, the principal is a Jwt object. */
public static String getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
return jwtAuth.getToken().getSubject(); // ← "sub" claim
}
throw new IllegalStateException("Not a JWT authentication");
}
}
SecurityContextHolder holds the Authentication on the current thread. This works from any @Service, @Repository, or component — not only controllers.