JWT with Spring Security
A complete stateless JWT authentication flow — issuing a token on login and validating it with a custom filter.
Why JWT for a stateless API
A traditional session-based login stores authentication state on the server (a session, tied to a cookie) — every request after login relies on the server remembering who you are. That doesn't scale cleanly across multiple server instances without a shared session store, and it doesn't fit non-browser clients well.
A JSON Web Token (JWT) flips this: after a successful login, the server issues a signed token containing the user's identity (and optionally roles), and the client sends that token back on every subsequent request, typically as an Authorization: Bearer <token> header. The server verifies the token's signature and reads the claims directly — no server-side session storage needed at all. This is what "stateless authentication" means in practice.
1. POST /auth/login {email, password}
│
v
Server verifies credentials, issues a signed JWT
│
v
2. Client stores the token, sends it on every future request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
│
v
3. A custom filter on the server validates the token's signature + expiry,
and sets the SecurityContext for that request — no session lookup involved
Issuing a token on login
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMillis = 1000 * 60 * 60; // 1 hour
public JwtService(@Value("${jwt.secret}") String secret) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String email, List<String> roles) {
return Jwts.builder()
.subject(email)
.claim("roles", roles)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMillis))
.signWith(key)
.compact();
}
public String extractEmail(String token) {
return parseClaims(token).getSubject();
}
public boolean isValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
String token = jwtService.generateToken(request.email(), roles);
return ResponseEntity.ok(Map.of("token", token));
}
}
record LoginRequest(String email, String password) {}
authenticationManager.authenticate(...) is where the actual credential check happens — it delegates to the UserDetailsService and PasswordEncoder beans described earlier, and throws AuthenticationException (translated by Spring Security into a 401) on bad credentials.
Validating the token on every subsequent request
A custom filter reads the Authorization header, validates the token, and — if valid — populates the SecurityContext so the rest of the filter chain (and @PreAuthorize checks) see an authenticated request:
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final AppUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, AppUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response); // no token — let later filters/authorization decide
return;
}
String token = header.substring(7);
if (jwtService.isValid(token)) {
String email = jwtService.extractEmail(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
var authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
Registering the filter in the SecurityFilterChain, ahead of Spring Security's built-in username/password filter, and disabling session creation entirely since there's no session to maintain:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // no cookies involved, so CSRF protection doesn't apply
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
SessionCreationPolicy.STATELESS tells Spring Security never to create or use an HttpSession — every request must carry its own proof of identity (the JWT), which is the whole point of a stateless design.
Common mistakes
- Storing the JWT signing secret in source code instead of externalized configuration/secrets management — anyone with the secret can forge valid tokens.
- Setting no expiration (or an excessively long one) on issued tokens — a stolen token with no expiry is valid forever, since there's no server-side session to revoke.
- Putting sensitive data (passwords, full profile details) inside JWT claims — the payload is only signed, not encrypted, and is trivially readable by anyone who has the token.
- Forgetting
SessionCreationPolicy.STATELESS, which leaves the app creating sessions anyway alongside JWT validation — inconsistent and unnecessary for a genuinely stateless design.