JWT, OAuth2 & Securing APIs

Refresh Tokens

18 min Lesson 7 of 13

Refresh Tokens

In the previous lessons you built a system that issues a JWT access token on login and validates it on every protected request. That works well, but it contains a hidden trade-off: for the token to be useful without a round-trip to the database on every request it must be stateless, which means the server cannot revoke it before it expires. Short-lived tokens (5–15 minutes) minimise the revocation window, but they also mean the user must re-login every few minutes — terrible UX.

Refresh tokens solve this tension. The idea is simple:

  • Issue a short-lived access token (5–15 minutes) — the bearer credential for API calls.
  • Issue a long-lived refresh token (days to weeks) — stored securely, used only to obtain new access tokens.
  • When the access token expires, the client presents its refresh token to a dedicated /auth/refresh endpoint and receives a fresh access token without re-prompting for credentials.
Why two tokens? The access token travels in every request header and is validated purely in-memory (no DB hit). If it is stolen the attacker can only use it for at most 15 minutes. The refresh token is used rarely, can be validated against a database record, and can be revoked server-side. This gives you both performance and real revocation.

The Refresh Flow Step by Step

  1. User logs in. Server returns { accessToken, refreshToken }. The refresh token is persisted in the database and returned to the client.
  2. Client stores the access token in memory (never localStorage for high-security apps) and the refresh token in an HttpOnly cookie or secure storage.
  3. Client sends requests with the access token in the Authorization: Bearer <token> header.
  4. When the server returns 401 Unauthorized (access token expired), the client calls POST /auth/refresh with the refresh token.
  5. Server validates the refresh token against the database (checks it exists, has not been revoked, has not expired). If valid, it issues a new access token (and optionally rotates the refresh token).
  6. Client retries the original request with the new access token.

Persisting Refresh Tokens

Unlike access tokens, refresh tokens are stateful. You must store them in the database so you can revoke them. A minimal entity looks like this:

@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 value (UUID or 256-bit hex) @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(nullable = false) private Instant expiresAt; @Column(nullable = false) private boolean revoked = false; // getters, setters, constructors omitted for brevity }
Use an opaque random value for the refresh token itself, not another JWT. A JWT refresh token cannot be revoked before its expiry without a blacklist — which defeats the purpose. An opaque token (e.g. UUID.randomUUID().toString() or SecureRandom bytes encoded as hex) lets you delete or mark it revoked in the database instantly.

The RefreshTokenService

@Service @Transactional public class RefreshTokenService { private static final Duration REFRESH_TTL = Duration.ofDays(7); private final RefreshTokenRepository refreshTokenRepo; public RefreshTokenService(RefreshTokenRepository refreshTokenRepo) { this.refreshTokenRepo = refreshTokenRepo; } public RefreshToken createFor(User user) { // rotate: revoke any existing token for this user refreshTokenRepo.revokeAllByUser(user); RefreshToken rt = new RefreshToken(); rt.setToken(UUID.randomUUID().toString()); rt.setUser(user); rt.setExpiresAt(Instant.now().plus(REFRESH_TTL)); return refreshTokenRepo.save(rt); } public RefreshToken validate(String rawToken) { RefreshToken rt = refreshTokenRepo.findByToken(rawToken) .orElseThrow(() -> new TokenException("Refresh token not found")); if (rt.isRevoked()) { throw new TokenException("Refresh token has been revoked"); } if (rt.getExpiresAt().isBefore(Instant.now())) { rt.setRevoked(true); refreshTokenRepo.save(rt); throw new TokenException("Refresh token has expired"); } return rt; } }

The Auth Controller — Login and Refresh Endpoints

@RestController @RequestMapping("/auth") public class AuthController { private final AuthenticationManager authManager; private final JwtService jwtService; private final RefreshTokenService refreshTokenService; // constructor injection omitted for brevity @PostMapping("/login") public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.email(), req.password()) ); User user = (User) auth.getPrincipal(); String accessToken = jwtService.generateAccessToken(user); RefreshToken rt = refreshTokenService.createFor(user); return ResponseEntity.ok(new AuthResponse(accessToken, rt.getToken())); } @PostMapping("/refresh") public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) { RefreshToken rt = refreshTokenService.validate(req.refreshToken()); User user = rt.getUser(); String accessToken = jwtService.generateAccessToken(user); // optional: rotate the refresh token on every use RefreshToken newRt = refreshTokenService.createFor(user); return ResponseEntity.ok(new AuthResponse(accessToken, newRt.getToken())); } @PostMapping("/logout") public ResponseEntity<Void> logout(@RequestBody RefreshRequest req) { refreshTokenService.revokeToken(req.refreshToken()); return ResponseEntity.noContent().build(); } }

Refresh Token Rotation and Reuse Detection

Notice the call to createFor(user) inside the /auth/refresh endpoint. This is refresh token rotation: every successful refresh issues a brand-new refresh token and revokes the old one. This is a critical security technique.

Why does rotation matter? If an attacker steals a refresh token and uses it, token rotation means the legitimate user's token is revoked at the same moment. The next time the legitimate user tries to refresh, validation will fail — alerting you (and potentially them) that the token was compromised. Without rotation, a stolen refresh token is valid until its natural expiry date.

Beware of race conditions with token rotation. If a mobile client fires two concurrent requests that both trigger a refresh, one of them will fail because the first rotation already revoked the token. Design your client to serialise refresh calls (only one in-flight at a time) or implement a short grace window where the old token stays valid for a few seconds after rotation.

Access Token Lifetime Recommendations

These are practical starting points — adjust based on your threat model:

  • Access token: 5–15 minutes. Stateless, validated in-memory. Short window limits stolen-token exposure.
  • Refresh token: 7–30 days. Stateful, database-backed, rotated on use. The expiresAt column in the DB is your hard deadline.
  • Absolute session limit: Set a maximum total session duration (e.g. 90 days). After that, the user must re-authenticate regardless of token activity. This prevents a single login session from living forever on a shared device.

Storing Tokens Safely on the Client

The server's token design only matters if the client stores tokens correctly:

  • Access token: keep in JavaScript memory (a module-level variable or state store). Never in localStorage or sessionStorage — XSS can read those.
  • Refresh token: send in an HttpOnly; Secure; SameSite=Strict cookie. HttpOnly makes it invisible to JavaScript; SameSite=Strict prevents CSRF. The server's /auth/refresh endpoint reads it from the cookie rather than the request body in this stricter variant.

Summary

Refresh tokens are the mechanism that lets you have both short-lived, hard-to-misuse access tokens and a smooth user experience. Access tokens stay stateless and fast; refresh tokens are stateful and revocable. Pair them with rotation to detect theft, store them server-side in a database, and combine HttpOnly cookies on the client side for the best security posture. In the next lesson you will look at OAuth2 and OpenID Connect — the industry-standard protocols that formalise exactly this kind of token lifecycle.