How to handle concurrency with OAuth token refreshes
How to implement OAuth token refresh logic without race conditions or concurrency bugs
OAuth 2.0 introduced refresh tokens to solve a fundamental security problem: short-lived access tokens that expire quickly. While this improves security, it creates a new challenge for developers building integrations: handling token refreshes without introducing race conditions or concurrency bugs. This is just one of the many complexities that make OAuth implementation challenging.
In this guide, we'll explore the concurrency problems that arise with OAuth token refreshes, how to detect them, and the best practices for implementing robust token refresh logic that works reliably in production.
How OAuth token refresh works
OAuth 2.0 access tokens are designed to be short-lived for security reasons. Most APIs issue access tokens that expire within 1-2 hours, requiring your application to periodically refresh them using a longer-lived refresh token.
Here's a typical token refresh flow:
- Your application makes an API request with an access token
- The API responds with a 401 Unauthorized error, indicating the token has expired
- Your application exchanges the refresh token for a new access token
- The new access token is used for subsequent API requests
A typical token refresh response looks like this:
OAuth Token Refresh Response
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "def50200a1b2c3d4e5f6...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}
The expires_in
field tells you how long the new access token will be valid. 3600 seconds (1 hour) is a common value.
Potential concurrency problems
Concurrency problems with OAuth token refreshes can manifest in two critical ways:
Problem 1: API requests with stale tokens
When your application detects that a token needs refreshing, there's a window where other API requests might still be using the old, expired token. This happens because:
- Multiple threads or processes might be making API requests simultaneously
- The token refresh process takes time (from 100ms to seconds for some APIs)
- During this refresh window, other requests continue using the cached expired token
This leads to unnecessary API failures and poor user experience.
Problem 2: Race conditions in token refresh
More critically, multiple processes might attempt to refresh the same token simultaneously. This creates a race condition where:
- Process A detects an expired token and starts refreshing
- Process B also detects the same expired token and starts refreshing
- Both processes make refresh requests to the OAuth provider
- The last process to complete might overwrite the "good" new token with an already-expired one
The consequences can be severe. In the worst case, you might lose your valid refresh token, making it impossible to refresh the access token in the future and forcing users to re-authenticate.
API-specific refresh behaviors
Different APIs handle token refreshes differently, which affects how concurrency issues manifest:
Single access token per client: Some APIs (like GitHub) only issue one access token per OAuth client at a time. If you refresh the token again, you get the same access token back. This reduces the risk of losing tokens but doesn't eliminate race conditions.
New refresh tokens on each refresh: Many APIs issue a new refresh token with each refresh. In this case, a race condition could lead to the loss of your valid refresh token, making future refreshes impossible.
Stable refresh tokens: Some APIs keep refresh tokens stable across refreshes, which reduces the risk of losing the refresh token but doesn't eliminate the race condition problem.
How to detect concurrency issues
Since race conditions are inherently timing-dependent, they can be subtle and hard to reproduce. Look for these warning signs:
Temporary API failures: API requests failing with "access token invalid" or similar error messages, even when you know the token should be valid.
Refresh token revocation errors: Access token refreshes failing with "revoked refresh token", or invalid_grant
messages. While refresh tokens can be revoked for many reasons, frequent revocation errors might indicate race conditions. For specific guidance on handling these errors, see our guides on Google OAuth invalid_grant errors and Salesforce OAuth refresh token issues.
Intermittent authentication failures: Users reporting that integrations work sometimes but fail at other times, especially under high load.
Token expiration inconsistencies: Tokens expiring sooner than expected or behaving inconsistently across different parts of your application.
The key insight is that these issues often appear under load or in production environments where multiple processes are running simultaneously. They're much harder to detect in development or testing environments with single-threaded execution.
These concurrency challenges are part of the broader infrastructure complexity teams face when building integrations.
Solution: Implementing proper locking
To solve both concurrency problems, you need to ensure that:
- No API requests use a token while it's being refreshed
- Only one refresh process runs at a time for each token
Both requirements can be satisfied with a locking mechanism. The following sections show a sample implementation of this solution in TypeScript.
Basic locking approach
The following code uses an in-memory map to track ongoing refreshes per connection.
If a refresh is already in progress for a given token, other requests will wait for it to complete, ensuring only one refresh happens at a time and no API requests use a stale token.
Basic Token Manager with Locking
class TokenManager {
private refreshLocks = new Map>();
async getValidToken(connectionId: string): Promise {
// Check if a refresh is already in progress
const existingRefresh = this.refreshLocks.get(connectionId);
if (existingRefresh) {
return await existingRefresh;
}
const token = await this.getStoredToken(connectionId);
// Check if token needs refresh (expires within 5 minutes)
if (this.isTokenExpiringSoon(token)) {
const refreshPromise = this.refreshToken(connectionId);
this.refreshLocks.set(connectionId, refreshPromise);
try {
const newToken = await refreshPromise;
return newToken;
} finally {
this.refreshLocks.delete(connectionId);
}
}
return token.access_token;
}
private async refreshToken(connectionId: string): Promise {
const storedToken = await this.getStoredToken(connectionId);
const response = await fetch('https://api.provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedToken.refresh_token,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
})
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.statusText}`);
}
const newToken = await response.json();
await this.storeToken(connectionId, newToken);
return newToken.access_token;
}
}
Distributed locking with Redis
For applications running multiple instances, you'll need a distributed locking mechanism:
Distributed Token Manager with Redis
import Redis from 'ioredis';
class DistributedTokenManager {
private redis = new Redis(process.env.REDIS_URL);
async getValidToken(connectionId: string): Promise {
const lockKey = `token_refresh_lock:${connectionId}`;
const lockValue = Date.now().toString();
// Try to acquire lock with 30-second expiration
const lockAcquired = await this.redis.set(lockKey, lockValue, 'PX', 30000, 'NX');
if (lockAcquired) {
try {
return await this.refreshTokenIfNeeded(connectionId);
} finally {
// Release the lock
await this.redis.del(lockKey);
}
} else {
// Wait for the other process to finish refreshing
return await this.waitForRefresh(connectionId);
}
}
private async waitForRefresh(connectionId: string): Promise {
// Poll for the refresh to complete
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
const token = await this.getStoredToken(connectionId);
if (!this.isTokenExpiringSoon(token)) {
return token.access_token;
}
}
throw new Error('Token refresh timeout');
}
}
Optimizations for performance
While the above implementation is correct, checking locks on every API request can introduce overhead. Here are some optimizations:
Token caching: Cache the access token in memory for short periods (1-2 minutes) to reduce lock checks:
Optimized Token Manager with Caching
class OptimizedTokenManager {
private tokenCache = new Map();
async getValidToken(connectionId: string): Promise {
const cached = this.tokenCache.get(connectionId);
// Return cached token if it's still valid for at least 2 minutes
if (cached && cached.expiresAt > Date.now() + 120000) {
return cached.token;
}
// Get fresh token with locking
const token = await this.getValidTokenWithLock(connectionId);
// Cache the token
this.tokenCache.set(connectionId, {
token,
expiresAt: Date.now() + 300000 // Cache for 5 minutes
});
return token;
}
}
Retry on 401 errors: If an API request fails with a 401 error, invalidate the cache and retry with a fresh token:
API Request with 401 Retry Logic
async makeApiRequest(connectionId: string, url: string): Promise {
let token = await this.getValidToken(connectionId);
let response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
// If we get a 401, the token might be invalid despite our checks
if (response.status === 401) {
// Invalidate cache and get fresh token
this.tokenCache.delete(connectionId);
token = await this.getValidToken(connectionId);
// Retry the request
response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
}
return response.json();
}
Other OAuth token issues to consider
Token refresh concurrency is just one aspect of robust OAuth implementation. Here are other critical issues to address:
Refresh token expiration
Refresh tokens can also expire. Some APIs revoke refresh tokens that haven't been used for a while (typically 90 days or more). To prevent this, refresh your access tokens periodically, even if they're not being used for API requests.
Timing considerations
Refresh tokens a few minutes before they expire to account for clock drift between your server and the OAuth provider.
Always use the API's expiration timestamp
Don't assume token lifetimes based on general API rules. Always use the expires_in
value returned by the API. Most APIs have a fixed token lifetime, but some allow users to customize the access token lifetime setting.
Handling refresh failures
Token refreshes will fail sometimes due to network issues, API changes, or revoked refresh tokens. Implement proper error handling and user re-authentication flows:
Error Handling for Token Refresh
async refreshToken(connectionId: string): Promise {
try {
const response = await fetch('https://api.provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedToken.refresh_token,
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET
})
});
if (!response.ok) {
const error = await response.json();
// Handle specific error cases
if (error.error === 'invalid_grant') {
// Refresh token is invalid, user needs to re-authenticate
await this.notifyUserReauthRequired(connectionId);
throw new Error('Refresh token invalid - re-authentication required');
}
throw new Error(`Token refresh failed: ${error.error_description}`);
}
return await response.json();
} catch (error) {
// Log the error and notify monitoring systems
console.error('Token refresh failed:', error);
await this.notifyTokenRefreshFailure(connectionId, error);
throw error;
}
}
Using Nango for OAuth token management
If you're building multiple integrations, implementing robust OAuth token refresh logic for each API can be time-consuming and error-prone. This is one of the hidden costs of building integrations in-house.
Nango handles OAuth token refreshes automatically, including:
- Automatic token refresh before expiration
- Built-in concurrency protection
- Webhook notifications when refreshes fail
- Support for 500+ APIs with pre-built OAuth flows
- Secure credential storage with encryption
Nango refreshes each access token at least once every 24 hours to prevent refresh token revocation, and provides detailed logs for debugging authentication issues.
Conclusion
OAuth token refresh concurrency issues are subtle but can cause significant problems in production. The key is to implement proper locking mechanisms that prevent multiple refresh attempts and ensure API requests wait for ongoing refreshes to complete.
Start with a simple in-memory lock for single-instance applications, and upgrade to distributed locking with Redis for multi-instance deployments. Always implement proper error handling and user re-authentication flows for when refresh tokens become invalid.
By following these best practices, you can build reliable OAuth integrations that work consistently under load and provide a smooth experience for your users.