A refresh token is supposed to be used to obtain a new access token.
After a successful refresh, the client receives a new refresh token. The previous one becomes invalid.
That sounds straightforward.
But what happens if the old refresh token is used again?
Maybe an attacker stole it. Maybe the client retried a request. Maybe two refresh requests arrived at almost exactly the same time.
These scenarios can look similar from the server's perspective, but they don't necessarily have the same cause.
This is where refresh-token rotation becomes more interesting than simply generating a new random string.
Rotation is not just about replacing a token. It can also provide a mechanism for detecting token replay.
In this article, we'll examine how refresh-token rotation works, what happens when a previously used token appears again, and how to design the server-side behavior in a Spring Boot application.
We'll cover:
- The difference between refresh-token rotation and replay detection
- A step-by-step token reuse scenario
- How token families help track refresh-token relationships
- What the server should do when reuse is detected
- How to model the lifecycle in a database
- How to implement the critical checks in Spring Boot
- Why concurrent requests and retries complicate the design
- Which tests should protect the implementation
The examples use opaque refresh tokens stored as SHA-256 hashes. The focus is on the server-side lifecycle, not on JWT generation itself.
1. Why Rotate Refresh Tokens?
Consider an application that issues two credentials after login:
- A short-lived access token
- A longer-lived refresh token
The access token is sent to protected API endpoints. When it expires, the client uses the refresh token to obtain a new access token without asking the user to log in again.
A simplified flow looks like this:
Login
|
v
Access token + refresh token
|
v
Access token expires
|
v
Client submits refresh token
|
v
Server issues new access token
If the same refresh token remains valid for thirty days, someone who steals it may be able to keep obtaining new access tokens throughout that period.
Revoking the token can stop that behavior, but the server first needs a reason to revoke it.
Rotation reduces the period during which a particular refresh token can be reused.
Instead of allowing the same token to remain valid after a successful refresh, the server replaces it:
Refresh token A
|
| successful refresh
v
Refresh token A revoked
Refresh token B issued
The client receives token B. Token A must no longer be accepted as a valid refresh credential.
This gives each refresh token a limited role in the session lifecycle.
But there is a second benefit.
If token A appears again after it has already been consumed, the server has evidence that something unexpected is happening.
That is the foundation of replay detection.
2. Rotation and Replay Detection Are Different
These terms are related, but they describe different operations.
Refresh-token rotation means issuing a replacement refresh token and invalidating the previous one after a successful refresh.
Replay detection means recognizing that a refresh token that should no longer be usable has been presented again.
For example:
Initial state:
Token A = active
First refresh:
Token A = revoked
Token B = active
Second request using token A:
Token A = already revoked
The server should not treat the second request as another normal refresh.
It has encountered a previously consumed credential.
However, this observation does not prove that an attacker is responsible.
The original client might have sent a duplicate request. A retry could have occurred after a network timeout. Two requests could have raced.
The server knows that a token was reused. It cannot automatically know who reused it or why.
This distinction matters because a secure response must account for the possibility that a legitimate client is affected by the same behavior.
3. A Concrete Token Theft Scenario
Imagine a user signs in on a web application.
The server creates refresh token A:
Token A
|
v
Client stores A
The user continues using the application normally.
At some point, an attacker obtains a copy of token A. The attacker might have compromised a client device or obtained the token through another exposure.
The attacker now possesses the same bearer credential as the legitimate client.
The server cannot distinguish the two requests simply by comparing the token value. Both parties have the same token.
Now suppose the legitimate client refreshes first.
LEGITIMATE CLIENT
Token A
|
v
Refresh request
|
v
Server validates A
|
v
Server revokes A
|
v
Server issues B
The client stores token B.
The attacker's copy of token A has now become stale.
When the attacker submits it:
ATTACKER
Stolen token A
|
v
Refresh request
|
v
Server finds A already consumed
|
v
Replay detected
This is the key security property.
Without rotation, both parties might continue using the same refresh token until it expires or is revoked.
With rotation and appropriate replay handling, the second use can trigger a security response.
The server still cannot determine whether the legitimate client or the attacker submitted the reused token. It can, however, invalidate the active refresh-token chain to stop further refreshes through that chain.
This is the behavior described in the refresh-token protection recommendations of RFC 9700, OAuth 2.0 Security Best Current Practice.
4. Why a Single Revoked Flag Is Not Enough
A basic refresh-token table might look like this:
refresh_tokens
id
user_id
token_hash
expires_at
revoked
created_at
This is enough to determine whether an individual token exists, has expired, or has been revoked.
But consider what happens after several rotations:
Token A -> Token B -> Token C
Token A and token B are now revoked. Token C is active.
Suppose token A is presented again.
The server can discover that A is revoked. But what should it revoke next?
If the database only records independent token rows, the relationship between A, B, and C may not be available.
The server may not know that C is the current descendant of A.
That is why a more complete design associates refresh tokens with a shared session or token family.
A token family represents the lineage of tokens belonging to the same refresh-token session or authorization grant.
For example:
Family F1
Token A [revoked]
|
v
Token B [revoked]
|
v
Token C [active]
If token A is reused, the server can identify family F1 and revoke the active token in that family.
This prevents the attacker from simply switching to an earlier point in the same token chain.
The essential design decision is to preserve enough information to identify the session whose token was reused.
A token family is one way to do this. Other designs can associate tokens with an authorization grant or another server-side session identifier.
5. Model the Token Family Explicitly
A practical data model could include the following fields:
refresh_tokens
id
user_id
family_id
token_hash
parent_token_id
created_at
expires_at
revoked_at
consumed_at
The fields have distinct responsibilities.
| Field | Purpose |
|---|---|
id |
Identifies the token record |
user_id |
Associates the token with a user |
family_id |
Groups tokens belonging to the same session lineage |
token_hash |
Stores the SHA-256 hash, not the raw token |
parent_token_id |
Records which token was replaced |
created_at |
Records when the token was created |
expires_at |
Defines the token's expiration |
revoked_at |
Records when the token was invalidated |
consumed_at |
Records when the token was successfully used |
Not every implementation needs all these fields. For example, a design might represent the current state using a single status field rather than multiple timestamps.
The important part is that the data model can distinguish at least three situations:
- The token is active and eligible for use.
- The token has expired or been revoked.
- The token was previously consumed successfully and is being presented again.
That third state is particularly useful for security monitoring.
It allows the application to distinguish an ordinary invalid token from a token that has already participated in a successful rotation.
A corresponding JPA entity might look like this:
@Entity
@Table(
name = "refresh_tokens",
indexes = {
@Index(
name = "idx_refresh_token_family",
columnList = "family_id"
)
}
)
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "family_id", nullable = false)
private UUID familyId;
@Column(name = "token_hash", nullable = false, unique = true)
private String tokenHash;
@Column(name = "parent_token_id")
private UUID parentTokenId;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "consumed_at")
private Instant consumedAt;
@Column(name = "revoked_at")
private Instant revokedAt;
// Getters and setters omitted
}
This is an illustrative entity, not a complete drop-in implementation. A real application also needs its database migration, repository methods, transaction boundaries, and concurrency controls.
6. The Refresh Operation Is a State Transition
A common implementation mistake is to think of refresh as a lookup followed by token generation.
A safer mental model is that refresh is a state transition.
The incoming token must be valid in its current state. The server then atomically consumes that token and creates its replacement.
Conceptually:
Receive token A
|
v
Hash submitted token
|
v
Find token record
|
v
Is token eligible?
/ \
No Yes
| |
v v
Reject or Consume token A
handle reuse |
v
Create token B
|
v
Store hash of B
|
v
Return token B
The crucial word is atomically.
The server must not allow two concurrent requests to consume the same token successfully.
Otherwise, both requests might observe token A as active before either request marks it as consumed.
That race condition deserves its own section.
7. A Spring Boot Service: The Core Logic
The following example illustrates the control flow. Repository methods and entity methods are intentionally abstracted; their implementations must enforce the required transaction and locking behavior.
@Service
public class TokenRefreshService {
private final RefreshTokenRepository repository;
private final RefreshTokenHasher hasher;
private final RefreshTokenGenerator generator;
public TokenRefreshService(
RefreshTokenRepository repository,
RefreshTokenHasher hasher,
RefreshTokenGenerator generator) {
this.repository = repository;
this.hasher = hasher;
this.generator = generator;
}
@Transactional
public String rotate(String rawToken) {
String tokenHash = hasher.hash(rawToken);
RefreshToken current = repository
.findByTokenHashForUpdate(tokenHash)
.orElseThrow(InvalidRefreshTokenException::new);
Instant now = Instant.now();
if (current.getExpiresAt().isBefore(now)) {
throw new InvalidRefreshTokenException();
}
if (current.getRevokedAt() != null) {
handleReuse(current);
throw new InvalidRefreshTokenException();
}
if (current.getConsumedAt() != null) {
handleReuse(current);
throw new InvalidRefreshTokenException();
}
current.setConsumedAt(now);
current.setRevokedAt(now);
String replacement = generator.generate();
RefreshToken next = new RefreshToken();
next.setUserId(current.getUserId());
next.setFamilyId(current.getFamilyId());
next.setParentTokenId(current.getId());
next.setTokenHash(hasher.hash(replacement));
next.setExpiresAt(now.plus(30, ChronoUnit.DAYS));
repository.save(next);
return replacement;
}
private void handleReuse(RefreshToken token) {
repository.revokeActiveTokensInFamily(
token.getFamilyId(),
Instant.now()
);
}
}
This snippet demonstrates the intended state transitions, not a complete production-ready implementation.
In particular, it assumes:
-
findByTokenHashForUpdatelocks the relevant database row. - The repository methods participate in the same transaction.
- Family revocation is persisted atomically.
- The application distinguishes previously consumed tokens from ordinary invalid credentials.
- The transaction and exception-handling behavior is configured so that the intended revocations are actually committed.
There is an important transaction subtlety here: if handleReuse updates records and the subsequent exception causes the transaction to roll back, the revocation may never be committed.
A real implementation must deliberately handle that behavior. One option is to isolate the security response in a transaction that commits independently of the rejected refresh operation. Another is to structure the service so the revocation commits before the failure is returned. The exact approach depends on the persistence and transaction design.
Don't copy the snippet into production without implementing and testing those guarantees.
8. Why Concurrent Refresh Requests Are Dangerous
Imagine a client that sends two refresh requests nearly simultaneously.
Request A -------------------->
Server
Request B -------------------->
Both requests contain token A.
Without concurrency protection, the following sequence is possible:
Request A: reads A as active
Request B: reads A as active
Request A: consumes A
Request B: consumes A
Request A: creates B
Request B: creates C
The client may receive two different replacement tokens.
Depending on the implementation, the server may now have multiple active descendants, or the client may retain a token that has already been invalidated.
This is not merely a performance issue. It can undermine the security properties of rotation.
Possible solutions include:
- Pessimistic row-level locking
- Optimistic locking with a version column
- An atomic database update that only succeeds for an active token
- Unique constraints and carefully designed transaction boundaries
For example, an atomic consume operation could be expressed conceptually as:
UPDATE refresh_tokens
SET consumed_at = :now,
revoked_at = :now
WHERE token_hash = :token_hash
AND consumed_at IS NULL
AND revoked_at IS NULL
AND expires_at > :now;
The application must inspect the number of affected rows.
If exactly one row was updated, the request successfully consumed the token.
If zero rows were updated, the token was no longer eligible for use, had expired, or did not match the expected state. The application then needs to determine whether this represents a known token reuse event or another invalid-token condition.
The token's state must not be checked in one operation and updated in another without protecting against intervening requests.
9. The Retry Problem: Reuse Doesn't Always Mean Theft
Consider a network failure.
The client submits token A. The server successfully rotates it to token B and commits the transaction.
But the response never reaches the client.
The client still has token A.
It retries the request.
From the server's perspective, token A has already been consumed.
That looks like replay.
The server may revoke the family, including token B, to protect against a possible attack.
The legitimate user then has to authenticate again.
This is an uncomfortable result, but it illustrates the trade-off.
The server cannot safely assume that every reuse is a harmless retry. If an attacker has a copy of token A, the attacker can create a very similar sequence.
There are several ways to manage this problem, depending on the client architecture and risk model:
- Use single-flight refresh logic so one client instance does not issue multiple refresh requests concurrently.
- Ensure clients coordinate refresh operations across tabs or processes where necessary.
- Handle network errors without blindly retrying a consumed refresh token.
- Design a deliberate recovery strategy for ambiguous refresh outcomes.
- If the protocol and threat model support it, consider a carefully designed idempotency mechanism or a narrowly bounded response-recovery mechanism.
Any recovery mechanism must avoid allowing a stolen old token to retrieve the replacement credential. A grace period that simply accepts old tokens again can weaken replay detection.
The safest design is not necessarily the one that never forces a user to log in again. It is the one that makes the security and usability trade-offs explicit.
10. What Should Happen When Reuse Is Detected?
For a design using token families, the usual security response is to invalidate the active refresh-token lineage associated with the reused token.
For example:
Before reuse:
Family F1
Token A [revoked]
|
v
Token B [revoked]
|
v
Token C [active]
Token A is submitted again.
After reuse:
Family F1
Token A [revoked]
|
v
Token B [revoked]
|
v
Token C [revoked]
This stops further refreshes through token C.
The client must obtain a new authorization grant, typically by authenticating again.
The server should also record a security event, without logging the raw token.
For example:
event=refresh_token_reuse_detected
family_id=...
user_id=...
timestamp=...
Logging should be designed carefully. Avoid recording raw refresh tokens, access tokens, or other credentials. Restrict access to security logs and apply appropriate retention policies.
Whether the event triggers alerts, additional investigation, or other session revocations depends on the application's security policy.
It is also important not to claim more than the mechanism proves: reuse is evidence of an unexpected token presentation, not definitive proof of an attacker or of which party is legitimate.
11. What About Logout?
Logout and replay detection solve different problems.
Logout is an intentional request to end a session or invalidate its refresh credentials.
Replay detection is a response to a token being used after it should no longer be valid.
A family-based design can make logout straightforward: revoke the active refresh credentials belonging to the relevant family.
For example:
Logout
|
v
Identify session family
|
v
Revoke active refresh tokens
|
v
Future refresh requests fail
However, revoking refresh tokens does not automatically invalidate already issued self-contained JWT access tokens.
If an access token is valid for another ten minutes, it may remain usable until it expires unless the resource server implements an additional revocation mechanism.
This is one reason short-lived access tokens and server-side refresh-token state are often used together.
12. What Should You Test?
Refresh-token rotation is a lifecycle feature, so happy-path testing is not enough.
At minimum, test the following cases.
Successful rotation
- Create an active refresh token A.
- Submit A.
- Verify that A is no longer eligible for use.
- Verify that token B is created.
- Verify that only the hash of B is persisted.
- Verify that the response contains the raw replacement token.
Reuse of an old token
- Create token A.
- Rotate A into token B.
- Submit A again.
- Verify that the request is rejected.
- Verify that the reuse event is recorded.
- Verify that the active token family is revoked according to the application's policy.
Expired token
- Create a token with an expired timestamp.
- Submit it.
- Verify that no replacement token is issued.
Revoked token
- Create a revoked token.
- Submit it.
- Verify that the request is rejected.
Concurrent refresh requests
- Create one active token A.
- Submit two refresh requests concurrently with A.
- Verify that at most one request consumes A successfully.
- Verify that the database ends in a consistent state.
- Verify that the failure path cannot leave multiple unintended active descendants.
Transaction rollback
- Begin a refresh operation.
- Simulate a failure after consuming the old token.
- Verify that the old token and replacement token cannot be left in an inconsistent state.
- Separately test that a replay-triggered family revocation is committed as intended.
These tests are particularly important when the implementation uses database locks, transaction propagation, or atomic updates.
For integration tests, PostgreSQL through Testcontainers can help validate the behavior against the database engine used in production. A mocked repository alone cannot prove that your locking and transaction strategy behaves correctly under concurrent access.
13. Common Mistakes
A few implementation mistakes repeatedly weaken refresh-token rotation.
Mistake 1: Issuing a replacement without invalidating the old token
This is token renewal, not effective single-use rotation.
Mistake 2: Revoking the old token but forgetting its relationship to the replacement
Without lineage or equivalent session tracking, detecting reuse may not tell the server which active credentials to invalidate.
Mistake 3: Treating every invalid token as proof of theft
Expired, malformed, revoked, unknown, and previously consumed tokens are not necessarily the same event. Distinguish them internally, while avoiding unnecessary information leakage in client-facing errors.
Mistake 4: Assuming rotation prevents every replay
Rotation provides a way to detect reuse. It does not prevent an attacker from using a stolen token before the legitimate client does.
Mistake 5: Ignoring concurrency
Two simultaneous refresh requests can race unless the server enforces an atomic state transition.
Mistake 6: Assuming refresh-token revocation immediately invalidates JWT access tokens
The two credentials have separate lifecycles.
Mistake 7: Logging the raw token during debugging
Credentials should never become routine application-log data. Log event metadata, not the secret itself.
14. The Complete Security Model
A useful way to think about the design is as a set of cooperating controls.
Cryptographically random refresh token
|
v
Store SHA-256 hash
|
v
Validate server-side state
|
v
Atomically consume token
|
v
Issue replacement token
|
v
Preserve token lineage
|
v
Detect reuse of old token
|
v
Revoke active token family
Each layer addresses a different risk.
- Randomness makes token guessing impractical.
- Hashing prevents the database from storing reusable plaintext refresh credentials.
- Expiration limits how long a token remains eligible for use.
- Rotation invalidates a token after successful use.
- Lineage tracking helps identify related replacement tokens.
- Replay detection recognizes the reuse of a consumed token.
- Concurrency controls prevent simultaneous requests from breaking the state transition.
These controls complement one another. None is a substitute for the others.
Conclusion
Refresh-token rotation is often described as a simple operation: invalidate the old token and return a new one.
But the real security value appears when a token is used again after it should have been consumed.
That event can indicate a compromised credential. By retaining the relationship between replacement tokens, the server can identify the affected session and revoke its active refresh credentials.
The implementation details matter.
The state transition must be atomic. Concurrent requests must not create inconsistent token chains. Reuse handling must survive transaction failures. And the application must account for legitimate network retries without silently weakening replay protection.
The most important principle is this:
A secure refresh-token system must track not only whether a token is valid, but also how it reached its current state.
That is what turns rotation from a token-generation technique into a meaningful security control.
Further reading
- RFC 9700 — OAuth 2.0 Security Best Current Practice
- Spring Boot JWT Starter Demo
- Why You Should Hash Refresh Tokens with SHA-256
- Production-Ready JWT Authentication with Spring Boot 4
AI disclosure
This article was created with the assistance of AI and reviewed for technical accuracy by the author. The examples are illustrative; concurrency, transaction handling, and replay responses must be verified against the requirements of the application before production use.
Top comments (0)