DEV Community

Alberto Haboba
Alberto Haboba

Posted on

Rotating refresh tokens with reuse detection in Spring Boot 4 (no extra JWT library)

Short-lived JWTs, opaque hashed refresh tokens, and what to do when an old token shows up again.

Most JWT tutorials stop at "sign a token, put it in a header." Then real life shows up. Access tokens that live for days can't be revoked, and refresh tokens that never change are a permanent key for anyone who copies one.

Here's the setup I use in Spring Boot 4.1 / Java 21:

  • Access token: a JWT (HS256) that lives for 15 minutes. It's signed with Spring Security's own Nimbus encoder, so there's no extra JWT library.
  • Refresh token: a random opaque string (not a JWT) that lives for 30 days. The database stores only its SHA-256 hash.
  • Rotation: every refresh returns a new refresh token and revokes the old one.
  • Reuse detection: if a revoked refresh token is ever presented again, assume it was stolen and revoke the whole token family.

All snippets below come from a working, tested project. Let's go piece by piece.

Why rotation + reuse detection?

Say an attacker copies a user's refresh token. Without rotation, they can mint access tokens for 30 days and you'll never know.

With rotation, the attacker and the real user now hold the same token, and only one of them can use it. Whoever refreshes second presents a token that's already been rotated. That's the signal. You can't tell which party is the attacker, so you kill the whole chain (the "family") and force a fresh login. The real user gets logged out once. The attacker's access ends right there.

The table

CREATE TABLE refresh_tokens (
    id           UUID        PRIMARY KEY,
    user_id      UUID        NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    token_hash   VARCHAR(64) NOT NULL UNIQUE,
    family_id    UUID        NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL,
    revoked_at   TIMESTAMPTZ,
    replaced_by  UUID
);
CREATE INDEX ix_refresh_tokens_family  ON refresh_tokens (family_id);
CREATE INDEX ix_refresh_tokens_user    ON refresh_tokens (user_id);
CREATE INDEX ix_refresh_tokens_expires ON refresh_tokens (expires_at);
Enter fullscreen mode Exit fullscreen mode
  • token_hash: we never store the raw token. A DB dump doesn't give anyone usable tokens.
  • family_id: every token created from the same login shares it.
  • revoked_at / replaced_by: the rotation chain. A revoked token points to the token that replaced it.

Issuing tokens

The refresh token is 32 random bytes, URL-safe:

