intermediate~2h

Refresh Tokens & Session Lifecycle

Module 13's access token expires in 15 minutes on purpose. This module is what happens next, without forcing your user to log in again every quarter hour.

Learning objectives

  • Beginner: Explain why a short-lived access token is paired with a separate, longer-lived refresh token instead of one long-lived token.
  • Intermediate: Implement a refresh endpoint that issues a new access token without forcing re-authentication.
  • Advanced: Design refresh token storage and revocation so a stolen refresh token can be detected and invalidated.

◆ The problem

A JWT can't be individually revoked once issued — Module 13 §5 established there's no database lookup on validation, which is exactly what makes JWTs fast, but also means there's no "delete this one token" operation. A long-lived token that leaks (stolen from a compromised device, intercepted) stays valid and dangerous for its entire lifetime. A short-lived token is safer if leaked, but forces the user to log in again constantly if that's the only token issued.

The standard resolution: issue two tokens at login. A short-lived access token (minutes) does the actual per-request authentication from Module 13 §5. A long-lived refresh token (days/weeks) is used only to obtain a new access token once the old one expires — never sent with normal API requests, which limits its exposure surface considerably compared to a token attached to every single call.

Two tokens issued at login: a short-lived access token for everyday requests, and a long-lived refresh token used only to mint a new access token once the old one expires.

◆ Under the hood — refresh tokens break pure statelessness, deliberately

Unlike access tokens, refresh tokens are typically stored server-side (a database row per issued token, or a hash of it) specifically because you need the ability to revoke one — on logout, or if a device is reported stolen. This is a deliberate, scoped reintroduction of server-side state, limited to the infrequently-used refresh flow, precisely so the high-frequency access-token validation path (Module 13 §5) can stay fully stateless and fast.

@Entity public class RefreshToken { @Id @GeneratedValue private Long id; private String tokenHash; // store a hash, not the raw token — same principle as passwords private Long userId; private Instant expiresAt; private boolean revoked; }

💻 Code example

@Entity public class RefreshToken { @Id @GeneratedValue private Long id; private String tokenHash; // store a hash, not the raw token — same principle as passwords private Long userId; private Instant expiresAt; private boolean revoked; }
@PostMapping("/auth/refresh") @Transactional public TokenResponse refresh(@RequestBody RefreshRequest request) { RefreshToken stored = refreshTokenRepository.findByTokenHash(hash(request.refreshToken())) .filter(rt -> !rt.isRevoked() && rt.getExpiresAt().isAfter(Instant.now())) .orElseThrow(() -> new InvalidTokenException("refresh token invalid or expired")); stored.setRevoked(true); // rotation: the token just used can never be used again User user = userRepository.findById(stored.getUserId()).orElseThrow(); String newAccessToken = jwtService.generateToken(user); RefreshToken newRefreshToken = refreshTokenRepository.save(RefreshToken.issueFor(user)); return new TokenResponse(newAccessToken, newRefreshToken.getRawToken()); }

▲ Pitfall

A common mistake is issuing a new access token on refresh but returning the SAME refresh token unchanged — leaving one refresh token valid for its entire (often long) lifetime with no way to limit a leaked token's usable window. The code above avoids this with rotation: every refresh call revokes the token it was just handed and issues a brand-new one, so a refresh token is single-use. This also gives you a compromise signal for free — if a revoked (already-rotated) refresh token is ever presented again, that's a strong sign it leaked and someone is racing you to use it; a production system would treat that as cause to revoke every refresh token for that user, not just reject the one request.

✓ Quick recap

Why can't an individual JWT access token simply be revoked? Validation is a stateless signature check with no database lookup — there's no server-side record to delete. Why are refresh tokens typically stored server-side, unlike access tokens? Specifically so they can be revoked (on logout, on rotation, or on compromise) — a deliberate, scoped exception to statelessness, kept off the high-frequency access-token validation path.

💻 Code example

@PostMapping("/auth/refresh") @Transactional public TokenResponse refresh(@RequestBody RefreshRequest request) { RefreshToken stored = refreshTokenRepository.findByTokenHash(hash(request.refreshToken())) .filter(rt -> !rt.isRevoked() && rt.getExpiresAt().isAfter(Instant.now())) .orElseThrow(() -> new InvalidTokenException("refresh token invalid or expired")); stored.setRevoked(true); // rotation: the token just used can never be used again User user = userRepository.findById(stored.getUserId()).orElseThrow(); String newAccessToken = jwtService.generateToken(user); RefreshToken newRefreshToken = refreshTokenRepository.save(RefreshToken.issueFor(user)); return new TokenResponse(newAccessToken, newRefreshToken.getRawToken()); }

Want a visual for this concept?

Generate a diagram tailored to “Refresh Tokens & Session Lifecycle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Securing Exceptions & Custom Errors← Back to all Spring Boot chapters