Skip to content

Refresh Token Flow

This page documents the token refresh flow in GrydAuth, which allows users to obtain new access tokens without re-entering their credentials.

v5 (Epic 533) — refresh token in an HttpOnly cookie. The refresh token is no longer returned in the JSON body. It is delivered exclusively in a Set-Cookie: __Secure-gryd.refresh; HttpOnly; Secure; SameSite; Path=/api/v1/auth cookie (inaccessible to JavaScript). POST /api/v1/auth/refresh reads the token from that cookie — the request body carries only an optional tenantId. Because the endpoint is now cookie-authenticated it requires the CSRF header X-Gryd-Csrf matching the readable gryd.csrf cookie (double-submit), and the SPA must send credentials: include. A missing refresh cookie → 401; a missing/mismatched CSRF header → 403. The single-use rotation rewrites the cookie on every refresh, and /logout expires it. See token-storage-backend-change-inventory.md.

Overview

Access tokens in GrydAuth v5 are short-lived by design — capped at 15 minutes, default 10 — because their roles/permissions are a snapshot at issuance time. When an access token expires, the client can use the refresh token to obtain a new access token without requiring the user to log in again.

Token Lifetimes

Token TypeDefault LifetimeConfigurable?Purpose
Access Token10 minutesYes — JwtSettings:ExpirationMinutes, validated at boot, must be 1–15API authentication
Refresh Token7 daysNo — hardcoded default parameter in StatelessRefreshTokenService, not read from JwtSettingsObtain new access tokens
Global Token2 minutesNo — hardcoded in JwtTokenServiceTenant selection / first login only, see Switch Tenant

Refresh-token TTL is not currently configurable

Unlike the access-token TTL, the 7-day refresh-token lifetime is a hardcoded default parameter, not a JwtSettings/GrydAuth config key. Don't document or rely on a config toggle for it — if you need it configurable, that's a gap to raise with the Auth team, not an existing setting.

Sequence Diagram

100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar

Step-by-Step Explanation

Phase 1: Access Token Expiration

When the access token expires, API requests return 401 Unauthorized with a ProblemDetails body carrying code: "TOKEN_EXPIRED". The client should detect this and initiate the refresh flow. See the canonical error code matrix.

Phase 2: Token Refresh

2.1 Send Refresh Request

http
POST /api/v1/auth/refresh
Cookie: __Secure-gryd.refresh=<httponly>; gryd.csrf=<value>
X-Gryd-Csrf: <value>
Content-Type: application/json

{
  "tenantId": "550e8400-e29b-41d4-a716-446655440000"
}

tenantId is optional — TenantValidationBehavior injects it automatically from context, but a client may override it.

The response is the same AuthenticationResult shape as Login — the token field is token, not accessToken, and current roles/permissions/tenant info are included, not just a bare token pair.

2.2 Validate Refresh Token

The refresh token is validated against Redis cache:

csharp
var tokenData = await _cache.GetAsync<RefreshTokenData>(refreshToken);
if (tokenData == null)
    return Result.Failure("Invalid refresh token");

2.3 Validate User Status

The system ensures:

  • User still exists
  • User is active (IsActive = true)
  • User is not locked out

2.4 Token Version Check

Token version prevents use of old tokens after:

  • Password change
  • Admin-forced logout
  • Security incident response
csharp
if (tokenData.TokenVersion != user.TokenVersion)
{
    await _cache.DeleteAsync(refreshToken);
    return Result.Failure("Token invalidated");
}

2.5 Tenant Validation

Ensures the user still has access to the tenant from the original token.

2.6 Refresh Roles & Permissions

Important: The new access token contains the current roles and permissions, not the cached ones. This ensures permission changes take effect immediately.

2.7 Token Rotation

For security, GrydAuth implements refresh token rotation:

  1. Old refresh token is deleted
  2. New refresh token is generated
  3. Client must use new refresh token for next refresh

This limits the damage if a refresh token is compromised.

Phase 3: Resume Operations

Client stores the new tokens and continues making API requests.

Automatic Token Refresh

Client-Side Implementation