/** 256 bits of randomness, URL-safe, no padding. */
public String newRefreshTokenValue() {
    byte[] bytes = new byte[32];
    RANDOM.nextBytes(bytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

public static String sha256(String value) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("SHA-256 not available", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

"Why SHA-256 and not bcrypt like passwords?" Passwords are low-entropy, so they need a slow, salted hash. A 256-bit random token can't be brute-forced, so a fast unsalted hash is enough. It's also deterministic, which means we can look the token up by its hash with a unique index. You can't do that with salted bcrypt.

The access token uses Spring Security's JwtEncoder:

public String createAccessToken(User user) {
    Instant now = clock.instant();
    JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer(props.jwt().issuer())
            .subject(user.getId().toString())
            .issuedAt(now)
            .expiresAt(now.plus(props.jwt().accessTokenTtl()))
            .claim("email", user.getEmail())
            .claim(SecurityConfig.ROLES_CLAIM, List.of(user.getRole().name()))
            .build();
    JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
    return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
Enter fullscreen mode Exit fullscreen mode

Login and register both start a new family (UUID.randomUUID()). Refresh keeps the existing family.

The refresh flow

This is the heart of it:

/**
 * Rotates a refresh token. Presenting a token that was already rotated/revoked is treated as
 * theft: the whole token family is revoked and the client must log in again.
 */
@Transactional(noRollbackFor = ApiException.class)
public TokenResponse refresh(String rawRefreshToken) {
    Instant now = clock.instant();
    RefreshToken current = refreshTokens.findByTokenHash(TokenService.sha256(rawRefreshToken))
            .orElseThrow(AuthService::invalidRefreshToken);

    if (current.isRevoked()) {
        int revoked = refreshTokens.revokeFamily(current.getFamilyId(), now);
        log.warn("Refresh token reuse detected for user {} (family {}); revoked {} active token(s)",
                current.getUserId(), current.getFamilyId(), revoked);
        throw invalidRefreshToken();
    }
    if (current.isExpired(now)) {
        throw invalidRefreshToken();
    }
    User user = users.findById(current.getUserId())
            .filter(User::isEnabled)
            .orElseThrow(AuthService::invalidRefreshToken);

    IssuedRefreshToken next = createRefreshToken(user, current.getFamilyId(), now);
    current.revoke(now, next.entity().getId());
    return response(user, next.rawValue());
}
Enter fullscreen mode Exit fullscreen mode

Three details are easy to miss:

1. noRollbackFor = ApiException.class

When reuse is detected, we run revokeFamily(...) and then throw so the client gets a 401. Spring rolls back transactions on runtime exceptions by default, which would quietly undo the revocation. That's the one write we really care about. noRollbackFor keeps it.

2. A row lock on lookup

/** Row lock prevents two concurrent refreshes with the same token from both succeeding. */
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<RefreshToken> findByTokenHash(String tokenHash);

@Modifying
@Query("update RefreshToken t set t.revokedAt = :now where t.familyId = :familyId and t.revokedAt is null")
int revokeFamily(@Param("familyId") UUID familyId, @Param("now") Instant now);
Enter fullscreen mode Exit fullscreen mode

Without the lock, two requests carrying the same token could both read it as "not revoked" and both rotate it. You'd end up with two valid descendants. With SELECT ... FOR UPDATE, the second request waits, then sees the token as revoked.

3. Every failure gives the same answer

Unknown token, expired, revoked, disabled user: the client always gets the same 401 with the same error code (INVALID_REFRESH_TOKEN). The response doesn't tell anyone which case they hit.

Logout

Logout revokes the family, and it's idempotent:

/** Revokes the token family of the given refresh token. Idempotent: unknown tokens are ignored. */
@Transactional
public void logout(String rawRefreshToken) {
    refreshTokens.findByTokenHash(TokenService.sha256(rawRefreshToken))
            .ifPresent(t -> refreshTokens.revokeFamily(t.getFamilyId(), clock.instant()));
}
Enter fullscreen mode Exit fullscreen mode

The access token itself stays valid until it expires (at most 15 minutes). That's the trade-off you accept with stateless JWTs, and it's why the access TTL is short.

The trade-off you should know about

Strict reuse detection has a known side effect: legit races look like theft. If a client fires two refreshes with the same token at once (two browser tabs, or a retry after a network timeout), the second one finds a revoked token and the family gets killed. The user just has to log in again.

This setup chooses strictness. Some systems add a short grace window where the immediately previous token is still accepted. That's more forgiving, but it's also a small window an attacker can use. Whichever you pick, make the client refresh from one place (a single in-flight refresh promise, for example) and you'll rarely hit this.

Proving it with a test

The integration test runs against a real PostgreSQL (Testcontainers) and checks exactly the behavior described above:

@Test
void refreshRotatesTokenAndReuseRevokesTheWholeFamily() throws Exception {
    Tokens first = register(uniqueEmail());

    Tokens second = refresh(first.refreshToken());
    assertThat(second.refreshToken()).isNotEqualTo(first.refreshToken());

    // Replaying the already-rotated token = suspected theft -> 401 ...
    refreshRaw(first.refreshToken())
            .andExpect(status().isUnauthorized())
            .andExpect(jsonPath("$.code").value("INVALID_REFRESH_TOKEN"));
    // ... and the legitimately rotated token is revoked too.
    refreshRaw(second.refreshToken()).andExpect(status().isUnauthorized());
}
Enter fullscreen mode Exit fullscreen mode

That last line matters. A test that only checks "the old token fails" would pass even without family revocation.

Housekeeping

Expired tokens pile up, so an hourly job deletes the ones that expired more than a day ago:

@Scheduled(cron = "${app.jobs.refresh-token-cleanup-cron:0 0 * * * *}")
@Transactional
public void purgeExpired() {
    int deleted = refreshTokens.deleteExpiredBefore(clock.instant().minus(Duration.ofDays(1)));
    if (deleted > 0) {
        log.info("Purged {} expired refresh token(s)", deleted);
    }
}
Enter fullscreen mode Exit fullscreen mode

Bonus: don't leak which emails exist

Not refresh-specific, but it lives in the same service. On login, an unknown email still runs a password check against a dummy hash, so it takes about as long as a wrong password:

if (user == null) {
    passwordEncoder.matches(request.password(), dummyHash);
    throw invalidCredentials();
}
Enter fullscreen mode Exit fullscreen mode

Both cases return the same 401 INVALID_CREDENTIALS, and there's a test for that too.

Recap

  • Short-lived JWT access tokens, long-lived opaque refresh tokens.
  • Store only a SHA-256 hash of the refresh token and look it up by hash.
  • Rotate on every refresh, and group tokens into families.
  • A revoked token showing up again means revoke the family, and make sure that write isn't rolled back.
  • Lock the row during refresh so concurrent requests can't both win.
  • Test the second half of reuse detection, not just the first.

This is one piece of a bigger baseline I packaged as a paid source-code starter: register/login/refresh/logout, roles, PostgreSQL + Flyway, RFC 9457 error responses, OpenAPI, 30 tests with Testcontainers, Docker and GitHub Actions. If you want the whole thing instead of wiring it yourself: Spring Boot 4 API Starter (29 USD). Built by me, a Java backend engineer in Mexico City (GitHub: kodkodmx). Everything above works without buying anything.

Top comments (0)