Skip to main content

Security

JWT and refresh token rotation: the part we often forget

A short-lived access token is good practice. Without rotation and reuse detection on the refresh side, a stolen token stays exploitable silently for weeks.

- 9 min read

JWT tokens are widely used to protect APIs, with a now well-known rule: a short-lived access token and a refresh token to obtain new ones. The most neglected part sits in the second: its lifespan, storage and renewal cycle.

The problem with long-lived refresh tokens

A refresh token valid for thirty days and reusable at will is an almost permanent key. If it leaks, through a log, a compromised workstation or a poorly chosen browser storage, the attacker keeps access until expiry. Without fine-grained logging, nobody notices.

The principle: single use and reuse detection

Rotation means issuing a new refresh token on every use and immediately invalidating the previous one. Each token belongs to a family, that is, a chain of renewals originating from the same initial authentication.

If an already used token is presented again, two scenarios are possible: a legitimate network replay, or theft. Treating it as theft and revoking the whole family is the only reasonable answer: the legitimate user re-authenticates, and the attacker loses access.

Never store the token in clear text

A refresh token is stored as a hash, never in clear text. A database read by an attacker must not allow replaying a session.

create table refresh_token (
    id          uuid primary key,
    family_id   uuid not null,
    user_id     uuid not null,
    token_hash  char(64) not null unique,
    issued_at   timestamptz not null,
    expires_at  timestamptz not null,
    used_at     timestamptz,
    revoked_at  timestamptz
);

create index ix_refresh_token_family on refresh_token (family_id);
create index ix_refresh_token_user on refresh_token (user_id, expires_at);

Server-side implementation

The refresh service checks the hash, verifies token state, then revokes and replaces. Exceptions are distinct because they are handled differently: an expired token leads to re-authentication, detected reuse raises a security alert.

@Service
class RefreshTokenService {

    private final RefreshTokenRepository repository;
    private final TokenIssuer tokenIssuer;

    RefreshTokenService(RefreshTokenRepository repository, TokenIssuer tokenIssuer) {
        this.repository = repository;
        this.tokenIssuer = tokenIssuer;
    }

    @Transactional
    public IssuedTokens rotate(String presentedToken, String deviceId) {
        RefreshToken current = repository.findByHash(TokenHasher.sha256(presentedToken))
                .orElseThrow(InvalidRefreshTokenException::new);

        if (current.isUsed() || current.isRevoked()) {
            repository.revokeFamily(current.familyId());
            throw new TokenReuseDetectedException(current.userId(), deviceId);
        }
        if (current.isExpired()) {
            throw new ExpiredRefreshTokenException();
        }

        repository.markUsed(current.id());
        return tokenIssuer.issueForFamily(current.familyId(), current.userId());
    }
}

The operations are transactional: marking a token used and issuing a new one must succeed or fail together, otherwise a mid-flight failure leaves two valid tokens.

RiskWithout rotationWith rotation and detection
Stolen tokenSilent access until expiryAccess revoked on first reuse
Incident detectionAfter the fact, rarelyImmediate security event
Exposure windowUp to thirty daysMinutes to a few hours
Global revocationHard without storageRevocation per family or per user
A single-use refresh token turns token theft into a detectable incident instead of silent, permanent access.

The browser case

In a web application the refresh token belongs in a cookie carrying the HttpOnly, Secure and SameSite=Strict attributes, with a path restricted to the refresh endpoint. Browser local storage should be avoided: any script running on the page can read it, including a compromised dependency.

Cross-site request forgery protection remains necessary, as a session-bound anti-CSRF token verified on the refresh endpoint only.

What to check before production

Four points cover the essentials: access token lifetime stays short; the refresh token is single-use and stored as a hash; any reuse revokes the family and raises a security event; logout explicitly revokes the family server-side instead of merely deleting the token from the browser.

  • JWT
  • OAuth 2.1 and OIDC
  • API security
  • Spring Boot

Related articles