Portal Community

Token Lifetime Overview

TokenDefault LifetimeConfigurable?Storage
Access Token15 minutesPer client (min 5 min)Memory / session — never localStorage
Refresh Token7 daysPer clientEncrypted HttpOnly cookie (SPA) or server-side session (backend)
id_tokenSame as access tokenMemory — 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:

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);
    });
  }
}
Refresh Token Storage in SPAs

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.