Spring Security Filter Chain: A Deep Dive for Java Backend Engineers
If you've ever wondered what actually happens between the moment an HTTP request hits your Spring Boot application and the moment your controller method executes — this post is for you. The answer is the Security Filter Chain, and understanding it is the difference between cargo-culting security configs and actually knowing what you're doing.
This is the foundation that every other authentication and authorization mechanism in Spring Security builds on.
What Is the Security Filter Chain?
Spring Security is built on a chain of servlet filters. When a request arrives, it passes through a series of filters in order before reaching your controller. Each filter can inspect, modify, reject, or pass through the request and response.
Think of it like airport security — each checkpoint (filter) does a specific check. If you fail at any checkpoint, you're rejected. Pass all checkpoints and you board the plane.
In Spring Security, these filters are wired together in a SecurityFilterChain bean. The order matters enormously — a filter that runs before another behaves very differently.
HTTP Request
↓
[ChannelProcessingFilter] ← HTTP or HTTPS?
↓
[WebAsyncManagerIntegrationFilter] ← Async context propagation
↓
[SecurityContextPersistenceFilter] ← Load SecurityContext from session
↓
[HeaderWriterFilter] ← Security headers (X-Frame-Options, etc.)
↓
[CsrfFilter] ← CSRF token validation
↓
[LogoutFilter] ← Handle /logout requests
↓
[UsernamePasswordAuthenticationFilter] ← Form login (if enabled)
↓
[DefaultLoginPageGeneratingFilter] ← Serve login page (if needed)
↓
[DefaultLogoutPageGeneratingFilter] ← Serve logout page (if needed)
↓
[RequestCacheAwareFilter] ← Save/restore request after login
↓
[SecurityContextHolderAwareRequestFilter] ← Wrap request with Security-aware wrapper
↓
[AnonymousAuthenticationFilter] ← Inject AnonymousAuthenticationToken if no auth yet
↓
[SessionManagementFilter] ← Session fixation protection, max sessions
↓
[ExceptionTranslationFilter] ← Translate security exceptions → HTTP responses
↓
[FilterSecurityInterceptor] ← Final authorization check — ALLOW or DENY
↓
Controller
The FilterSecurityInterceptor is typically the last filter in the chain. If it throws an AccessDeniedException or AuthenticationException, the ExceptionTranslationFilter catches it and sends an appropriate HTTP response (403 Forbidden or 401 Unauthorized).
The Default Security Filter Chain (Spring Boot 3.x)
Here's what most people see in their application.properties:
spring.security.user.name=admin
spring.security.user.password={noop}secret
Or in modern Spring Boot 3 with Spring Security 6, the simplest config:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
}
This single method (securityFilterChain) wires up 15+ filters behind the scenes. Spring Boot's auto-configuration decides which filters to include based on what you configure. You don't see them — but they're there.
Customizing the Security Filter Chain: A Real Production Example
Let's build something useful: an HMIS (Hospital Management System) API that needs:
- JWT authentication (no sessions — stateless API)
- Role-based access control (ADMIN, DOCTOR, NURSE, RECEPTIONIST)
- API key support for inter-service communication
- Actuator endpoints publicly readable (for Prometheus scraping)
package com.snowcare.hmis.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
// BCrypt with strength 12 — OWASP recommendation for password hashing
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
/**
* Actuator filter chain — runs BEFORE the main filter chain.
* Prometheus needs /actuator/prometheus open.
* Order 0 = highest priority (runs first).
*/
@Bean
@Order(0)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/prometheus").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
/**
* Main API filter chain — JWT auth, stateless, role-based.
* This is the production HMIS config.
*/
@Bean
@Order(1)
public SecurityFilterChain apiSecurityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthFilter,
ApiKeyAuthenticationFilter apiKeyFilter) throws Exception {
http
.securityMatcher("/api/**")
// Stateless — no sessions, no cookies
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Disable CSRF — not needed for stateless JWT APIs
.csrf(AbstractHttpConfigurer::disable)
// CORS — HMIS frontend is on a different domain
.cors(c -> {})
// Authorization rules
.authorizeHttpRequests(auth -> auth
// Public auth endpoints
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
// FHIR endpoints — authenticated only, DOCTOR or ADMIN
.requestMatchers("/api/fhir/**").hasAnyRole("DOCTOR", "ADMIN")
// Admin-only operations
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Clinical data — DOCTOR and NURSE
.requestMatchers("/api/clinical/**").hasAnyRole("DOCTOR", "NURSE", "ADMIN")
// Reception handles patient registration
.requestMatchers("/api/patient/register").hasAnyRole("RECEPTIONIST", "ADMIN")
// All other API calls require authentication
.anyRequest().authenticated()
)
// Add JWT filter BEFORE UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// Add API key filter BEFORE JwtAuthenticationFilter (higher priority)
.addFilterBefore(apiKeyFilter, JwtAuthenticationFilter.class);
return http.build();
}
/**
* Default filter chain — fallback for anything not matched above.
* Usually serves the web frontend.
*/
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/error").permitAll()
.anyRequest().authenticated()
)
.formLogin(f -> f.loginPage("/login").defaultSuccessUrl("/dashboard"))
.logout(log -> log.logoutSuccessUrl("/login?logout"));
return http.build();
}
}
Building the JWT Authentication Filter
The JwtAuthenticationFilter is a custom filter. Here's a production-grade implementation:
package com.snowcare.hmis.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = extractJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
// Extract claims from token
String username = jwtTokenProvider.getUsernameFromToken(jwt);
List<String> roles = jwtTokenProvider.getRolesFromToken(jwt);
String userId = jwtTokenProvider.getUserIdFromToken(jwt);
// Build authorities from roles
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
// Create authentication token
UserPrincipal principal = new UserPrincipal(userId, username, roles);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
// Set in SecurityContext — THIS is what @PreAuthorize checks
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user: {} with roles: {}", username, roles);
}
} catch (Exception ex) {
log.error("Could not set user authentication in security context", ex);
// Don't throw — let the filter chain continue.
// AnonymousAuthenticationFilter will set anonymous auth if nothing else did.
}
filterChain.doFilter(request, response);
}
private String extractJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}
Building the API Key Filter
For machine-to-machine (M2M) communication — say, a lab system pushing results into the HMIS:
package com.snowcare.hmis.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private static final String SYSTEM_PRINCIPAL = "SYSTEM_API";
@Value("${hmis.api-keys.lab-system}")
private String labSystemApiKey;
@Value("${hmis.api-keys.diagnostic-equipment}")
private String diagnosticEquipmentApiKey;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Only check API key auth for /api/m2m/** endpoints
if (!request.getRequestURI().startsWith("/api/m2m/")) {
filterChain.doFilter(request, response);
return;
}
String providedKey = request.getHeader(API_KEY_HEADER);
if (StringUtils.hasText(providedKey)) {
String systemName = validateApiKey(providedKey);
if (systemName != null) {
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_SYSTEM"),
new SimpleGrantedAuthority("ROLE_LAB_TECHNICIAN")
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
systemName, null, authorities);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("M2M authentication successful for system: {}", systemName);
} else {
log.warn("Invalid API key provided from IP: {}", request.getRemoteAddr());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"Invalid API key\"}");
response.setContentType("application/json");
return;
}
} else {
log.warn("Missing API key for M2M endpoint: {}", request.getRequestURI());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"API key required\"}");
response.setContentType("application/json");
return;
}
filterChain.doFilter(request, response);
}
private String validateApiKey(String key) {
if (labSystemApiKey.equals(key)) return "lab-system";
if (diagnosticEquipmentApiKey.equals(key)) return "diagnostic-equipment";
return null;
}
}
Filter Order Matters — Here's Why
With @Order(0), @Order(1), @Order(2) on each SecurityFilterChain, Spring Security creates three separate filter chains, each handling a different URL pattern. The key insight:
-
Actuator chain (
Order 0) runs first — Prometheus can scrape/actuator/prometheuswithout JWT -
API chain (
Order 1) handles/api/**— JWT + API key filters active -
Default chain (
Order 2) handles everything else — web UI with sessions
If you didn't separate them, you'd need JWT on your Prometheus endpoint, which doesn't make sense.
Method-Level Security: @PreAuthorize
Once the SecurityContextHolder is populated by your filter, you can use method-level annotations:
package com.snowcare.hmis.service;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class PatientService {
// Only ADMIN can create patients
@PreAuthorize("hasRole('ADMIN')")
public Patient createPatient(Patient patient) { ... }
// DOCTOR can read, NURSE can read basic info
@PreAuthorize("hasAnyRole('DOCTOR', 'NURSE', 'ADMIN')")
public Patient getPatient(String patientId) { ... }
// Only DOCTOR or ADMIN can update clinical records
@PreAuthorize("hasAnyRole('DOCTOR', 'ADMIN')")
public ClinicalRecord updateClinicalRecord(String patientId, ClinicalRecord record) { ... }
// Complex expression
@PreAuthorize("hasRole('ADMIN') or (hasRole('RECEPTIONIST') and #patient.status.name() == 'PROVISIONAL')")
public void dischargePatient(String patientId) { ... }
}
@EnableMethodSecurity (already in our config) enables these annotations. The SecurityContextHolderAwareRequestFilter that runs in the chain populates the SecurityContext, which @PreAuthorize checks via SecurityExpressionHandler.
Testing the Filter Chain
@WebMvcTest(PatientController.class)
@Import(SecurityConfig.class)
class PatientControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
private String adminToken;
private String doctorToken;
@BeforeEach
void setUp() {
// Generate test JWTs with correct roles
adminToken = jwtTestUtil.generateToken("admin", List.of("ADMIN"));
doctorToken = jwtTestUtil.generateToken("dr.sharma", List.of("DOCTOR"));
}
@Test
void getPatient_asDoctor_shouldSucceed() throws Exception {
mockMvc.perform(get("/api/patient/{id}", "P001")
.header("Authorization", "Bearer " + doctorToken))
.andExpect(status().isOk());
}
@Test
void createPatient_asDoctor_shouldBeForbidden() throws Exception {
mockMvc.perform(post("/api/patient")
.header("Authorization", "Bearer " + doctorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(somePatient)))
.andExpect(status().isForbidden());
}
@Test
void getPatient_withoutToken_shouldBeUnauthorized() throws Exception {
mockMvc.perform(get("/api/patient/{id}", "P001"))
.andExpect(status().isUnauthorized());
}
@Test
void actuatorPrometheus_shouldBePublic() throws Exception {
mockMvc.perform(get("/actuator/prometheus"))
.andExpect(status().isOk());
}
}
Common Mistakes to Avoid
1. Putting your JWT filter AFTER AnonymousAuthenticationFilter without handling anonymous users
If you add a filter after AnonymousAuthenticationFilter, it will always see an Authentication in the context — either real (JWT) or anonymous. Make sure your filter handles both cases explicitly.
2. Disabling CSRF globally and then forgetting it's gone
CSRF protection is disabled in our JWT config via csrf(AbstractHttpConfigurer::disable). This is correct for stateless APIs, but if you add session-based features later, remember to re-enable it.
3. Not setting SecurityContextHolder.getContext().setAuthentication() in your filter
This is the most common bug. The filter runs, the token is valid, but if you forget this one line, @PreAuthorize will always fail because the SecurityContext is empty.
4. Hardcoding roles in the filter instead of reading from the token
Never hardcode hasRole("ADMIN") checks in your filter logic. Read roles from the JWT claims and let Spring Security's MethodSecurityExpressionHandler handle the evaluation.
Conclusion
The Spring Security Filter Chain is the engine room of authentication and authorization in Spring Boot. Once you understand:
- Filter order (what runs before what)
-
Multiple
SecurityFilterChainbeans (URL-based separation) -
SecurityContextHolder(how auth state propagates) -
OncePerRequestFilter(the right base class for custom filters)
— everything else in Spring Security (JWT, OAuth2, method security) starts making sense as a layer built on top of these foundations.
In the next post, we'll build on this by implementing stateless JWT authentication end-to-end — token generation, token validation, refresh tokens, and token blacklisting.
This article is part of the Spring Boot Security series. Next: B2 — JWT Authentication: Stateless REST Auth End-to-End.
Building secure healthcare software? Orglance Technologies specializes in Spring Boot backend development for healthcare IT, fintech, and enterprise systems.
Want a live demo of ArogyaPlus HMIS security features? Book a 30-minute call.
Top comments (0)