Token Refresh
Obtain a new access token using a refresh token — understand refresh token rotation, implement proactive refresh in backend services, and handle silent refresh in SPAs.
Token Lifetime Overview
| Token | Default Lifetime | Configurable? | Storage |
|---|---|---|---|
| Access Token | 15 minutes | Per client (min 5 min) | Memory / session — never localStorage |
| Refresh Token | 7 days | Per client | Encrypted HttpOnly cookie (SPA) or server-side session (backend) |
| id_token | Same as access token | — | Memory — for identity display only |
Refresh Token Rotation
Passport uses refresh token rotation — every time a refresh token is used, Passport issues a new refresh token and invalidates the old one. This means:
- You must always store the most recently issued refresh token
- Using an old refresh token (after it was rotated) is treated as a possible replay attack and invalidates the entire token family
- Concurrent refresh requests must be handled carefully — use a mutex or queue
Refreshing an Access Token
// POST /passport/token with refresh_token grant
POST /passport/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64("workflow-api-prod:client-secret")
grant_type=refresh_token
&refresh_token=current-refresh-token-value
// Successful response (200 OK)
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...", // new access token
"refresh_token": "new-refresh-token-value", // MUST store this, old one is now invalid
"token_type": "Bearer",
"expires_in": 900,
"scope": "openid profile email roles tenant"
}
// Expired/invalid refresh token (400 Bad Request)
{
"error": "invalid_grant",
"error_description": "The refresh token is expired, revoked, or has already been used."
}
C# — Server-Side Refresh (Go.Essentials Pattern)
public sealed class PassportTokenRefreshService(
IPassportClient passportClient,
ITokenStorage tokenStorage,
ILogger<PassportTokenRefreshService> logger)
{
// Mutex per user to prevent concurrent refresh races
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<string> GetValidAccessTokenAsync(string userId, CancellationToken ct)
{
var tokens = await tokenStorage.GetTokensAsync(userId);
// Return existing token if not expiring within 60s
if (tokens.AccessTokenExpiresAt > DateTimeOffset.UtcNow.AddSeconds(60))
return tokens.AccessToken;
// Serialize refresh per user
var semaphore = _locks.GetOrAdd(userId, _ => new SemaphoreSlim(1, 1));
await semaphore.WaitAsync(ct);
try
{
// Re-check after acquiring lock (another thread may have refreshed)
tokens = await tokenStorage.GetTokensAsync(userId);
if (tokens.AccessTokenExpiresAt > DateTimeOffset.UtcNow.AddSeconds(60))
return tokens.AccessToken;
logger.LogInformation("Refreshing tokens for user {UserId}", userId);
var refreshed = await passportClient.RefreshTokenAsync(tokens.RefreshToken, ct);
await tokenStorage.StoreTokensAsync(userId, refreshed);
return refreshed.AccessToken;
}
catch (InvalidGrantException)
{
logger.LogWarning("Refresh token expired for user {UserId} — re-auth required", userId);
await tokenStorage.ClearTokensAsync(userId);
throw new ReAuthenticationRequiredException(userId);
}
finally
{
semaphore.Release();
}
}
}
Silent Refresh in SPAs
For Single-Page Applications, silent refresh avoids visible login prompts by using an invisible iframe to silently obtain new tokens while the user is still active.
// JavaScript — silent refresh using hidden iframe
class PassportSilentRefresh {
private refreshTimer: number | null = null;
startSilentRefresh(accessTokenExpiresAt: Date) {
const refreshAt = new Date(accessTokenExpiresAt.getTime() - 60_000); // 60s before expiry
const delay = refreshAt.getTime() - Date.now();
if (delay <= 0) {
this.doSilentRefresh();
return;
}
this.refreshTimer = setTimeout(() => this.doSilentRefresh(), delay);
}
private async doSilentRefresh() {
// Use an iframe to POST to the token endpoint silently
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = buildSilentRefreshUrl();
document.body.appendChild(iframe);
return new Promise<void>((resolve, reject) => {
iframe.onload = () => {
// Extract new tokens from iframe message
document.body.removeChild(iframe);
resolve();
};
setTimeout(() => {
document.body.removeChild(iframe);
reject(new Error('Silent refresh timeout'));
}, 5000);
});
}
}
In browser-based SPAs, never store refresh tokens in localStorage or sessionStorage — they are accessible to any JavaScript on the page (XSS risk). Use a Backend-for-Frontend (BFF) pattern where the BFF server holds the refresh token in an HttpOnly cookie and the SPA only receives short-lived access tokens.