Spring Boot
JWT Authentication
Step-by-step JWT authentication for Spring Boot 3 and Spring Security 6 REST APIs using JJWT 0.12: access token generation, an OncePerRequestFilter, refresh token rotation with reuse detection, hashed refresh tokens in the database, logout, and JSON 401/403 responses. Includes the security mistakes most JWT tutorials ship with, and where to store tokens on the client.
How the Flow Works
JWT authentication in Spring Boot has two tokens with different jobs.
1. The client sends email and password to POST /api/v1/auth/login.
2. The server returns a short-lived access token (15 minutes) and a long-lived refresh token (7 days).
3. The client sends the access token as Authorization: Bearer <token> on every API call. A filter validates it before the request reaches any controller.
4. When the access token expires, the client sends the refresh token to POST /api/v1/auth/refresh and receives a new access token and a new refresh token. The old refresh token is revoked (rotation).
5. On logout, the refresh token is revoked server-side.
Key design rule: the access token is stateless and cannot be revoked, so keep it short-lived. The refresh token is stored on the server so it can be revoked. The code below also marks every token with a type claim, so a refresh token can never be used as an access token.
Dependencies
Add the JJWT library for JWT creation and parsing, plus Spring Security. JJWT is split into an API artifact and two runtime artifacts. This entry targets Spring Boot 3.x (Spring Security 6) and JJWT 0.12.x, whose builder and parser API differs from the older 0.11 releases most tutorials still use.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>Configuration and Secret Key
The secret must be at least 256 bits for HS256. JJWT rejects shorter keys with a WeakKeyException at startup. The code decodes the secret as Base64, so generate it with openssl rand -base64 64 (not -hex, which produces a string that decodes to the wrong bytes). Store it in an environment variable or secrets manager, never in source control, and rotate it if it ever leaks: rotating invalidates every issued token.
yaml
# ── application.yml ────────────────────────────────────────────────────
app:
jwt:
secret: ${JWT_SECRET} # Base64-encoded, at least 32 bytes decoded
access-token-expiry: 900 # 15 minutes (seconds)
refresh-token-expiry: 604800 # 7 days (seconds)
issuer: myapp
# ── Generate a secure secret ──────────────────────────────────────────
# openssl rand -base64 64
# Store the output in the JWT_SECRET environment variableJwtService — Token Generation and Validation
JwtService centralises all JWT operations: building tokens, parsing and validating them, and checking the token type. Two details matter. First, every token carries a type claim ("access" or "refresh"), so the two cannot be swapped. Second, the JJWT parser already verifies the signature and expiry and throws ExpiredJwtException or another JwtException on failure, so there is no separate expiry check to write. Use HS256 for single-service deployments and RS256 (asymmetric keys) when multiple services must verify tokens without sharing a secret.
Java
@Service
@Slf4j
public class JwtService {
private static final String TYPE_CLAIM = "type";
private static final String TYPE_ACCESS = "access";
private static final String TYPE_REFRESH = "refresh";
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) {
// Throws WeakKeyException if the key is shorter than 256 bits
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(TYPE_CLAIM, TYPE_ACCESS);
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(TYPE_CLAIM, TYPE_REFRESH),
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, Jwts.SIG.HS256)
.compact();
}
// ── Extract subject (email). Throws JwtException if invalid/expired
public String extractSubject(String token) {
return extractClaim(token, Claims::getSubject);
}
// ── Validate an ACCESS token for a given user ─────────────────────
public boolean isValidAccessToken(String token, UserDetails userDetails) {
try {
Claims claims = parse(token);
return TYPE_ACCESS.equals(claims.get(TYPE_CLAIM, String.class))
&& claims.getSubject().equals(userDetails.getUsername())
&& userDetails.isEnabled();
} catch (JwtException | IllegalArgumentException ex) {
log.warn("JWT validation failed: {}", ex.getMessage());
return false;
}
}
// ── Validate a REFRESH token and return its subject ───────────────
public String requireRefreshToken(String token) {
Claims claims = parse(token); // signature + expiry checked here
if (!TYPE_REFRESH.equals(claims.get(TYPE_CLAIM, String.class))) {
throw new JwtException("Not a refresh token");
}
return claims.getSubject();
}
public <T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(parse(token));
}
private Claims parse(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
}
}Supporting Classes
The examples reference an AuthenticatedUser principal, and Spring Security needs a PasswordEncoder and an AuthenticationManager bean. AuthenticatedUser extends Spring's User so it carries the database id alongside the standard UserDetails fields. Passwords must be stored with BCrypt, never in plain text.
Java
// ── Principal that carries the user id ────────────────────────────────
@Getter
public class AuthenticatedUser extends User {
private final Long id;
private final String email;
public AuthenticatedUser(Long id, String email, String passwordHash,
boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
super(email, passwordHash, enabled, true, true, true, authorities);
this.id = id;
this.email = email;
}
}
// ── Beans required by the auth controller ─────────────────────────────
@Configuration
public class AuthBeansConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}JWT Authentication Filter
A OncePerRequestFilter intercepts every request, extracts the Bearer token, validates it, and populates the SecurityContext. If the token is missing or invalid, the filter does not reject the request itself. It records why the token failed in a request attribute and lets Spring Security's access rules produce the 401 downstream. This matters because exceptions thrown inside a filter never reach a @RestControllerAdvice, so the reason has to travel to the AuthenticationEntryPoint through the request.
Java
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String JWT_ERROR_ATTR = "jwt.error";
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).trim();
String email = null;
try {
email = jwtService.extractSubject(jwt);
} catch (ExpiredJwtException ex) {
request.setAttribute(JWT_ERROR_ATTR, "expired");
} catch (JwtException | IllegalArgumentException ex) {
log.warn("Invalid JWT: {}", ex.getMessage());
request.setAttribute(JWT_ERROR_ATTR, "invalid");
}
// ── Authenticate only if not already authenticated ────────────
if (email != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
try {
UserDetails userDetails =
userDetailsService.loadUserByUsername(email);
if (jwtService.isValidAccessToken(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());
} else {
request.setAttribute(JWT_ERROR_ATTR, "invalid");
}
} catch (UsernameNotFoundException ex) {
request.setAttribute(JWT_ERROR_ATTR, "invalid");
}
}
chain.doFilter(request, response);
}
// ── Skip filter for auth endpoints ────────────────────────────────
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getServletPath().startsWith("/api/v1/auth/");
}
}Auth Controller — Login, Refresh and Logout
The login endpoint authenticates credentials and returns both tokens. The refresh endpoint validates the refresh token, issues a new access token, and rotates the refresh token: the old one is revoked and a new one returned. Rotation is what makes theft detectable, because a stolen token that is used after the real client already rotated it will be found revoked.
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();
return ResponseEntity.ok(buildResponse(principal,
jwtService.generateAccessToken(principal),
refreshTokenService.create(principal)));
}
// ── POST /api/v1/auth/refresh (rotates the refresh token) ─────────
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@RequestBody @Valid RefreshRequest request) {
String email = refreshTokenService.validate(request.refreshToken());
AuthenticatedUser user = (AuthenticatedUser)
userDetailsService.loadUserByUsername(email);
String newRefreshToken =
refreshTokenService.rotate(request.refreshToken(), user);
return ResponseEntity.ok(buildResponse(user,
jwtService.generateAccessToken(user), newRefreshToken));
}
// ── POST /api/v1/auth/logout ──────────────────────────────────────
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@RequestBody @Valid RefreshRequest request) {
refreshTokenService.revoke(request.refreshToken());
return ResponseEntity.noContent().build();
}
private AuthResponse buildResponse(AuthenticatedUser user,
String accessToken,
String refreshToken) {
return new AuthResponse(
accessToken,
refreshToken,
"Bearer",
accessTokenExpiry,
user.getId(),
user.getEmail(),
user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
}
}Refresh Token Service
Refresh tokens are long-lived and stored server-side so they can be revoked. Store only a SHA-256 hash of the token, never the token itself: if the table leaks, the hashes cannot be replayed. Two details are easy to get wrong. A transaction that throws a RuntimeException is rolled back, so the revokeAll or delete calls inside validate() would be undone by the very exception that follows them. noRollbackFor keeps them. And @Scheduled does nothing unless @EnableScheduling is present on a configuration class.
Java
// ── Refresh token entity (stores a hash, not the token) ──────────────
@Entity
@Table(name = "refresh_tokens")
@Getter @Setter @NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
private String tokenHash;
@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> findByTokenHash(String tokenHash);
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.setTokenHash(sha256(tokenValue));
token.setUserEmail(userDetails.getUsername());
token.setExpiresAt(Instant.now().plusSeconds(refreshTokenExpiry));
token.setCreatedAt(Instant.now());
tokenRepo.save(token);
return tokenValue;
}
// ── Validate and return subject ───────────────────────────────────
// noRollbackFor: the revoke/delete below must survive the exception
@Transactional(noRollbackFor = InvalidTokenException.class)
public String validate(String tokenValue) {
// 1. Signature, expiry and token type (throws JwtException)
try {
jwtService.requireRefreshToken(tokenValue);
} catch (JwtException | IllegalArgumentException ex) {
throw new InvalidTokenException("Invalid refresh token");
}
// 2. Must exist in the database
RefreshToken token = tokenRepo.findByTokenHash(sha256(tokenValue))
.orElseThrow(() ->
new InvalidTokenException("Refresh token not found"));
// 3. A revoked token being presented again suggests theft
if (token.isRevoked()) {
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();
}
// ── Rotate: revoke the old token, issue a new one ─────────────────
@Transactional
public String rotate(String oldTokenValue, UserDetails userDetails) {
revoke(oldTokenValue);
return create(userDetails);
}
// ── Revoke a single token ─────────────────────────────────────────
@Transactional
public void revoke(String tokenValue) {
tokenRepo.findByTokenHash(sha256(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 (needs @EnableScheduling on a @Configuration)
@Scheduled(cron = "0 0 3 * * *") // 3 AM daily
@Transactional
public void cleanExpired() {
tokenRepo.deleteByExpiresAtBefore(Instant.now());
log.info("Cleaned expired refresh tokens");
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(
digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}JWT Exception Handling
Errors raised inside a filter happen before Spring MVC is involved, so @RestControllerAdvice never sees them. Instead, implement AuthenticationEntryPoint (401) and AccessDeniedHandler (403) and register them on the security filter chain. The entry point reads the reason the filter stored on the request, so clients can tell an expired token (refresh it) from an invalid one (log in again). Exceptions thrown inside controllers, such as bad credentials on login or an invalid refresh token, are still handled by a normal @RestControllerAdvice.
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 {
String reason = (String) req.getAttribute(
JwtAuthenticationFilter.JWT_ERROR_ATTR);
String title = "Unauthorized";
String message = "Authentication required to access this resource";
if ("expired".equals(reason)) {
title = "Token Expired";
message = "Access token has expired. Use the refresh token.";
} else if ("invalid".equals(reason)) {
title = "Invalid Token";
message = "The access token is invalid.";
}
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(res.getWriter(),
ErrorResponse.of(401, title, message, 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()));
}
}
// ── Security configuration ────────────────────────────────────────────
@Configuration
@EnableWebSecurity
@EnableScheduling
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
private final JwtAuthenticationEntryPoint authEntryPoint;
private final JwtAccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http)
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // safe only for header-based tokens
.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();
}
}
// ── Controller-level handlers (login and refresh errors) ──────────────
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler({BadCredentialsException.class,
InvalidTokenException.class})
public ResponseEntity<ErrorResponse> handleAuthFailure(
RuntimeException ex, HttpServletRequest req) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.of(401, "Unauthorized",
"Invalid credentials or token.",
req.getRequestURI()));
}
}Where to Store Tokens on the Client
Storage is a trade-off, and the choice changes your security configuration.
- localStorage: easy, but any XSS vulnerability lets an attacker read the tokens. Avoid it for the refresh token.
- Access token in memory (a JavaScript variable): lost on page reload, but safe from persistent theft. Restore it by calling the refresh endpoint on startup.
- Refresh token in an HttpOnly, Secure, SameSite cookie: JavaScript cannot read it, so XSS cannot steal it. Scope the cookie path to /api/v1/auth.
If you move any token into a cookie, the csrf().disable() line above is no longer automatically safe. Cookies are sent by the browser on its own, so you need SameSite=Strict (or Lax) plus CSRF protection on the endpoints that read the cookie. Pure Authorization-header APIs are not affected, which is why csrf is disabled in the example.
Common Mistakes
1. Letting a refresh token act as an access token. Both are signed with the same key, so without a type claim a stolen 7-day refresh token works as a Bearer token on every endpoint.
2. Claiming refresh token rotation but reusing the same token. Rotation means revoke the old token and issue a new one on every refresh.
3. Storing refresh tokens in plain text. Store a SHA-256 hash.
4. Rolling back your own cleanup. Throwing a RuntimeException inside a @Transactional method undoes the revoke or delete done just before it. Use noRollbackFor.
5. Expecting @RestControllerAdvice to catch filter exceptions. It only handles exceptions from controllers. Use AuthenticationEntryPoint and AccessDeniedHandler for filter-level failures.
6. Weak or mismatched secrets. The key must be at least 256 bits, and it must be generated in the same encoding the code decodes (Base64 here).
7. Long-lived access tokens. They cannot be revoked, so 5 to 15 minutes is the safe range.
8. Putting sensitive data in claims. A JWT is signed, not encrypted: anyone can decode the payload. Keep it to ids, roles and expiry.
9. Forgetting @EnableScheduling, so the cleanup job never runs and the table grows forever.
10. Skipping the enabled check. A disabled or deleted user keeps a valid access token until it expires unless the filter reloads and checks the user.
FAQ
How do I log a user out if JWTs are stateless?
Revoke the refresh token server-side so no new access tokens can be issued. The current access token stays valid until it expires, which is why it must be short-lived. For instant revocation, keep a blocklist of token ids (the jti claim) in Redis until their expiry.
Should I use HS256 or RS256?
HS256 is simpler and fine when one service both issues and verifies tokens. Use RS256 when other services must verify tokens but must not be able to issue them.
Why do I get 403 instead of 401 for a missing token?
Without a configured AuthenticationEntryPoint, Spring Security can return 403 for anonymous requests. Register the entry point shown above.
Can I use Spring Security's built-in OAuth2 resource server instead?
Yes. spring-boot-starter-oauth2-resource-server validates JWTs with far less code. A custom filter, as shown here, gives you full control over claims, token types and refresh handling when you issue the tokens yourself.
Where should the access token go in requests?
In the Authorization header as Bearer followed by the token. Do not put tokens in URL query strings, because URLs end up in server logs and browser history.