JWT Authentication: Anatomy of a Filter Chain
The token is only half the story. The half that actually decides whether a request is authenticated is a filter that runs before your controller ever sees the request.
A JWT is just a signed string with three parts: a header (signing algorithm + token type), a payload (the claims โ usually the subject and an expiry), and a signature that lets the server verify the payload hasn't been tampered with. The interesting engineering isn't the token format โ it's the filter that runs on every request to check it.
Where the token comes from
private String parseJwt(HttpServletRequest request) {
String headerAuth = request.getHeader("Authorization");
if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
return headerAuth.substring(7, headerAuth.length());
}
return null;
}Tip
That substring(7) is skipping past the literal string "Bearer " (7 characters, including the trailing space) to get to the raw token. If you've ever wondered exactly what that magic number means in an Authorization header โ that's it.
The filter that runs before your controller
public class AuthTokenFilter extends OncePerRequestFilter {
@Autowired private JwtUtils jwtUtils;
@Autowired private UserService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = parseJwt(request);
if (jwt != null && jwtUtils.validateJwtToken(jwt)) {
String username = jwtUtils.getUserNameFromJwtToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
logger.error("Cannot set user authentication: {}", e);
}
filterChain.doFilter(request, response);
}
}This is the piece that actually answers the question I explained in [[prompt-engineering-for-backend-developers]]'s worked example: authentication ends up non-null in SecurityContextHolder only if this filter ran, found a valid token, and populated it. No valid token, no exception thrown either โ the filter just doesn't call setAuthentication(), and the request continues unauthenticated. That's why a downstream null check on authentication is meaningful: it's not defensive paranoia, it's reading the direct result of this filter's decision.
Wiring the filter into the security chain
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeHttpRequests((requests) -> requests
.requestMatchers("/", "/auth/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/admin/**").hasAnyAuthority("ADMIN_ROLE")
.anyRequest().authenticated());
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}- STATELESS session policy โ the server keeps no session state between requests; every request re-proves identity via the token, which is what makes JWT auth horizontally scalable without sticky sessions.
- addFilterBefore(..., UsernamePasswordAuthenticationFilter.class) โ the JWT filter runs before Spring Security's own username/password filter, so token-based requests are authenticated first in the chain.
- permitAll() paths are a whitelist, not a suggestion โ anything not explicitly listed falls through to .anyRequest().authenticated().
Validating, not just decoding
public boolean validateJwtToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("Invalid JWT signature: {}", e.getMessage());
} catch (ExpiredJwtException e) {
logger.error("JWT token is expired: {}", e.getMessage());
} catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
logger.error("Invalid JWT token: {}", e.getMessage());
}
return false;
}Best Practice
parseClaimsJws() re-verifies the signature against the server's secret on every call โ it doesn't just decode the base64 payload. A tampered payload with a mismatched signature throws SignatureException here and never reaches the point of setting an Authentication object.
Inversion of Control & Dependency Injection: The Bean I Kept Recreating
NextREST API Design: DTOs, Exception Handling, and CORS
Related articles
Idempotency: "Timeout Is Not Failure โ It's an Unknown Outcome"
A production incident from a duplicate payout, and the UNIQUE constraint that actually closes the race an exists() check can't.
MyBatis Dynamic SQL: The Tags That Actually Matter
if, where, set, foreach, and choose cover almost every real query I write. A field guide with the patterns I reach for most.
Thymeleaf for Spring Boot: From th:text to Fragments
A practical path through Thymeleaf โ basic attribute binding, object selection, and the fragment pattern that kills copy-pasted navbars.