Refresh Tokens and Token Rotation
Why Refresh Tokens?
Short-lived access tokens (15–60 minutes) limit the damage if a token is stolen — it expires quickly. But forcing users to log in every hour is terrible UX.
Refresh tokens solve this: a long-lived token (7–30 days) stored securely lets the client silently obtain a new access token when the old one expires. The user stays logged in indefinitely without re-entering credentials.
sequenceDiagram
participant Client
participant AuthServer as Auth Endpoint
participant API as Protected API
Client->>AuthServer: POST /api/auth/login
AuthServer-->>Client: {accessToken: exp 15min, refreshToken: exp 7d}
Note over Client,API: Normal API usage (15 min)
Client->>API: GET /api/data\nAuthorization: Bearer {accessToken}
API-->>Client: 200 OK
Note over Client,API: Access token expires
Client->>API: GET /api/data\nAuthorization: Bearer {expiredToken}
API-->>Client: 401 Unauthorized
Note over Client,API: Silent token refresh
Client->>AuthServer: POST /api/auth/refresh\n{refreshToken}
AuthServer-->>Client: {newAccessToken, newRefreshToken}
Client->>API: GET /api/data\nAuthorization: Bearer {newAccessToken}
API-->>Client: 200 OK
Token Rotation: Every Refresh Issues a New Refresh Token
Token rotation is the critical security mechanism: every time a refresh token is used, the server issues a new refresh token and invalidates the old one.
flowchart LR
RT1[Refresh Token 1\nactive] -->|used| Invalidated1[Refresh Token 1\ninvalidated]
Invalidated1 --> RT2[Refresh Token 2\nactive]
RT2 -->|used| Invalidated2[Refresh Token 2\ninvalidated]
Invalidated2 --> RT3[Refresh Token 3\nactive]
Why rotation detects theft: If an attacker steals RT1 and uses it after the legitimate client already refreshed to RT2, the server sees that RT1 has already been used (invalidated). This is a reuse detection signal — someone has the old token. The server invalidates all tokens for that user, forcing a re-login.
sequenceDiagram
participant Legitimate as Legitimate Client
participant Attacker
participant Server
Note over Legitimate,Server: Normal rotation
Legitimate->>Server: refresh with RT1
Server-->>Legitimate: AT2 + RT2 (RT1 now invalid)
Note over Attacker,Server: Attacker steals RT1 and tries to use it
Attacker->>Server: refresh with RT1 (already used!)
Server->>Server: RT1 already invalidated → THEFT DETECTED
Server->>Server: Invalidate ALL tokens for this user
Server-->>Attacker: 401 - token reuse detected
Note over Legitimate,Server: Legitimate user forced to re-login
Database Schema
Refresh tokens must be stored in the database — unlike access tokens, they must be revocable:
CREATE TABLE refresh_tokens (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash of the token
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
device_info VARCHAR(255), -- optional: browser/device for UX
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
Never store the raw token. Store a SHA-256 hash. If the token table is compromised, attackers cannot use the hashes.
RefreshToken Entity and Repository
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(nullable = false)
private boolean revoked = false;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "device_info")
private String deviceInfo;
}
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByTokenHash(String tokenHash);
List<RefreshToken> findByUserIdAndRevokedFalse(Long userId);
int revokeAllByUserId(Long userId); // via @Modifying query
}
RefreshTokenService
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
@Value("${app.jwt.refresh-expiration-ms:604800000}") // 7 days
private long refreshTokenDurationMs;
private final RefreshTokenRepository refreshTokenRepository;
public String createRefreshToken(User user, String deviceInfo) {
// Generate a cryptographically secure random token
String rawToken = generateSecureToken();
String tokenHash = hashToken(rawToken);
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(user);
refreshToken.setTokenHash(tokenHash);
refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(refreshTokenDurationMs / 1000));
refreshToken.setDeviceInfo(deviceInfo);
refreshTokenRepository.save(refreshToken);
return rawToken; // return the raw token to the client — never the hash
}
@Transactional
public RefreshToken verifyAndRotate(String rawToken) {
String tokenHash = hashToken(rawToken);
RefreshToken token = refreshTokenRepository.findByTokenHash(tokenHash)
.orElseThrow(() -> new InvalidRefreshTokenException("Refresh token not found"));
if (token.isRevoked()) {
// REUSE DETECTED — invalidate all tokens for this user
revokeAllTokensForUser(token.getUser().getId());
throw new RefreshTokenReusedException(
"Refresh token reuse detected. All sessions invalidated."
);
}
if (token.getExpiresAt().isBefore(LocalDateTime.now())) {
token.revoke();
refreshTokenRepository.save(token);
throw new RefreshTokenExpiredException("Refresh token has expired");
}
// Rotate: revoke old token
token.revoke();
refreshTokenRepository.save(token);
// Return the revoked token — caller will create a new one
return token;
}
@Transactional
public void revokeAllTokensForUser(Long userId) {
List<RefreshToken> activeTokens =
refreshTokenRepository.findByUserIdAndRevokedFalse(userId);
activeTokens.forEach(t -> {
t.setRevoked(true);
t.setRevokedAt(LocalDateTime.now());
});
refreshTokenRepository.saveAll(activeTokens);
}
private String generateSecureToken() {
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
private String hashToken(String rawToken) {
return DigestUtils.sha256Hex(rawToken);
}
}
JwtService: Refresh Token Generation
@Service
public class JwtService {
@Value("${app.jwt.expiration-ms:900000}") // 15 min
private long jwtExpiration;
@Value("${app.jwt.refresh-expiration-ms:604800000}") // 7 days
private long refreshExpiration;
public String generateToken(UserDetails userDetails) {
return buildToken(Map.of(), userDetails.getUsername(), jwtExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
// Refresh JWT — minimal claims, just subject
return buildToken(Map.of("type", "refresh"), userDetails.getUsername(), refreshExpiration);
}
private String buildToken(Map<String, Object> claims, String subject, long expiration) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
// ... extractUsername, isTokenValid, etc. from Article 8
}
Auth Controller: Login and Refresh Endpoints
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
private final UserRepository userRepository;
private final UserDetailsService userDetailsService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@RequestBody @Valid LoginRequest request,
HttpServletRequest httpRequest
) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
AppUserDetails userDetails = (AppUserDetails) auth.getPrincipal();
User user = userRepository.findById(userDetails.getId()).orElseThrow();
String accessToken = jwtService.generateToken(userDetails);
String rawRefreshToken = refreshTokenService.createRefreshToken(
user,
httpRequest.getHeader("User-Agent") // optional device info
);
return ResponseEntity.ok(new AuthResponse(
accessToken, rawRefreshToken, userDetails.getUsername()
));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(
@RequestBody @Valid RefreshRequest request,
HttpServletRequest httpRequest
) {
// Verify, detect reuse, and rotate
RefreshToken oldToken = refreshTokenService.verifyAndRotate(request.refreshToken());
// Load user for new token generation
UserDetails userDetails =
userDetailsService.loadUserByUsername(oldToken.getUser().getUsername());
// Issue new access token and refresh token
String newAccessToken = jwtService.generateToken(userDetails);
String newRawRefreshToken = refreshTokenService.createRefreshToken(
oldToken.getUser(),
httpRequest.getHeader("User-Agent")
);
return ResponseEntity.ok(new AuthResponse(
newAccessToken, newRawRefreshToken, userDetails.getUsername()
));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(
@AuthenticationPrincipal UserDetails currentUser,
@RequestBody(required = false) LogoutRequest request
) {
if (request != null && request.refreshToken() != null) {
// Revoke the specific refresh token
refreshTokenService.revokeToken(request.refreshToken());
} else {
// Revoke all tokens (logout from all devices)
AppUserDetails appUser = (AppUserDetails) currentUser;
refreshTokenService.revokeAllTokensForUser(appUser.getId());
}
return ResponseEntity.ok().build();
}
}
Token Storage on the Client
Where to store tokens has security implications:
flowchart TD
Q[Where to store tokens?] --> WEB[Web Application]
Q --> MOB[Mobile / Desktop App]
WEB --> HC[HttpOnly Cookie\n✓ XSS-safe\n✗ CSRF risk — mitigate with SameSite=Strict]
WEB --> MEM[JavaScript Memory\n✓ No XSS theft\n✗ Lost on page refresh]
WEB --> LS[localStorage\n✗ XSS can steal token\n✗ Do not use for tokens]
MOB --> KS[OS Keychain / Keystore\n✓ Secure storage\n✓ Best practice for mobile]
Recommended: HttpOnly Cookie for Web Apps
// Set refresh token as HttpOnly cookie (inaccessible to JavaScript)
@PostMapping("/login")
public ResponseEntity<AccessTokenResponse> login(@RequestBody LoginRequest request,
HttpServletResponse response) {
// ... authenticate and generate tokens ...
// Refresh token in HttpOnly cookie
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", rawRefreshToken)
.httpOnly(true) // not accessible via JavaScript
.secure(true) // HTTPS only
.sameSite("Strict") // CSRF protection for cookies
.maxAge(Duration.ofDays(7))
.path("/api/auth/refresh") // only sent to refresh endpoint
.build();
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
// Access token in response body (stored in memory by client)
return ResponseEntity.ok(new AccessTokenResponse(accessToken));
}
// Read refresh token from cookie
@PostMapping("/refresh")
public ResponseEntity<AccessTokenResponse> refresh(
@CookieValue("refreshToken") String refreshToken
) {
// ... verify and rotate ...
}
Token Cleanup
Expired and revoked tokens accumulate in the database. Schedule cleanup:
@Component
@RequiredArgsConstructor
public class RefreshTokenCleanupJob {
private final RefreshTokenRepository refreshTokenRepository;
@Scheduled(cron = "0 0 3 * * *") // daily at 3am
@Transactional
public void deleteExpiredTokens() {
int deleted = refreshTokenRepository.deleteByExpiresAtBefore(LocalDateTime.now());
log.info("Cleaned up {} expired refresh tokens", deleted);
}
}
Summary
- Access tokens: short-lived (15 min–1 hour), stateless JWT, no revocation needed.
- Refresh tokens: long-lived (7–30 days), stored in DB as hash, fully revocable.
- Token rotation: every refresh issues a new refresh token and invalidates the old one.
- Reuse detection: if an already-invalidated refresh token is presented, all sessions for that user are revoked — this signals token theft.
- Store raw refresh tokens in HttpOnly + Secure + SameSite=Strict cookies for web apps; use OS keychain for mobile.
- Never store raw tokens in the database — always hash with SHA-256.
Next: Article 10 covers LDAP authentication — integrating Spring Security with enterprise directory services.