Issue and verify JWTs — stateless auth with Spring Security 6 and java-jwt.
Published September 21, 2026
JWT (JSON Web Token) enables stateless authentication — the server doesn't store sessions. Instead, each request carries a signed token that the server validates.
Header.Payload.Signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Base64(Header)
.eyJzdWIiOiJ1c2VyMTIzIiwiZXhwIjoxNjk5fQ ← Base64(Payload)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← HMAC signature
Payload (claims):
{
"sub": "user@example.com",
"iat": 1699000000,
"exp": 1699086400,
"roles": ["ROLE_USER"]
}
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
class JwtAuthenticationFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtService.isValid(token)) {
Authentication auth = jwtService.buildAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth); // populate for the rest of THIS request
}
chain.doFilter(request, response); // always pass through — let downstream authorization decide
}
}
OncePerRequestFilter guarantees its filtering logic runs exactly once per request, even in servlet environments where a request might otherwise be internally forwarded/dispatched multiple times — important for a filter validating and setting authentication state, since running it twice could waste work or, worse, interact awkwardly with state set by the first pass.
A short-lived access token (minutes) is sent with every request; a longer-lived refresh token (days/weeks) is used only to obtain a new access token once the current one expires, without requiring the user to log in again. Rotation on use — issuing a brand-new refresh token every time the old one is used to refresh — means a leaked refresh token has a bounded window of usefulness: once the legitimate client uses it and gets a new one, the old (potentially leaked) token becomes invalid.
Stateless JWTs are validated purely by signature — there's no server-side session to delete, so a compromised or logged-out token remains technically "valid" until it naturally expires, unless the server does extra work to prevent it. The standard mitigation: keep access-token expiry short (minutes, not hours), and maintain a small denylist cache (Redis, TTL matching the token's remaining validity) of explicitly revoked token IDs, checked on each request — this reintroduces a small amount of server-side state specifically to close the revocation gap, a deliberate, bounded exception to full statelessness.
A JWT's header declares which signing algorithm was used (e.g. HS256, RS256). A known attack class exploits libraries that trust this header blindly: an attacker crafts a token claiming a different, weaker algorithm than the server actually uses (a classic version swaps an asymmetric RS256-signed setup for HS256, using the public key — normally safe to expose — as the HMAC secret). Secure verification code must explicitly specify and enforce the expected algorithm server-side, never trust the algorithm named in the token's own header.
OAuth2 is an authorization framework defining how a client obtains an access token on a resource owner's behalf, via defined grant types (authorization code, client credentials, etc.) — it says nothing about the token's actual format. JWT is a token format/encoding — OAuth2 commonly issues JWTs as its access tokens (a natural pairing), but the two solve genuinely different problems and aren't interchangeable: you could run OAuth2 with opaque, non-JWT tokens, and you could use JWTs entirely outside any OAuth2 flow, as this lesson's own custom filter does.
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration-ms:86400000}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.claim("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.signWith(key())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isExpired(token);
}
private boolean isExpired(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(key())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
chain.doFilter(request, response);
}
}
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(
@RequestBody @Valid LoginRequest req) {
authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.email(), req.password()));
UserDetails user = userDetailsService.loadUserByUsername(req.email());
String token = jwtService.generateToken(user);
return ResponseEntity.ok(Map.of("token", token));
}
}
It helps to trace one request through the code above:
/auth/login. The AuthenticationManager verifies them (via UserDetailsService and the PasswordEncoder). On success, JwtService signs a short-lived access token containing the user ID, roles and an expiry, and usually issues a refresh token too.Authorization: Bearer <token>. The OncePerRequestFilter extracts it, verifies the signature and expiry, builds an Authentication object with the user's authorities, and places it in the SecurityContextHolder.@PreAuthorize checks read that Authentication. The server never looks up a session, which is the whole point of stateless tokens.Nothing in the token is secret. A JWT is signed, not encrypted by default, and anyone can Base64-decode the payload. Never put passwords, personal data or internal secrets in claims.
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage / sessionStorage | High: any injected script can read and exfiltrate the token | None | Simple, but one XSS bug leaks every user's token |
| In-memory JS variable | Lower: lost on reload | None | Pair with a refresh token cookie to restore sessions |
HttpOnly, Secure, SameSite cookie | Script can't read it | Mitigated by SameSite=Lax/Strict, plus CSRF tokens for unsafe methods | Usually the safest choice for web apps |
A common robust setup: the access token lives in memory, and the refresh token lives in an HttpOnly; Secure; SameSite=Strict cookie scoped to the refresh endpoint's path. Mobile apps should use the platform's secure storage (Keychain, Keystore).
kid) in the token header, publish the old and new public keys together during a transition, sign new tokens with the new key, and retire the old key after the longest token lifetime has passed.Q: Is a JWT encrypted? A: Not normally. A signed JWT (JWS) is only Base64URL-encoded, so anyone can read its claims. The signature just proves they weren't modified. Use JWE (encrypted JWT) if claims must be confidential, or better, keep sensitive data out of tokens.
Q: How long should access tokens live? A: Short: typically 5–15 minutes. A short lifetime limits the damage from a stolen token, since revocation is otherwise hard with stateless tokens. Refresh tokens can live much longer, because they're stored and checked server-side, and can be revoked.
Q: How do you log a user out with stateless JWTs?
A: Delete or revoke their refresh token server-side, so they can't get new access tokens, and let the client discard the current access token, which expires within minutes. For immediate revocation, keep a short-lived deny-list of token IDs (jti), or a per-user "tokens issued before this time are invalid" timestamp.
Q: Why validate the alg header, or pin the algorithm?
A: Some old libraries trusted the token's own alg value. Attackers could send alg: none (no signature), or trick an RS256 verifier into treating the public key as an HMAC secret. Always configure the expected algorithm and key on the server, and reject tokens that don't match.
Q: Sessions or JWTs: which should a typical web app use? A: For a single server-rendered or same-site app, server-side sessions with a secure cookie are simpler, and easy to revoke. JWTs help when many services must validate identity without a shared session store, or with third-party and mobile clients. Choose based on architecture, not fashion.