JWT Access and Refresh Token Flow (Complete Design)
Short-lived access tokens are safe but annoying, long-lived tokens are convenient but dangerous. The standard solution is a short access token plus a long-lived refresh token that is stored (hashed) on the server, rotated at every use and revocable on logout.
The flow
LOGIN POST /auth/login {user, password}
◄── access token (JWT, 15 min) + refresh token (random string, 7 days, HttpOnly cookie)
API CALL GET /api/orders Authorization: Bearer <access token> ─► 200
EXPIRED GET /api/orders (access token expired) ─► 401
REFRESH POST /auth/refresh (cookie carries the refresh token)
server: hash(token) ─► find in DB ─► valid and not used? ─► mark it USED
◄── NEW access token + NEW refresh token (rotation)
REUSE an old (used) refresh token is presented again ─► theft suspected ─► revoke ALL tokens of that user
LOGOUT POST /auth/logout ─► delete/revoke the refresh token; the access token just expires| Token | Lifetime | Format | Stored where |
|---|---|---|---|
| Access token | 5 to 15 minutes | Signed JWT | Client memory. Not stored on the server |
| Refresh token | Days | Random opaque string (32 bytes) | Client: HttpOnly Secure cookie. Server: only the SHA-256 HASH in the database |
Entity and repository
@Entity
@Table(name = "refresh_tokens", indexes = @Index(columnList = "tokenHash", unique = true))
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false, unique = true) private String tokenHash;
@Column(nullable = false) private String username;
@Column(nullable = false) private Instant expiresAt;
private boolean used;
private boolean revoked;
protected RefreshToken() { }
public RefreshToken(String tokenHash, String username, Instant expiresAt) {
this.tokenHash = tokenHash; this.username = username; this.expiresAt = expiresAt;
}
public String getUsername() { return username; }
public boolean isUsed() { return used; }
public boolean isRevoked() { return revoked; }
public boolean isExpired() { return expiresAt.isBefore(Instant.now()); }
public void markUsed() { this.used = true; }
public void revoke() { this.revoked = true; }
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByTokenHash(String tokenHash);
@Modifying @Transactional
@Query("update RefreshToken t set t.revoked = true where t.username = :username")
void revokeAllByUsername(@Param("username") String username);
}Refresh token service
@Service
public class RefreshTokenService {
private static final SecureRandom RANDOM = new SecureRandom();
private final RefreshTokenRepository repository;
private final Duration lifetime = Duration.ofDays(7);
public RefreshTokenService(RefreshTokenRepository repository) { this.repository = repository; }
@Transactional
public String create(String username) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); // sent to the client ONCE
repository.save(new RefreshToken(hash(token), username, Instant.now().plus(lifetime)));
return token;
}
/** Validates the token, marks it used and returns the username. Throws if invalid. */
@Transactional
public String consume(String token) {
RefreshToken stored = repository.findByTokenHash(hash(token))
.orElseThrow(() -> new BadCredentialsException("Unknown refresh token"));
if (stored.isUsed()) { // reuse = probably stolen
repository.revokeAllByUsername(stored.getUsername());
throw new BadCredentialsException("Refresh token reuse detected");
}
if (stored.isRevoked() || stored.isExpired()) throw new BadCredentialsException("Refresh token expired");
stored.markUsed();
return stored.getUsername();
}
@Transactional
public void revoke(String token) {
repository.findByTokenHash(hash(token)).ifPresent(RefreshToken::revoke);
}
private static String hash(String token) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
}Auth controller (uses the TokenService from the JWT page)
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final TokenService accessTokens; // creates the signed JWT
private final RefreshTokenService refreshTokens;
private final UserDetailsService users;
public AuthController(AuthenticationManager authManager, TokenService accessTokens,
RefreshTokenService refreshTokens, UserDetailsService users) {
this.authManager = authManager; this.accessTokens = accessTokens;
this.refreshTokens = refreshTokens; this.users = users;
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
return tokensFor(auth.getName(), roles(auth.getAuthorities()));
}
@PostMapping("/refresh")
public ResponseEntity<Map<String, String>> refresh(@CookieValue(name = "refresh_token", required = false) String token) {
if (token == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String username = refreshTokens.consume(token); // rotation: this token is now used
UserDetails user = users.loadUserByUsername(username);
return tokensFor(username, roles(user.getAuthorities()));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@CookieValue(name = "refresh_token", required = false) String token) {
if (token != null) refreshTokens.revoke(token);
ResponseCookie clear = ResponseCookie.from("refresh_token", "").path("/auth").maxAge(0).build();
return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, clear.toString()).build();
}
private ResponseEntity<Map<String, String>> tokensFor(String username, List<String> roles) {
String access = accessTokens.createToken(username, roles);
String refresh = refreshTokens.create(username);
ResponseCookie cookie = ResponseCookie.from("refresh_token", refresh)
.httpOnly(true).secure(true).sameSite("Strict").path("/auth").maxAge(Duration.ofDays(7)).build();
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(Map.of("accessToken", access));
}
private static List<String> roles(Collection<? extends GrantedAuthority> a) {
return a.stream().map(x -> x.getAuthority().replace("ROLE_", "")).toList();
}
}Design notes
- Why hash the refresh token? A database leak must not give the attacker working tokens. Same idea as password hashing (SHA-256 is enough for high-entropy random tokens).
- Why an HttpOnly cookie with path /auth? JavaScript cannot read it (XSS safe) and the browser sends it only to the auth endpoints. SameSite=Strict reduces CSRF risk.
- Why rotation? Each refresh token works once. A stolen token is detected when the real user or the thief uses the old one.
- Clean up: delete expired rows with a scheduled job.
- Access tokens cannot be revoked, so keep them short. For instant revocation you would need a deny list or opaque tokens with introspection.
Common mistake: Storing the refresh token in localStorage and never rotating it. Any script injection can steal a long-lived credential. Use an HttpOnly cookie, rotate on every use, and store only a hash on the server.
Interview questions
Why not simply make the access token last 30 days?
A stolen token would give an attacker access for 30 days and you cannot revoke a JWT. Short access tokens limit the damage.
What is refresh token rotation?
Every refresh call returns a new refresh token and invalidates the old one, so reuse of an old token reveals theft.
Where should the access token live in a browser app?
In memory (a JavaScript variable). After a page reload the app gets a new one using the refresh cookie.