Refresh Token Rotation and the Concurrent Request Race Condition
Refresh Token Rotation and the Concurrent Request Race Condition
The Assumption
Refresh token rotation is straightforward: when a client presents a refresh token, issue a new access token and a new refresh token, invalidate the old refresh token. Most implementations stop here.
The assumption is that refresh requests arrive sequentially. They do not.
A mobile app fires three API requests simultaneously. All three get a 401 (access token expired). The app’s HTTP interceptor catches the 401 and attempts to refresh. Three threads, same refresh token, same millisecond. The authorization server receives three refresh requests with the same token value. What happens next determines whether you have a security vulnerability or just a UX inconvenience.
The Attack
Token theft with undetected replay. An attacker steals a refresh token from a compromised device. The legitimate user and the attacker both hold the same refresh token. The attacker refreshes first, obtaining a new token pair. The legitimate user’s next refresh attempt uses the now-rotated token.
Without replay detection: the server sees a token it has already rotated. Two responses are possible:
-
Silent failure (insecure). The server rejects the old token with an
invalid_granterror. The legitimate user re-authenticates. The attacker continues undetected with the new token. Nobody knows a theft occurred. -
Grant chain invalidation (secure). The server detects that a rotated token is being reused. This is evidence of compromise: either the attacker or the legitimate user has a stale token. The server invalidates the entire grant chain (every token descended from the original authorization). Both parties are forced to re-authenticate. The security team is alerted.
Option 2 is the correct response. It causes friction (the legitimate user must log in again), but it stops the attacker immediately and creates an audit event.
The Race Condition
The race condition is different from the theft scenario but triggers the same replay detection logic.
Timeline of a concurrent refresh race:
The timeline shows the core problem: when multiple client threads receive 401s simultaneously, they all race to refresh using the same token. Thread 1 succeeds and rotates RT_1 to RT_2. Thread 2 presents the now-consumed RT_1, which looks identical to a replay attack. Without a grace period, the server invalidates the entire grant chain, causing a denial-of-service against the legitimate user. The solution is a 5-10 second grace window where the old refresh token remains valid after rotation.
If the server invalidates the entire grant chain on replay detection, the legitimate user loses their session because of a race condition in the client. This is a denial-of-service against your own users.
The Spec or Mechanism
RFC 6749 Section 10.4 recommends refresh token rotation:
The authorization server could employ refresh token rotation in which a new refresh token is issued with every access token refresh response.
The RFC does not specify how to handle concurrent rotation requests or how to implement replay detection. These are implementation decisions that have security implications.
The standard approach: a grace period for concurrent requests. When a rotated refresh token is presented, check how recently it was rotated. If within a short window (e.g., 2 seconds), treat it as a concurrent request rather than a replay attack. Issue a new token pair without invalidating the grant chain.
After the grace period expires: any use of the old refresh token is treated as replay. Invalidate the entire grant chain.
The Implementation
Naive Rotation (Vulnerable to Race Condition)
// VULNERABLE: No grace period, concurrent requests trigger false-positive replay detection
@Component
public class NaiveTokenRotationService implements OAuth2AuthorizationService {
private final ConcurrentHashMap<String, OAuth2Authorization> store = new ConcurrentHashMap<>();
@Override
public void save(OAuth2Authorization authorization) {
store.put(authorization.getId(), authorization);
}
@Override
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
return store.values().stream()
.filter(auth -> hasToken(auth, token, tokenType))
.findFirst()
.orElse(null);
}
// Problem: two threads call findByToken with the same refresh token.
// Both find it. Both proceed with rotation. The second rotation
// sees the token as already-rotated and triggers replay detection.
// The user's session is invalidated for no security reason.
}
Hardened Rotation with Grace Period
// HARDENED: Redis-backed rotation with atomic operations and grace period
@Component
public class RedisTokenRotationService implements OAuth2AuthorizationService {
private final RedisTemplate<String, byte[]> redisTemplate;
private final Duration gracePeriod = Duration.ofSeconds(2);
private static final String GRANT_PREFIX = "oauth2:grant:";
private static final String TOKEN_PREFIX = "oauth2:token:";
private static final String ROTATION_PREFIX = "oauth2:rotated:";
@Override
public void save(OAuth2Authorization authorization) {
String grantKey = GRANT_PREFIX + authorization.getId();
byte[] serialized = serialize(authorization);
redisTemplate.opsForValue().set(grantKey, serialized);
// Index tokens for lookup
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken =
authorization.getToken(OAuth2RefreshToken.class);
if (refreshToken != null) {
String tokenKey = TOKEN_PREFIX + refreshToken.getToken().getTokenValue();
redisTemplate.opsForValue().set(tokenKey,
authorization.getId().getBytes(),
refreshToken.getToken().getExpiresAt().getEpochSecond() -
Instant.now().getEpochSecond(),
TimeUnit.SECONDS);
}
}
public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
return findByRefreshToken(token);
}
// ... access token lookup
return null;
}
private OAuth2Authorization findByRefreshToken(String tokenValue) {
String tokenKey = TOKEN_PREFIX + tokenValue;
byte[] grantId = redisTemplate.opsForValue().get(tokenKey);
if (grantId != null) {
// Token is current (not yet rotated)
return loadGrant(new String(grantId));
}
// Check if token was recently rotated (within grace period)
String rotatedKey = ROTATION_PREFIX + tokenValue;
byte[] rotatedGrantId = redisTemplate.opsForValue().get(rotatedKey);
if (rotatedGrantId != null) {
// Token was rotated within grace period.
// This is likely a concurrent request, not a replay attack.
// Return the current authorization (with the new refresh token).
return loadGrant(new String(rotatedGrantId));
}
// Token not found and not within grace period.
// This is either expired or a replay attack.
return null;
}
public void rotateRefreshToken(OAuth2Authorization authorization,
String oldTokenValue,
String newTokenValue) {
// Atomic rotation: delete old token index, create new, set grace marker
redisTemplate.execute(new SessionCallback<>() {
@Override
public Object execute(RedisOperations operations) {
operations.multi();
// Remove old token index
operations.delete(TOKEN_PREFIX + oldTokenValue);
// Create new token index
operations.opsForValue().set(
TOKEN_PREFIX + newTokenValue,
authorization.getId().getBytes());
// Set grace period marker for old token
operations.opsForValue().set(
ROTATION_PREFIX + oldTokenValue,
authorization.getId().getBytes(),
gracePeriod.getSeconds(),
TimeUnit.SECONDS);
return operations.exec();
}
});
}
public void invalidateGrantChain(String grantId) {
// Nuclear option: destroy everything associated with this grant
OAuth2Authorization authorization = loadGrant(grantId);
if (authorization == null) return;
// Delete all token indexes
OAuth2Authorization.Token<OAuth2RefreshToken> refresh =
authorization.getToken(OAuth2RefreshToken.class);
if (refresh != null) {
redisTemplate.delete(TOKEN_PREFIX + refresh.getToken().getTokenValue());
}
// Delete the grant itself
redisTemplate.delete(GRANT_PREFIX + grantId);
// Log for incident response
log.warn("Grant chain invalidated due to replay detection. " +
"Grant ID: {}, Subject: {}",
grantId, authorization.getPrincipalName());
}
}
Client-Side: Preventing the Race Condition
The race condition originates at the client. The best fix is preventing concurrent refresh requests:
// Client-side refresh token mutex (TypeScript/React example)
class TokenRefreshManager {
private refreshPromise: Promise<TokenPair> | null = null;
async getValidToken(): Promise<string> {
const current = this.getStoredAccessToken();
if (current && !this.isExpired(current)) {
return current;
}
// Deduplicate: if a refresh is already in flight, wait for it
if (this.refreshPromise) {
const result = await this.refreshPromise;
return result.accessToken;
}
// Start the refresh and store the promise
this.refreshPromise = this.doRefresh();
try {
const result = await this.refreshPromise;
return result.accessToken;
} finally {
this.refreshPromise = null;
}
}
private async doRefresh(): Promise<TokenPair> {
const response = await fetch("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: this.getStoredRefreshToken(),
client_id: "frontend-shell",
}),
});
if (!response.ok) {
// Refresh failed. Force re-authentication.
this.clearTokens();
window.location.href = "/login";
throw new Error("Refresh failed");
}
const tokens = await response.json();
this.storeTokens(tokens);
return tokens;
}
}
This pattern ensures that regardless of how many concurrent 401 responses arrive, only one refresh request is sent. All waiting callers receive the same refreshed token.
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class RefreshTokenRotationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private RedisTokenRotationService tokenService;
@Test
void rotatedTokenIsRejectedAfterGracePeriod() throws Exception {
// Obtain initial token pair
MvcResult initialResult = mockMvc.perform(post("/oauth2/token")
.param("grant_type", "authorization_code")
.param("code", "valid-code")
.param("redirect_uri", "https://app.saas.example/callback")
.header("Authorization", basicAuth("frontend-shell", "secret")))
.andExpect(status().isOk())
.andReturn();
String refreshToken1 = extractRefreshToken(initialResult);
// Rotate: use refresh token to get new pair
MvcResult refreshResult = mockMvc.perform(post("/oauth2/token")
.param("grant_type", "refresh_token")
.param("refresh_token", refreshToken1)
.header("Authorization", basicAuth("frontend-shell", "secret")))
.andExpect(status().isOk())
.andReturn();
String refreshToken2 = extractRefreshToken(refreshResult);
assertThat(refreshToken2).isNotEqualTo(refreshToken1);
// Wait for grace period to expire
Thread.sleep(2100);
// Attempt to use old refresh token (replay attack simulation)
mockMvc.perform(post("/oauth2/token")
.param("grant_type", "refresh_token")
.param("refresh_token", refreshToken1)
.header("Authorization", basicAuth("frontend-shell", "secret")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("invalid_grant"));
// Verify entire grant chain was invalidated
mockMvc.perform(post("/oauth2/token")
.param("grant_type", "refresh_token")
.param("refresh_token", refreshToken2)
.header("Authorization", basicAuth("frontend-shell", "secret")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("invalid_grant"));
}
@Test
void concurrentRefreshWithinGracePeriodSucceeds() throws Exception {
String refreshToken = obtainRefreshToken();
// Simulate concurrent refresh: two requests with same token, no delay
CompletableFuture<MvcResult> request1 = CompletableFuture.supplyAsync(() ->
performRefresh(refreshToken));
CompletableFuture<MvcResult> request2 = CompletableFuture.supplyAsync(() ->
performRefresh(refreshToken));
MvcResult result1 = request1.get(5, TimeUnit.SECONDS);
MvcResult result2 = request2.get(5, TimeUnit.SECONDS);
// At least one should succeed (the first to process)
// The second should also succeed due to grace period
boolean bothSucceeded = result1.getResponse().getStatus() == 200
&& result2.getResponse().getStatus() == 200;
boolean oneSucceeded = result1.getResponse().getStatus() == 200
|| result2.getResponse().getStatus() == 200;
assertThat(oneSucceeded).isTrue();
// With grace period, both should succeed
assertThat(bothSucceeded).isTrue();
}
}
The first test proves that replay detection works: after the grace period, using a rotated token invalidates the entire grant chain. The second test proves that the grace period prevents false positives: concurrent requests within the window do not trigger security responses. Together they validate that the system distinguishes between race conditions and genuine replay attacks.