typescript
// Axios interceptor for automatic refresh
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    
    if (error.response?.status === 401 &&
        error.response?.data?.code === 'TOKEN_EXPIRED' &&
        !originalRequest._retry) {
      
      originalRequest._retry = true;
      
      try {
        const { token, refreshToken } = await authService.refresh();
        authStore.setTokens(token, refreshToken);
        
        originalRequest.headers.Authorization = `Bearer ${token}`;
        return axios(originalRequest);
        
      } catch (refreshError) {
        // Refresh failed - redirect to login
        authStore.clearTokens();
        router.push('/login');
        return Promise.reject(refreshError);
      }
    }
    
    return Promise.reject(error);
  }
);

Token Refresh Service

typescript
class AuthService {
  private refreshPromise: Promise<AuthenticationResult> | null = null;
  
  async refresh(): Promise<AuthenticationResult> {
    // Prevent multiple simultaneous refresh requests
    if (this.refreshPromise) {
      return this.refreshPromise;
    }
    
    this.refreshPromise = this.doRefresh();
    
    try {
      return await this.refreshPromise;
    } finally {
      this.refreshPromise = null;
    }
  }
  
  private async doRefresh(): Promise<AuthenticationResult> {
    const refreshToken = authStore.getRefreshToken();
    const response = await api.post('/auth/refresh', { refreshToken });
    return response.data;
  }
}

Sliding Expiration (Rotation Side-Effect, Not a Toggle)

There is no EnableSlidingExpiration setting. The sliding behavior below falls out naturally from rotation: every successful refresh issues a brand-new refresh token with a fresh 7-day expiry (the old one is deleted immediately). As long as the user keeps refreshing within 7 days, the session effectively never expires:

100% 💡 Use Ctrl + Scroll para zoom | Arraste para navegar

Error Scenarios

ErrorHTTP StatusCauseClient Action
Invalid refresh token401Token not found in cacheRedirect to login
Token expired401Refresh token TTL exceededRedirect to login
Token revoked401Password changed or admin actionRedirect to login
User inactive401Account deactivatedShow account disabled message
Tenant access revoked403Removed from tenantRedirect to tenant selection
Reuse detection unavailable401Cache/Redis outage — rotation denied (fail-secure, v5)Redirect to login
Rate limit exceeded429Too many refresh attemptsBack off, honor Retry-After

Security Considerations

Refresh Token Storage

PlatformRecommended Storage
Web (SPA)HttpOnly cookie or memory
MobileSecure storage (Keychain/Keystore)
DesktopOS credential manager

Never Store in localStorage

Refresh tokens should never be stored in localStorage due to XSS vulnerability.

Token Rotation Benefits

  1. Limits exposure window - Stolen tokens become invalid after one use
  2. Detects theft - If legitimate user's refresh fails, theft is detected
  3. Audit trail - Each rotation is logged for security analysis

Degraded Mode (Fail-Secure)

Single-use rotation depends on the reuse-detection store (Redis). If that store is unavailable, GrydAuth v5 denies rotation by default and forces re-authentication, rather than issuing a long-lived refresh token without anti-reuse protection (as v4 did under load). The policy is pluggable via IDegradedModeRefreshPolicy (default DenyAllDegradedModeRefreshPolicy); any alternative that issues a token in degraded mode must require MFA step-up, use a very short TTL, and stay behind a feature flag that is off by default. Denials are logged as security events. See the v4 → v5 migration guide.

Absolute Expiration

Recommended, not currently implemented

There is no enforced absolute maximum lifetime today — a user who refreshes regularly can stay signed in indefinitely on the rolling 7-day window above. An AbsoluteExpiration cap is a sensible hardening candidate, but don't document it as existing behavior; verify against StatelessRefreshTokenService before relying on it.

Configuration

jsonc
{
  "JwtSettings": {
    "ExpirationMinutes": 10   // access-token TTL; validated at boot, must be 1-15
  }
}

Refresh-token TTL (7 days) and rotation are not configuration-driven — they're hardcoded in StatelessRefreshTokenService. Access tokens are deliberately short-lived (maximum 15 minutes, default 10) because their authorization claims are snapshots. Refresh-token rotation preserves session continuity, while the shorter access-token TTL bounds exposure to stale claims.

Released under the MIT License.