Refresh Tokens
A stateless JWT cannot be revoked before it expires, so a leaked access token stays dangerous until its exp. Refresh tokens resolve this tension: issue a short-lived access token (minutes) plus a long-lived refresh token (days), keep the refresh token in the database so it can be revoked, and let the client trade it for a fresh access token. This page extends the JWT authentication flow with refresh, rotation, and revocation.
Why two tokens
| Access token | Refresh token | |
|---|---|---|
| Lifetime | 5–15 minutes | 7–30 days |
| Storage (server) | None — verified by signature | Persisted in a table |
| Sent to | Every protected endpoint | Only /auth/refresh |
| If stolen | Expires quickly | Can be revoked immediately |
The short access lifetime caps the blast radius of a leak; the database-backed refresh token gives you a revocation switch you otherwise lose with pure JWTs. This is the standard pattern behind “stay logged in” without keeping passwords around.
The refresh token entity
Store refresh tokens server-side so they can be looked up and revoked. Use an opaque random value (not a JWT) — it never needs to carry claims.
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token; // opaque random UUID
@Column(nullable = false)
private String username;
@Column(nullable = false)
private Instant expiresAt;
private boolean revoked;
// getters and setters omitted
}
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
void deleteByUsername(String username);
}
Tip: Hashing the stored token (like a password) means a leaked database does not hand out usable refresh tokens. For brevity the examples below store the raw value; in production hash it with a fast SHA-256 before saving and compare hashes on refresh.
Issuing both tokens at login
Update the login flow so it returns an access token and a refresh token. The access token is generated by the JwtService; the refresh token is a fresh random value persisted to the table.
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository repository;
private static final long REFRESH_DAYS = 14;
public RefreshToken create(String username) {
RefreshToken rt = new RefreshToken();
rt.setToken(UUID.randomUUID().toString());
rt.setUsername(username);
rt.setExpiresAt(Instant.now().plus(REFRESH_DAYS, ChronoUnit.DAYS));
rt.setRevoked(false);
return repository.save(rt);
}
public RefreshToken verify(String token) {
RefreshToken rt = repository.findByToken(token)
.orElseThrow(() -> new IllegalArgumentException("Unknown refresh token"));
if (rt.isRevoked() || rt.getExpiresAt().isBefore(Instant.now())) {
throw new IllegalArgumentException("Refresh token expired or revoked");
}
return rt;
}
}
The /auth/refresh endpoint
The client posts its refresh token and receives a new access token. Implement rotation: revoke the presented refresh token and issue a brand-new one in the same response, so a refresh token is never reusable.
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class TokenController {
private final RefreshTokenService refreshTokenService;
private final RefreshTokenRepository refreshTokenRepository;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
public record RefreshRequest(String refreshToken) {}
public record TokenPair(String accessToken, String refreshToken) {}
@PostMapping("/refresh")
public TokenPair refresh(@RequestBody RefreshRequest req) {
RefreshToken current = refreshTokenService.verify(req.refreshToken());
// Rotation: invalidate the used token, mint a new one.
current.setRevoked(true);
refreshTokenRepository.save(current);
UserDetails user = userDetailsService.loadUserByUsername(current.getUsername());
String access = jwtService.generateToken(user);
RefreshToken next = refreshTokenService.create(current.getUsername());
return new TokenPair(access, next.getToken());
}
}
Output of a successful refresh:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZS...",
"refreshToken": "f5b2c0d8-9a7e-4c3b-8a11-2d6e0f7c4b91"
}
Warning: Map
IllegalArgumentException(or a custom exception) to 401, never 500. A revoked or unknown refresh token is an authentication failure, not a server error. See Exception Handling.
Rotation and reuse detection
With rotation, each refresh produces a new token and revokes the old one. This unlocks reuse detection: if a refresh token that was already revoked is presented again, it almost certainly means the token was stolen and used by both the attacker and the legitimate client. The safe response is to revoke the entire family of tokens for that user, forcing a fresh login.
public void onReuseDetected(String username) {
// Nuclear option: invalidate every refresh token for the user.
refreshTokenRepository.deleteByUsername(username);
}
| Strategy | Behavior | Trade-off |
|---|---|---|
| No rotation | Same refresh token reused until expiry | Simple; a leak is usable for the full lifetime |
| Rotation | New token each refresh, old one revoked | Limits leak window; enables reuse detection |
| Rotation + reuse detection | Reuse triggers full revocation | Strongest; logs out the legitimate user too on theft |
Revocation (logout)
Because refresh tokens live in the database, logout is a real operation — flip revoked or delete the row:
@PostMapping("/logout")
public void logout(@RequestBody RefreshRequest req) {
refreshTokenRepository.findByToken(req.refreshToken())
.ifPresent(rt -> { rt.setRevoked(true); refreshTokenRepository.save(rt); });
}
The still-valid access token keeps working until its short exp — which is acceptable precisely because it is short-lived. For instant access-token revocation you would need a server-side blocklist keyed on the JWT jti, which trades away some statelessness.
Note: Schedule a periodic cleanup (Scheduling) to delete expired and revoked refresh tokens so the table does not grow unbounded.
Where to store the refresh token on the client
The refresh token is the long-lived, high-value credential. The safest place is an HttpOnly, Secure, SameSite=Strict cookie scoped to /auth/refresh, so JavaScript can never read it (XSS resistance) and it is only ever sent to the refresh path. Keep the short-lived access token in memory. See the storage discussion in the JWT Introduction.