Refresh Token Rotation and Reuse Detection in Spring Security
The first version of auth in most projects is a single JWT with a long expiry. It works, it's stateless, and it's fine right up until you need to log someone out. Then you discover that you can't — the token is valid until it expires, and nothing on your server has an opinion about it.
Here's the design I settled on for Raaqib, where a store owner can revoke an employee's access and expects it to mean something.
The split
Two tokens, doing two different jobs.
The access token is a short-lived JWT — 15 minutes in my case. It's self-contained, verified by signature on every request, and never touches the database. Nothing revokes it, and that's fine, because 15 minutes is a blast radius I can live with.
The refresh token is not a JWT. It's an opaque random string, long-lived, and it exists as a row in Postgres. Its only job is to be exchanged for a new access token. Because it's a database row, I can revoke it, and revocation is immediate.
That asymmetry is the whole idea: keep the hot path stateless, put the state where it's checked rarely.
public record TokenPair(String accessToken, String refreshToken) {}
Storing refresh tokens
A refresh token is a credential, so it goes in the database the way a password does — hashed, never in plaintext. If someone dumps the table, they shouldn't walk away with a working session for every user.
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
private UUID id;
@Column(nullable = false, unique = true)
private String tokenHash; // SHA-256 of the raw token
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private UUID familyId; // links a chain of rotations
private Instant expiresAt;
private Instant revokedAt;
}
SHA-256 rather than BCrypt here, deliberately. BCrypt is slow on purpose because passwords are low-entropy and guessable; a 256-bit random token isn't guessable, and lookups happen on every refresh. Fast hash, high entropy — the threat model is database disclosure, not brute force.
The familyId is what makes the next part work.
Rotation
Every refresh call burns the token it was given and issues a new one. A refresh token is single-use.
@Transactional
public TokenPair refresh(String rawToken) {
RefreshToken stored = repository.findByTokenHash(sha256(rawToken))
.orElseThrow(() -> new BadCredentialsException("Unknown refresh token"));
if (stored.getRevokedAt() != null) {
// This token was already used. Someone has a copy they shouldn't.
repository.revokeFamily(stored.getFamilyId(), Instant.now());
throw new BadCredentialsException("Refresh token reuse detected");
}
if (stored.getExpiresAt().isBefore(Instant.now())) {
throw new BadCredentialsException("Refresh token expired");
}
stored.setRevokedAt(Instant.now());
return issuePair(stored.getUserId(), stored.getFamilyId());
}
The interesting branch is the first one. If a token that has already been rotated shows up again, there are two possible explanations: an attacker stole it and is using it, or the attacker stole it, used it, and the legitimate client is now presenting the copy it still thinks is valid. You can't tell which — and you don't need to. Either way, someone has a token they shouldn't, so the entire family is revoked and both parties get logged out. The user re-authenticates once. The attacker gets nothing.
That's the payoff of rotation: theft of a refresh token stops being silent. Without rotation, a stolen refresh token is a permanent session and you never find out.
Where the token lives on the client
The frontend is Next.js, and this decision matters more than the token design.
The refresh token goes in an HttpOnly, Secure, SameSite=Strict cookie scoped to the refresh endpoint. JavaScript cannot read it, so an XSS bug can't exfiltrate it. The access token is held in memory in the client, not in localStorage — anything in localStorage is readable by any script that ends up on the page, and treating a short-lived token as slightly-less-catastrophic is not a security model.
The cost of in-memory storage is that a hard refresh loses the access token. That's fine: the app calls /auth/refresh on mount, the cookie goes along automatically, and a new access token comes back. Not free, but one request per page load.
The multi-tab race
Single-use refresh tokens plus a browser is a race condition waiting to happen. Two tabs, both with an expired access token, both fire /auth/refresh at the same moment. The first succeeds and rotates. The second arrives with a token that was revoked two milliseconds ago — and gets treated as reuse. Both tabs get logged out, and the user has done nothing wrong.
I fixed this on the client rather than by weakening the server rule. There's a single in-flight refresh promise, and every request that hits a 401 waits on that same promise instead of starting its own:
let inflight: Promise<string> | null = null;
export function refreshAccessToken(): Promise<string> {
inflight ??= fetch("/api/auth/refresh", { method: "POST", credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error("refresh failed");
return res.json().then((body) => body.accessToken as string);
})
.finally(() => {
inflight = null;
});
return inflight;
}
Across separate tabs a BroadcastChannel or a lock in localStorage extends the same idea. The alternative — a short grace window on the server where a just-rotated token is still accepted — works too, but it punches a hole in exactly the detection I built rotation for. I'd rather keep the server strict and make the client behave.
Revoking authority mid-session
The other thing people get wrong is stuffing roles and permissions into the access token and trusting them. It's tempting: zero database hits, fully stateless. But in Raaqib a store owner can revoke an employee's permission to apply discounts, and "it takes effect in up to 15 minutes" is not an acceptable answer for that.
So the JWT carries identity — subject, tenant, issued-at — and authorities are loaded fresh from the database on each request:
UserDetails user = userDetailsService.loadUserByUsername(jwt.getSubject());
var authentication = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities()
);
Yes, that's a database read per request. It's an indexed primary-key lookup, and it buys revocation that's actually immediate. If it ever shows up in a profile, the fix is a short-TTL cache keyed by user and invalidated on permission change — not moving the data back into the token.
This is the trade-off I'd want to talk through in an interview, because "JWTs are stateless" is repeated far more often than it's examined. A JWT is stateless. An authentication system that can revoke access is not, and pretending otherwise just means your revocation is broken.
What this buys
Logout works. A stolen refresh token gets caught the second it's used twice. A permission change lands on the next request. And the expensive part of that — the database — sits on the refresh path, which runs once every 15 minutes, not on every API call.