DEV Community

Harikrushna V
Harikrushna V

Posted on

Spring Security Filter Chain: A Deep Dive for Java Backend Engineers

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. JWT authentication (no sessions — stateless API)
  2. Role-based access control (ADMIN, DOCTOR, NURSE, RECEPTIONIST)
  3. API key support for inter-service communication
  4. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Actuator chain (Order 0) runs first — Prometheus can scrape /actuator/prometheus without JWT
  2. API chain (Order 1) handles /api/** — JWT + API key filters active
  3. 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) { ... }
}
Enter fullscreen mode Exit fullscreen mode

@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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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 SecurityFilterChain beans (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)