Stateless JWT Authentication Filter and SecurityFilterChain in Spring Boot
package com.example.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
@Component
public class JwtTokenProvider {
private final SecretKey key;
public JwtTokenProvider(@Value("${security.jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
public String getUsername(String token) {
Claims claims = Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
}
public String getRole(String token) {
Claims claims = Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token)
.getPayload();
return claims.get("role", String.class);
}
}
package com.example.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsername(token);
var authority = new SimpleGrantedAuthority("ROLE_" + tokenProvider.getRole(token));
var authentication = new UsernamePasswordAuthenticationToken(
username, null, List.of(authority));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
return header.substring(7);
}
return null;
}
}
package com.example.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider tokenProvider;
public SecurityConfig(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider);
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(ex -> ex.authenticationEntryPoint(unauthorizedEntryPoint()))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationEntryPoint unauthorizedEntryPoint() {
return (request, response, authException) ->
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
}
}
This snippet shows how a stateless JWT authentication layer is wired into Spring Security, replacing the default session-based flow with a per-request token check. The core idea is that each protected request carries a signed bearer token in the Authorization header; instead of the server keeping session state, it validates the signature and expiry on every call and populates the SecurityContext for the duration of that request only. This is the standard pattern for horizontally-scalable REST APIs where sticky sessions are undesirable.
In JwtTokenProvider, the token concerns are isolated behind a small service. It builds a signing SecretKey from a base64-encoded secret and uses the jjwt library to parseSignedClaims. The validateToken method deliberately catches JwtException and IllegalArgumentException and returns false rather than propagating — malformed, tampered, or expired tokens are treated as simply unauthenticated, not as server errors. Keeping the secret and parser configuration in one place means the filter stays thin and the signing algorithm can be changed without touching request-handling logic.
In JwtAuthenticationFilter, the class extends OncePerRequestFilter so it runs exactly once per request even when the servlet container internally forwards or includes. The filter extracts the token via resolveToken, checking for the Bearer prefix. When a valid token is present, it constructs a UsernamePasswordAuthenticationToken with the subject and authorities and stores it in the SecurityContextHolder. A crucial detail is that the filter never blocks the chain itself: if there is no token or the token is invalid, it simply calls filterChain.doFilter without setting authentication, and downstream authorization rules decide whether the request is allowed. This separation keeps the filter reusable across public and protected routes.
In SecurityConfig, the SecurityFilterChain bean expresses policy declaratively. CSRF is disabled because there are no cookies or server sessions to protect, and SessionCreationPolicy.STATELESS guarantees Spring never creates an HttpSession. The authorizeHttpRequests block whitelists /api/auth/** and the actuator health endpoint as permitAll, while everything else requires authentication. The custom filter is registered with addFilterBefore ahead of UsernamePasswordAuthenticationFilter, so the JWT check happens before the framework's default username/password machinery would run. An AuthenticationEntryPoint returns a clean 401 for unauthenticated access rather than redirecting to a login page.
A common pitfall this design avoids is leaking authentication state between requests: because the context is per-request and the session policy is stateless, there is no risk of one user's identity bleeding into another's request on a reused thread. The trade-off is that token revocation is not immediate — a valid token stays valid until it expires — which is typically mitigated with short lifetimes and a refresh-token flow. Developers reach for this pattern when building token-secured APIs consumed by SPAs or mobile clients that cannot rely on cookies.
Share this code
Here's the card — post it anywhere.