Spring Boot
JWT Authentication
JSON Web Tokens (JWT) provide stateless authentication for REST APIs. The server issues a signed token on login; the client sends it in the Authorization header on every subsequent request. Spring Security validates the token in a filter before the request reaches any controller. This entry covers token generation, validation, the authentication filter, refresh tokens, and token revocation.
Dependencies and Setup
Add the JJWT library for JWT creation and parsing. Configure the secret key and expiry times in application.yml. The secret must be at least 256 bits for HS256 — generate it with a secure random source and store it in an environment variable or secrets manager, never in source control.
XML
<!-- pom.xml — JJWT (Java JWT library) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
# ── application.yml ────────────────────────────────────────────────────
app:
jwt:
secret: ${JWT_SECRET} # min 64 hex chars (256-bit key)
access-token-expiry: 900 # 15 minutes (seconds)
refresh-token-expiry: 604800 # 7 days (seconds)
issuer: myapp
# ── Generate a secure secret ──────────────────────────────────────────
# openssl rand -hex 64
# Store the output in JWT_SECRET environment variableJwtService — Token Generation and Validation
JwtService centralises all JWT operations: building access tokens with claims, parsing and validating tokens, and extracting the subject. Use HS256 (HMAC-SHA-256) for single-server deployments and RS256 (RSA) for microservices where multiple services need to verify tokens without sharing a secret.
Java
@Service
@Slf4j
public class JwtService {
private final SecretKey secretKey;
private final long accessTokenExpiry;
private final long refreshTokenExpiry;
private final String issuer;
public JwtService(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.access-token-expiry}") long accessExpiry,
@Value("${app.jwt.refresh-token-expiry}") long refreshExpiry,
@Value("${app.jwt.issuer}") String issuer) {
this.secretKey = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(secret));
this.accessTokenExpiry = accessExpiry;
this.refreshTokenExpiry = refreshExpiry;
this.issuer = issuer;
}
// ── Generate access token ─────────────────────────────────────────
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
if (userDetails instanceof AuthenticatedUser u) {
claims.put("userId", u.getId());
}
return buildToken(claims, userDetails.getUsername(),
accessTokenExpiry);
}
// ── Generate refresh token (minimal claims) ───────────────────────
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Map.of(), userDetails.getUsername(),
refreshTokenExpiry);
}
private String buildToken(Map<String, Object> claims,
String subject, long expirySeconds) {
Instant now = Instant.now();
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuer(issuer)
.issuedAt(Date.from(now))
.expiration(Date.from(
now.plusSeconds(expirySeconds)))
.id(UUID.randomUUID().toString()) // jti — unique per token
.signWith(secretKey)
.compact();
}
// ── Extract subject (email) ───────────────────────────────────────
public String extractSubject(String token) {
return extractClaim(token, Claims::getSubject);
}
// ── Validate token ────────────────────────────────────────────────
public boolean isValid(String token, UserDetails userDetails) {
try {
String subject = extractSubject(token);
return subject.equals(userDetails.getUsername())
&& !isExpired(token);
} catch (JwtException ex) {
log.warn("JWT validation failed: {}", ex.getMessage());
return false;
}
}
public boolean isExpired(String token) {
return extractClaim(token, Claims::getExpiration)
.before(new Date());
}
public <T> T extractClaim(String token,
Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}JWT Authentication Filter
A OncePerRequestFilter intercepts every request, extracts the Bearer token from the Authorization header, validates it, and populates the SecurityContext. If the token is missing or invalid, the filter does nothing — Spring Security's access control handles the 401 response downstream.
Java
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
final String authHeader = request.getHeader(
HttpHeaders.AUTHORIZATION);
// ── No Bearer token — skip this filter ────────────────────────
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String email;
try {
email = jwtService.extractSubject(jwt);
} catch (JwtException ex) {
log.warn("Failed to extract JWT subject: {}",
ex.getMessage());
chain.doFilter(request, response);
return;
}
// ── Already authenticated in this request ─────────────────────
if (email != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
UserDetails userDetails =
userDetailsService.loadUserByUsername(email);
if (jwtService.isValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext()
.setAuthentication(authToken);
log.debug("Authenticated user: {} for {}",
email, request.getRequestURI());
}
}
chain.doFilter(request, response);
}
// ── Skip filter for auth endpoints ────────────────────────────────
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
return path.startsWith("/api/v1/auth/");
}
}Auth Controller — Login and Token Endpoint
The login endpoint authenticates credentials, generates an access token and a refresh token, and returns them to the client. The refresh endpoint accepts a valid refresh token and issues a new access token without requiring the user to log in again.
Java
// ── Auth DTOs ─────────────────────────────────────────────────────────
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}
public record AuthResponse(
String accessToken,
String refreshToken,
String tokenType, // "Bearer"
long expiresIn, // seconds
Long userId,
String email,
List<String> roles
) {}
public record RefreshRequest(
@NotBlank String refreshToken
) {}
// ── Auth controller ───────────────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
private final RefreshTokenService refreshTokenService;
@Value("${app.jwt.access-token-expiry}")
private long accessTokenExpiry;
// ── POST /api/v1/auth/login ───────────────────────────────────────
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@RequestBody @Valid LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(), request.password()));
AuthenticatedUser principal =
(AuthenticatedUser) auth.getPrincipal();
String accessToken = jwtService.generateAccessToken(principal);
String refreshToken = refreshTokenService.create(principal);
return ResponseEntity.ok(new AuthResponse(
accessToken,
refreshToken,
"Bearer",
accessTokenExpiry,
principal.getId(),
principal.getEmail(),
principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList()
));
}
// ── POST /api/v1/auth/refresh ─────────────────────────────────────
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@RequestBody @Valid RefreshRequest request) {
String email = refreshTokenService.validate(
request.refreshToken());
UserDetails userDetails =
userDetailsService.loadUserByUsername(email);
String newAccessToken =
jwtService.generateAccessToken(userDetails);
return ResponseEntity.ok(new AuthResponse(
newAccessToken,
request.refreshToken(), // reuse refresh token
"Bearer",
accessTokenExpiry,
((AuthenticatedUser) userDetails).getId(),
userDetails.getUsername(),
userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList()
));
}
// ── POST /api/v1/auth/logout ──────────────────────────────────────
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestBody @Valid RefreshRequest request) {
refreshTokenService.revoke(request.refreshToken());
return ResponseEntity.noContent().build();
}
}Refresh Token Service
Refresh tokens are long-lived and stored server-side (database or Redis) so they can be revoked. The access token is short-lived and stateless — it cannot be revoked, which is why its expiry should be short (5–15 minutes). Rotate refresh tokens on each use to detect token theft.
Java
// ── Refresh token entity ─────────────────────────────────────────────
@Entity
@Table(name = "refresh_tokens")
@Getter @Setter @NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 512)
private String token;
@Column(name = "user_email", nullable = false)
private String userEmail;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private boolean revoked = false;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
}
public interface RefreshTokenRepository
extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
void deleteByUserEmail(String email);
void deleteByExpiresAtBefore(Instant now);
}
// ── Refresh token service ─────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {
private final RefreshTokenRepository tokenRepo;
private final JwtService jwtService;
@Value("${app.jwt.refresh-token-expiry}")
private long refreshTokenExpiry;
// ── Create and store a refresh token ─────────────────────────────
@Transactional
public String create(UserDetails userDetails) {
String tokenValue = jwtService
.generateRefreshToken(userDetails);
RefreshToken token = new RefreshToken();
token.setToken(tokenValue);
token.setUserEmail(userDetails.getUsername());
token.setExpiresAt(
Instant.now().plusSeconds(refreshTokenExpiry));
token.setCreatedAt(Instant.now());
tokenRepo.save(token);
return tokenValue;
}
// ── Validate and return subject ───────────────────────────────────
@Transactional
public String validate(String tokenValue) {
RefreshToken token = tokenRepo.findByToken(tokenValue)
.orElseThrow(() ->
new InvalidTokenException("Refresh token not found"));
if (token.isRevoked()) {
// Possible token reuse attack — revoke all tokens for user
revokeAll(token.getUserEmail());
throw new InvalidTokenException(
"Refresh token has been revoked");
}
if (token.getExpiresAt().isBefore(Instant.now())) {
tokenRepo.delete(token);
throw new InvalidTokenException("Refresh token expired");
}
return token.getUserEmail();
}
// ── Revoke a single token ─────────────────────────────────────────
@Transactional
public void revoke(String tokenValue) {
tokenRepo.findByToken(tokenValue).ifPresent(t -> {
t.setRevoked(true);
tokenRepo.save(t);
});
}
// ── Revoke all tokens for a user (logout all devices) ────────────
@Transactional
public void revokeAll(String email) {
tokenRepo.deleteByUserEmail(email);
log.info("Revoked all refresh tokens for: {}", email);
}
// ── Scheduled cleanup of expired tokens ───────────────────────────
@Scheduled(cron = "0 0 3 * * *") // 3 AM daily
@Transactional
public void cleanExpired() {
tokenRepo.deleteByExpiresAtBefore(Instant.now());
log.info("Cleaned expired refresh tokens");
}
}JWT Exception Handling
Spring Security exceptions thrown before the controller — ExpiredJwtException, MalformedJwtException, and AuthenticationException — need dedicated handlers. Implement AuthenticationEntryPoint and AccessDeniedHandler to return structured JSON error responses instead of Spring's default HTML error page.
Java
// ── 401 AuthenticationEntryPoint ─────────────────────────────────────
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint
implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest req,
HttpServletResponse res,
AuthenticationException ex)
throws IOException {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(res.getWriter(),
ErrorResponse.of(401, "Unauthorized",
"Authentication required to access this resource",
req.getRequestURI()));
}
}
// ── 403 AccessDeniedHandler ───────────────────────────────────────────
@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
@Override
public void handle(HttpServletRequest req,
HttpServletResponse res,
AccessDeniedException ex)
throws IOException {
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(res.getWriter(),
ErrorResponse.of(403, "Forbidden",
"Insufficient permissions to access this resource",
req.getRequestURI()));
}
}
// ── Register in SecurityFilterChain ───────────────────────────────────
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
// ── Global handler for JWT exceptions from the filter ─────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ExpiredJwtException.class)
public ResponseEntity<ErrorResponse> handleExpired(
ExpiredJwtException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(401, "Token Expired",
"JWT token has expired. Please login again.",
req.getRequestURI()));
}
@ExceptionHandler({MalformedJwtException.class,
UnsupportedJwtException.class,
SignatureException.class})
public ResponseEntity<ErrorResponse> handleInvalidToken(
JwtException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(401, "Invalid Token",
"JWT token is invalid.",
req.getRequestURI()));
}
}