How to implement stateless JWT authentication in Spring Boot REST APIs — filter chain, token generation, validation, refresh tokens, and role-based access control.
Session-based authentication made sense when your server rendered every page. In a REST API world — where your backend talks to a React frontend, a mobile app, and three microservices simultaneously — server-side sessions become a scaling problem. You need sticky sessions or a shared session store, and both add complexity and failure points you don’t want.
JWT-based stateless authentication is the answer most teams reach for. The server issues a signed token, the client sends it with every request, and the server validates the signature. No session state anywhere. Every instance of your API can validate any token. I’ve used this pattern on financial data APIs at Mosaic Smart Data and government service backends at DWP Digital — the principles are consistent regardless of scale.
Spring Security processes requests through a chain of filters. For JWT authentication, you insert a custom filter before UsernamePasswordAuthenticationFilter. This filter extracts the token from the Authorization header, validates it, and sets the SecurityContext if the token is good:
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String username = jwtTokenService.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
filterChain.doFilter(request, response);
}
}
The OncePerRequestFilter base class guarantees the filter runs exactly once per request, regardless of how many times it’s forwarded internally. The early-exit on missing or malformed headers is deliberate — public endpoints must still be accessible without a token.
The SecurityFilterChain bean wires the custom filter into the chain and disables session creation:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
SessionCreationPolicy.STATELESS prevents Spring from creating or using HTTP sessions. @EnableMethodSecurity unlocks @PreAuthorize annotations on controller methods — which we’ll use for role-based access control shortly.
The token service handles signing with a secret key, setting expiry, and extracting claims:
@Service
public class JwtTokenService {
private static final long ACCESS_TOKEN_EXPIRY = 15 * 60 * 1000L; // 15 minutes
private static final long REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1000L; // 7 days
private final SecretKey signingKey;
public JwtTokenService(@Value("${app.jwt.secret}") String secret) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.signingKey = Keys.hmacShaKeyFor(keyBytes);
}
public String generateAccessToken(UserDetails userDetails) {
return buildToken(userDetails, ACCESS_TOKEN_EXPIRY);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(userDetails, REFRESH_TOKEN_EXPIRY);
}
private String buildToken(UserDetails userDetails, long expiryMs) {
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiryMs))
.signWith(signingKey)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}
The signing key is decoded from a Base64-encoded string stored in application.yml (or, in production, injected from AWS Secrets Manager). Never hardcode it.
Access tokens should be short-lived — 15 minutes is a reasonable default. Refresh tokens last longer (days to weeks) and are used to obtain new access tokens without re-authenticating. Store refresh tokens in the database and invalidate them on logout or on suspicious activity:
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenService jwtTokenService;
private final RefreshTokenRepository refreshTokenRepository;
private final UserDetailsService userDetailsService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
UserDetails user = userDetailsService.loadUserByUsername(request.email());
String accessToken = jwtTokenService.generateAccessToken(user);
String refreshToken = jwtTokenService.generateRefreshToken(user);
refreshTokenRepository.save(new RefreshToken(request.email(), refreshToken));
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest request) {
RefreshToken stored = refreshTokenRepository.findByToken(request.refreshToken())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token"));
UserDetails user = userDetailsService.loadUserByUsername(stored.getUsername());
String newAccessToken = jwtTokenService.generateAccessToken(user);
return ResponseEntity.ok(new AuthResponse(newAccessToken, request.refreshToken()));
}
}
With @EnableMethodSecurity active, you can lock down individual endpoints by role without cluttering SecurityFilterChain:
@RestController
@RequestMapping("/api/v1/claims")
public class ClaimController {
@GetMapping
@PreAuthorize("hasAnyRole('CASEWORKER', 'ADMIN')")
public List<ClaimSummary> listClaims() { ... }
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public void deleteClaim(@PathVariable String id) { ... }
@GetMapping("/{id}")
@PreAuthorize("hasRole('CASEWORKER') and #id == authentication.name")
public ClaimDetail getClaim(@PathVariable String id) { ... }
}
The Spring Expression Language in @PreAuthorize is more expressive than URL-based rules — you can reference method parameters, the authentication principal, and bean methods for complex access logic.
Spring Security’s test support makes verifying authentication straightforward:
@WebMvcTest(ClaimController.class)
@Import(SecurityConfig.class)
class ClaimControllerTest {
@Autowired MockMvc mvc;
@MockBean JwtTokenService jwtTokenService;
@MockBean UserDetailsService userDetailsService;
@Test
void returns401_whenNoToken() throws Exception {
mvc.perform(get("/api/v1/claims"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "CASEWORKER")
void returnsOk_whenAuthorised() throws Exception {
mvc.perform(get("/api/v1/claims"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "CASEWORKER")
void returns403_whenInsufficientRole() throws Exception {
mvc.perform(delete("/api/v1/claims/123"))
.andExpect(status().isForbidden());
}
}
@WithMockUser bypasses the JWT filter entirely and injects a pre-authenticated principal — which is exactly what you want for unit-testing controller access rules without the overhead of generating real tokens.
Algorithm confusion attacks. The JJWT library used above pins the algorithm to HMAC-SHA from the key type. Never accept the algorithm from the token header — some libraries allow it, and an attacker can switch the algorithm to none (the none algorithm attack) or exploit RS256/HS256 confusion to forge tokens. Using a typed SecretKey and calling verifyWith() rather than setSigningKey() eliminates this class of vulnerability.
Secret key strength. Your HMAC-SHA256 key needs at least 256 bits of entropy. Generate it with openssl rand -base64 64, store it in Secrets Manager, and rotate it. A short or guessable key breaks the entire authentication scheme.
Token storage on the client. localStorage is accessible to any JavaScript on the page — an XSS vulnerability means token theft. Store access tokens in memory and refresh tokens in HttpOnly, Secure, SameSite=Strict cookies.
If you’re securing REST APIs with Spring Boot and want an engineer who’s done it across financial services and government platforms, get in touch.