Spring BootSecurity Filters
Spring Boot

Security Filters

Spring Security's filter chain is an ordered list of servlet filters that process every HTTP request before it reaches a controller. Each filter handles a specific concern — reading the security context, enforcing CSRF, processing authentication, authorising the request. Understanding the filter chain is essential for adding custom authentication mechanisms, logging, rate limiting, and debugging security issues.

The Security Filter Chain

Spring Security registers a FilterChainProxy with the servlet container. It holds one or more SecurityFilterChain beans, each with an ordered list of filters. For every request, FilterChainProxy finds the first matching SecurityFilterChain and runs its filters in order. Understanding the order is critical when adding custom filters.
Shell
# ── Default filter order (Spring Security 6): ────────────────────────
# (lower number = runs earlier)
#
#  100  DisableEncodeUrlFilter
#  200  WebAsyncManagerIntegrationFilter
#  300  SecurityContextHolderFilter
#  400  HeaderWriterFilter            ← adds X-Frame-Options, X-XSS-Protection etc.
#  500  CorsFilter                    ← handles CORS preflight
#  600  CsrfFilter                    ← validates CSRF tokens
#  700  LogoutFilter                  ← handles /logout
#  800  UsernamePasswordAuthenticationFilter ← form login
#  900  DefaultLoginPageGeneratingFilter
# 1000  DefaultLogoutPageGeneratingFilter
# 1100  BasicAuthenticationFilter     ← HTTP Basic
# 1200  RequestCacheAwareFilter
# 1300  SecurityContextHolderAwareRequestFilter
# 1400  RememberMeAuthenticationFilter
# 1500  AnonymousAuthenticationFilter ← sets anonymous auth if nothing else matched
# 1600  SessionManagementFilter
# 1700  ExceptionTranslationFilter    ← 401/403 handling
# 1800  AuthorizationFilter           ← enforces access rules (was FilterSecurityInterceptor)

# ── Inspect the active filter chain at startup: ───────────────────────
logging:
  level:
    org.springframework.security.web.FilterChainProxy: DEBUG
# Logs: "Security filter chain: [
#          DisableEncodeUrlFilter,
#          WebAsyncManagerIntegrationFilter,
#          ...
#        ]"

Adding a Custom Filter

Custom filters extend OncePerRequestFilter — Spring's base class that guarantees execution exactly once per request, even across forward/include dispatching. Register them in the SecurityFilterChain using addFilterBefore(), addFilterAfter(), or addFilterAt().
Java
// ── Custom request logging filter: ───────────────────────────────────
@Component
@Slf4j
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        long start = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString().substring(0, 8);

        // Before the request:
        MDC.put("requestId", requestId);
        log.info("→ {} {} [{}]",
            request.getMethod(), request.getRequestURI(), requestId);

        try {
            chain.doFilter(request, response);   // proceed to next filter
        } finally {
            // After the response (always runs):
            long duration = System.currentTimeMillis() - start;
            log.info("← {} {} {}ms [{}]",
                response.getStatus(),
                request.getRequestURI(),
                duration,
                requestId);
            MDC.clear();
        }
    }

    // Skip the filter for specific paths:
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/actuator/health")
            || path.startsWith("/static/");
    }
}

// ── Register the filter in SecurityFilterChain: ───────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
        RequestLoggingFilter loggingFilter) throws Exception {
    http
        // Before: runs before the specified filter type
        .addFilterBefore(loggingFilter,
            UsernamePasswordAuthenticationFilter.class)

        // After: runs after the specified filter type
        // .addFilterAfter(loggingFilter,
        //     SecurityContextHolderFilter.class)

        // At: replaces the specified filter (same position)
        // .addFilterAt(loggingFilter,
        //     UsernamePasswordAuthenticationFilter.class);
        ;
    return http.build();
}

JWT Authentication Filter

