Implement stateless JWT authentication: generate tokens on login, validate on every request.
Published March 2, 2025
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>
@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));
}
}