Spring Boot
Security Filters
Spring Security is a chain of servlet filters that every request passes through before it reaches a controller. This page explains how the chain is built, how to add custom filters in the right position without running them twice, a working JWT filter with proper 401 handling, a rate limiter that does not leak memory, and how to debug the chain when a request is rejected and you cannot see why.
How the Security Filter Chain Works
Spring Boot registers one servlet filter named springSecurityFilterChain, which is a FilterChainProxy. It holds one or more SecurityFilterChain beans, each with its own request matcher and ordered list of filters. For each request, the proxy picks the first chain whose matcher fits and runs only that chain's filters.
The filters you configure decide what is in the chain. If you never call formLogin(), there is no UsernamePasswordAuthenticationFilter in it; if you never call httpBasic(), there is no BasicAuthenticationFilter. Still, those classes remain valid ordering anchors for addFilterBefore and addFilterAfter, because Spring Security keeps a fixed position for each known filter class. That is why JWT tutorials can place a filter "before UsernamePasswordAuthenticationFilter" in an API with no form login.
Order matters because filters depend on each other: the security context must be loaded before anything reads the user, CSRF is checked before authentication, and AuthorizationFilter (the access rules) always comes last.
Java
// ── Filters you will meet most often, in execution order ─────────────
// (Spring Security 6.x; which ones appear depends on your configuration)
//
// DisableEncodeUrlFilter
// WebAsyncManagerIntegrationFilter
// SecurityContextHolderFilter loads the SecurityContext
// HeaderWriterFilter writes security headers (X-Content-Type-Options,
// X-Frame-Options, Cache-Control ...)
// CorsFilter CORS (only if cors() is configured)
// CsrfFilter rejects POST/PUT/DELETE without a CSRF token
// LogoutFilter
// UsernamePasswordAuthenticationFilter only with formLogin()
// BasicAuthenticationFilter only with httpBasic()
// RequestCacheAwareFilter
// SecurityContextHolderAwareRequestFilter
// AnonymousAuthenticationFilter sets an anonymous user if nobody logged in
// SessionManagementFilter
// ExceptionTranslationFilter turns security exceptions into 401 / 403
// AuthorizationFilter enforces authorizeHttpRequests() rules
// ── Several chains: the first matching chain wins ────────────────────
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**") // only handles /api/**
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().permitAll())
.formLogin(Customizer.withDefaults()); // everything else
return http.build();
}
}Adding a Custom Filter (and Avoiding the Double-Run Trap)
Custom filters usually extend OncePerRequestFilter, which guarantees one execution per request dispatch. You add them to the chain with addFilterBefore, addFilterAfter or addFilterAt.
The trap: if you annotate the filter with @Component (or expose it as a @Bean of type Filter), Spring Boot also registers it as a plain servlet filter, outside Spring Security. It then runs in the servlet container chain and again inside the security chain. OncePerRequestFilter stops the second execution, but which copy runs, and where, is not what you intended. Create the filter with new inside your SecurityFilterChain bean instead.
Two more details: addFilterAt does not replace the existing filter at that position, it registers yours alongside it; and a logging filter belongs early in the chain, so it also records requests that Spring Security rejects.
Java
// No @Component here: the filter is created in the security config
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log =
LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
long start = System.nanoTime();
String requestId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("requestId", requestId);
try {
chain.doFilter(request, response);
} finally {
long ms = (System.nanoTime() - start) / 1_000_000;
log.info("{} {} -> {} ({} ms)", request.getMethod(),
request.getRequestURI(), response.getStatus(), ms);
MDC.clear();
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/health");
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Early position: also logs requests rejected with 401/403
.addFilterBefore(new RequestLoggingFilter(),
SecurityContextHolderFilter.class)
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
return http.build();
}
// addFilterBefore(f, X) -> runs just before X
// addFilterAfter(f, X) -> runs just after X
// addFilterAt(f, X) -> same position as X; X is NOT replaced, both run
// ── If you must keep @Component, stop Boot registering it twice ──────
@Bean
FilterRegistrationBean<RequestLoggingFilter> disableServletRegistration(
RequestLoggingFilter filter) {
FilterRegistrationBean<RequestLoggingFilter> reg =
new FilterRegistrationBean<>(filter);
reg.setEnabled(false); // keep it only in the security chain
return reg;
}JWT Authentication Filter
A JWT filter reads the Authorization: Bearer header, verifies the token, and puts an Authentication into the SecurityContext so later filters and controllers see a logged-in user. Three design choices make it robust.
First, a bad token does not write an error response inside the filter. It just leaves the request unauthenticated and records why; the AuthenticationEntryPoint then returns a consistent 401 for protected URLs, while public URLs keep working even if a client sends a stale token. Second, the API is stateless (no session) and CSRF is disabled, which is only safe because the credential is a header token rather than a cookie. Third, UsernameNotFoundException is handled too: a valid token for a deleted user must produce 401, not a 500.
The example uses jjwt 0.12 and an HMAC secret of at least 256 bits, supplied as Base64 from configuration, never hard-coded.
Java
// ── JwtService (jjwt 0.12.x) ─────────────────────────────────────────
@Service
public class JwtService {
private final SecretKey key;
private final long ttlMillis;
public JwtService(@Value("${app.jwt.secret}") String base64Secret,
@Value("${app.jwt.ttl-minutes:15}") long ttlMinutes) {
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret));
this.ttlMillis = ttlMinutes * 60_000;
}
public String generate(UserDetails user) {
Date now = new Date();
return Jwts.builder()
.subject(user.getUsername())
.issuedAt(now)
.expiration(new Date(now.getTime() + ttlMillis))
.signWith(key)
.compact();
}
// Verifies signature and expiry; throws JwtException if either fails
public String validateAndGetUsername(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
}
// ── The filter ───────────────────────────────────────────────────────
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService,
UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response); // not our business
return;
}
try {
String username = jwtService.validateAndGetUsername(header.substring(7));
if (SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (user.isEnabled()) {
var auth = UsernamePasswordAuthenticationToken
.authenticated(user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
}
}
} catch (ExpiredJwtException e) {
request.setAttribute("auth.error", "expired");
} catch (JwtException | IllegalArgumentException
| UsernameNotFoundException e) {
request.setAttribute("auth.error", "invalid");
}
// Never write the error here: stay unauthenticated and let the
// entry point answer 401 only where authentication is required.
chain.doFilter(request, response);
}
}
// ── Consistent JSON 401 ──────────────────────────────────────────────
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
String message = "expired".equals(request.getAttribute("auth.error"))
? "Token has expired" : "Authentication required";
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getOutputStream(), Map.of("error", message));
}
}
// ── Wiring ───────────────────────────────────────────────────────────
@Bean
SecurityFilterChain api(HttpSecurity http, JwtService jwtService,
UserDetailsService userDetailsService) throws Exception {
http
.csrf(csrf -> csrf.disable()) // safe only for header-token APIs
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/**", "/api/public/**",
"/actuator/health").permitAll()
.anyRequest().authenticated())
.exceptionHandling(e -> e
.authenticationEntryPoint(new JsonAuthenticationEntryPoint()))
.addFilterBefore(
new JwtAuthenticationFilter(jwtService, userDetailsService),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// application.yml:
// app:
// jwt:
// secret: ${JWT_SECRET} # Base64, 32+ bytes, from the environment
// ttl-minutes: 15Rate Limiting Filter
Rate limiting is a good fit for a filter: it runs before any controller work and can reject a request in microseconds. Three things separate a toy version from one you can deploy.
Bound the memory. A plain ConcurrentHashMap that gets one entry per client is a leak, and an attacker can fill it by rotating IPs. A Caffeine cache with a maximum size and expireAfterAccess evicts idle clients automatically.
Do not trust X-Forwarded-For from the open internet: any client can send it and pick a new "IP" per request. Behind a trusted reverse proxy, set server.forward-headers-strategy=native and read request.getRemoteAddr().
Place it after the JWT filter so the authenticated username is available as the key. Anchoring it after UsernamePasswordAuthenticationFilter would run it before authentication and every request would fall back to the IP key. This example needs the Guava and Caffeine dependencies. It is per-instance; with several app instances use a shared store such as Redis.
Java
public class RateLimitingFilter extends OncePerRequestFilter {
private static final double PERMITS_PER_SECOND = 10.0;
// Bounded, self-cleaning: idle clients are evicted
private final Cache<String, RateLimiter> limiters = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterAccess(Duration.ofMinutes(10))
.build();
private final ObjectMapper mapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
RateLimiter limiter = limiters.get(resolveClientKey(request),
k -> RateLimiter.create(PERMITS_PER_SECOND));
if (!limiter.tryAcquire()) { // non-blocking
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader(HttpHeaders.RETRY_AFTER, "1");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
mapper.writeValue(response.getOutputStream(),
Map.of("error", "Too Many Requests"));
return;
}
chain.doFilter(request, response);
}
private String resolveClientKey(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken)) {
return "user:" + auth.getName();
}
// Real client IP when a trusted proxy is configured (see body text)
return "ip:" + request.getRemoteAddr();
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/health");
}
}
// ── Registration: the anchor filter must be added first ──────────────
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(new RateLimitingFilter(), JwtAuthenticationFilter.class);
// application.yml (behind a trusted reverse proxy only):
// server:
// forward-headers-strategy: nativeDebugging the Filter Chain
When a request is rejected and you cannot tell why, make the chain visible. Set org.springframework.security to DEBUG to see which chain matched each request and the decisions taken; TRACE additionally lists every filter as it is invoked. The exact wording of these messages changes between Spring Security versions, so look for the pattern rather than the text.
A faster check is a test that asserts the behaviour you rely on. A MockMvc test with no token proves that your protected routes return 401 and your public ones stay open, and it will catch a broken filter order long before production does.
Avoid @EnableWebSecurity(debug = true): it dumps request details including headers, which can include credentials, and it has been deprecated in newer Spring Security versions. The logging levels give you the same insight safely.
Java
// ── application-dev.yml ──────────────────────────────────────────────
// logging:
// level:
// org.springframework.security: DEBUG
// # TRACE lists every filter as it is invoked, e.g.
// # "Invoking CsrfFilter (5/15)"
// # org.springframework.security: TRACE
//
// What to look for in the log:
// Securing GET /api/users <- a chain matched the request
// Will secure any request with [ ...filters ] <- the chain's filter list (startup)
// Failed to authorize ... with attributes ... <- AuthorizationFilter said no
// Set SecurityContextHolder to anonymous ... <- nobody authenticated the request
// ── Print the real chain at startup ──────────────────────────────────
@Component
public class SecurityChainInspector
implements ApplicationListener<ApplicationReadyEvent> {
private static final Logger log =
LoggerFactory.getLogger(SecurityChainInspector.class);
private final FilterChainProxy filterChainProxy;
public SecurityChainInspector(FilterChainProxy filterChainProxy) {
this.filterChainProxy = filterChainProxy;
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
filterChainProxy.getFilterChains().forEach(chain -> {
log.info("Security chain: {}", chain);
chain.getFilters().forEach(f ->
log.info(" {}", f.getClass().getSimpleName()));
});
}
}
// ── A test that locks the behaviour in ───────────────────────────────
@SpringBootTest
@AutoConfigureMockMvc
class SecurityChainTest {
@Autowired MockMvc mvc;
@Test
void protectedRouteWithoutTokenIs401() throws Exception {
mvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
void publicRouteIsOpen() throws Exception {
mvc.perform(get("/api/public/ping"))
.andExpect(status().isOk());
}
@Test
void garbageTokenOnProtectedRouteIs401() throws Exception {
mvc.perform(get("/api/users").header("Authorization", "Bearer nope"))
.andExpect(status().isUnauthorized());
}
}Common Problems and Fixes
These four account for most "my filter does not work" questions. Each is a mismatch between what you assumed Spring Security does and what it actually does.
Java
// ── 1. Filter runs twice / logs appear in duplicate ──────────────────
// Cause: filter is a @Component, so Boot registers it as a servlet filter
// AND you added it to the security chain.
// Fix: create it with new in the SecurityFilterChain bean, or disable
// the servlet registration (FilterRegistrationBean.setEnabled(false)).
// ── 2. You expect 401 but get 403 for a missing token ────────────────
// Cause: without formLogin()/httpBasic() there is no entry point that
// starts authentication, so Spring answers with a bare 403.
// Fix:
http.exceptionHandling(e -> e
.authenticationEntryPoint(new JsonAuthenticationEntryPoint()));
// 401 = "who are you?" (not authenticated)
// 403 = "I know who you are, you may not do this" (authenticated, no right)
// ── 3. POST/PUT/DELETE returns 403 even with a valid JWT ─────────────
// Cause: CsrfFilter is still active and the request has no CSRF token.
// Fix for a stateless, header-token API only:
http.csrf(csrf -> csrf.disable());
// Keep CSRF ON if authentication relies on cookies or a session.
// ── 4. Custom filter never runs ──────────────────────────────────────
// Check, in this order:
// a) Is the filter really in the chain? Print it with the inspector above.
// b) Does the request match this chain? A securityMatcher("/api/**") on
// another @Order(1) chain can capture the request first.
// c) Does shouldNotFilter() return true for this path?
// d) Is it anchored to a class that was never registered? addFilterAfter
// with a custom anchor fails unless that anchor was added earlier.