The most common custom security filter is a JWT validation filter. It reads the Bearer token from the Authorization header, validates it, and populates the SecurityContext so downstream filters and controllers see an authenticated principal.
Java
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        // No Bearer token — skip this filter:
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        try {
            String username = jwtService.extractUsername(token);

            // Only set auth if context is empty (not already authenticated):
            if (username != null && SecurityContextHolder.getContext()
                    .getAuthentication() == null) {

                UserDetails userDetails =
                    userDetailsService.loadUserByUsername(username);

                if (jwtService.isTokenValid(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                        );
                    authToken.setDetails(
                        new WebAuthenticationDetailsSource()
                            .buildDetails(request));
                    SecurityContextHolder.getContext()
                        .setAuthentication(authToken);

                    log.debug("Authenticated user: {}, uri: {}",
                        username, request.getRequestURI());
                }
            }

        } catch (ExpiredJwtException e) {
            log.debug("JWT expired: {}", e.getMessage());
            sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
                "Token has expired");
            return;
        } catch (JwtException e) {
            log.debug("Invalid JWT: {}", e.getMessage());
            sendError(response, HttpServletResponse.SC_UNAUTHORIZED,
                "Invalid token");
            return;
        }

        chain.doFilter(request, response);
    }

    private void sendError(HttpServletResponse response,
            int status, String message) throws IOException {
        response.setStatus(status);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(
            "{"error": "" + message + ""}");
    }

    // Skip JWT validation for public endpoints:
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getRequestURI();
        return path.startsWith("/api/auth/")
            || path.startsWith("/api/public/")
            || path.equals("/actuator/health");
    }
}

Rate Limiting Filter

A rate limiting filter demonstrates a cross-cutting concern that lives naturally in the filter chain — it runs before any controller logic and can reject requests immediately without loading user data.
Java
// ── Rate limiting filter using a token bucket: ───────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitingFilter extends OncePerRequestFilter {

    // Simple in-memory rate limiter — use Redis for distributed deployments:
    private final Map<String, RateLimiter> limiters =
        new ConcurrentHashMap<>();

    private static final double REQUESTS_PER_SECOND = 10.0;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        String clientKey = resolveClientKey(request);
        RateLimiter limiter = limiters.computeIfAbsent(
            clientKey,
            k -> RateLimiter.create(REQUESTS_PER_SECOND));

        if (!limiter.tryAcquire()) {
            log.warn("Rate limit exceeded for client: {}", clientKey);
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setHeader("Retry-After", "1");
            response.getWriter().write(
                "{"error": "Too Many Requests", " +
                ""message": "Rate limit exceeded"}");
            return;
        }

        chain.doFilter(request, response);
    }

    private String resolveClientKey(HttpServletRequest request) {
        // Use authenticated username if available:
        Authentication auth =
            SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated()
                && !(auth instanceof AnonymousAuthenticationToken)) {
            return "user:" + auth.getName();
        }
        // Fall back to IP address:
        String forwardedFor = request.getHeader("X-Forwarded-For");
        return "ip:" + (forwardedFor != null
            ? forwardedFor.split(",")[0].trim()
            : request.getRemoteAddr());
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // Exclude health checks and static resources:
        return request.getRequestURI().startsWith("/actuator/health");
    }
}

// ── Register after authentication so username is available: ───────────
http.addFilterAfter(rateLimitingFilter,
    UsernamePasswordAuthenticationFilter.class);

Debugging the Filter Chain

Debugging security issues requires visibility into which filters are running and what decisions they are making. Spring Security provides DEBUG and TRACE log levels that expose the full filter execution path.
yaml
# ── application-dev.yml — enable security debug logging: ─────────────
logging:
  level:
    org.springframework.security: DEBUG
    # TRACE is even more verbose — shows every security decision:
    # org.springframework.security: TRACE

# ── What DEBUG logging reveals: ──────────────────────────────────────
# Securing GET /api/users
# Checking match of request: '/api/users'; against '/api/public/**'
# Checking match of request: '/api/users'; against '/actuator/health'
# /api/users is secured
# Security filter chain: [
#   WebAsyncManagerIntegrationFilter,
#   SecurityContextHolderFilter,
#   ...
# ]
# JwtAuthenticationFilter: token extracted for user 'alice'
# AuthorizationFilter: Authorizing SecurityContextHolderAwareRequestWrapper...
# AuthorizationFilter: Authorized

# ── Programmatic filter chain inspection: ─────────────────────────────
@Component
@RequiredArgsConstructor
public class SecurityFilterChainInspector
        implements ApplicationListener<ApplicationReadyEvent> {

    private final FilterChainProxy filterChainProxy;

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        filterChainProxy.getFilterChains().forEach(chain -> {
            log.info("Filter chain for: {}", chain);
            if (chain instanceof DefaultSecurityFilterChain securityChain) {
                securityChain.getFilters().forEach(filter ->
                    log.info("  Filter: {}", filter.getClass().getSimpleName()));
            }
        });
    }
}

// ── Enable @EnableWebSecurity(debug = true) for very verbose output: ──
@Configuration
@EnableWebSecurity(debug = true)   // logs every filter invocation
public class SecurityConfig {
    // WARNING: logs request details including headers — never in production
}