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));
}
}