Skip to content

Refresh Tokens

This document describes the refresh token functionality in the auth service.

The auth service supports refresh tokens to provide a better user experience by allowing token renewal without re-authentication. When exchanging a Keycloak or Entra token, the service returns both an access token and a refresh token.

sequenceDiagram
participant Client
participant AuthService
participant IDP
participant Database
Client->>IDP: Login
IDP-->>Client: IDP Token
Client->>AuthService: POST /api/v1/auth/token<br/>(token exchange)
AuthService->>AuthService: Validate IDP token
AuthService->>Database: Create refresh token
AuthService-->>Client: Access Token + Refresh Token
Note over Client: Access token expires...
Client->>AuthService: POST /api/v1/auth/token<br/>(refresh grant)
AuthService->>Database: Validate refresh token
AuthService->>Database: Rotate refresh token
AuthService-->>Client: New Access Token + New Refresh Token
POST /api/v1/auth/token
Content-Type: application/json
{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "eyJhbGc...",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

Response:

{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_2KqM3bPt9HcLwYv6xRjN8aZe4FgS7uDm",
"refresh_expires_in": 2592000,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"scope": "org:1:role:organization_owner"
}
POST /api/v1/auth/token
Content-Type: application/json
{
"grant_type": "refresh_token",
"refresh_token": "rt_2KqM3bPt9HcLwYv6xRjN8aZe4FgS7uDm"
}

Response:

{
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_7uDm4FgS8aZe3bPt9HcLwYv6xRjN2KqM",
"refresh_expires_in": 2592000,
"scope": "org:1:role:organization_owner"
}

Refresh token behavior can be configured via environment variables:

VariableDefaultDescription
REFRESH_TOKEN_EXPIRY_DAYS30How long refresh tokens are valid
REFRESH_TOKEN_ROTATIONtrueWhether to rotate tokens on use
REFRESH_TOKEN_REUSE_WINDOW30Grace period (seconds) for concurrent requests
REFRESH_TOKEN_MAX_CHAIN_LENGTH100Maximum number of refreshes from original token
REFRESH_TOKEN_BIND_TO_IPfalseValidate IP address on refresh
REFRESH_TOKEN_BIND_TO_DEVICEfalseValidate device fingerprint on refresh

By default, refresh tokens are rotated on each use:

  • Old token is revoked after a grace period
  • New refresh token is issued with each refresh
  • Prevents token replay attacks

A configurable grace period allows for:

  • Concurrent requests with the same refresh token
  • Network retry scenarios
  • Default: 30 seconds

Prevents infinite token refresh chains:

  • Each refresh token tracks its parent
  • Maximum chain length enforced
  • Default: 100 refreshes

Refresh tokens can optionally be bound to:

  • IP Address: Token only valid from same IP
  • Device ID: Token only valid from same device
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(64) NOT NULL UNIQUE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
idp_provider VARCHAR(50) NOT NULL,
idp_user_id VARCHAR(255) NOT NULL,
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_used_at TIMESTAMP WITH TIME ZONE,
ip_address INET,
user_agent TEXT,
device_id VARCHAR(255),
parent_token_id UUID REFERENCES refresh_tokens(id) ON DELETE SET NULL,
revoked_at TIMESTAMP WITH TIME ZONE,
revocation_reason VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
class AuthService {
private accessToken: string;
private refreshToken: string;
private tokenExpiry: number;
async login(username: string, password: string) {
// 1. Login with Keycloak
const keycloakToken = await this.keycloakLogin(username, password);
// 2. Exchange for enriched tokens
const response = await fetch("/api/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: keycloakToken,
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
}),
});
const data = await response.json();
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
}
async getAccessToken(): Promise<string> {
// Check if token needs refresh (with 1 minute buffer)
if (Date.now() > this.tokenExpiry - 60000) {
await this.refreshAccessToken();
}
return this.accessToken;
}
private async refreshAccessToken() {
const response = await fetch("/api/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: this.refreshToken,
}),
});
if (!response.ok) {
// Refresh failed, need to re-authenticate
throw new Error("Token refresh failed");
}
const data = await response.json();
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
}
}
// Request interceptor
axios.interceptors.request.use(async (config) => {
const token = await authService.getAccessToken();
config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor for automatic retry
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await authService.refreshAccessToken();
const token = await authService.getAccessToken();
originalRequest.headers.Authorization = `Bearer ${token}`;
return axios(originalRequest);
} catch (refreshError) {
// Redirect to login
window.location.href = "/login";
}
}
return Promise.reject(error);
},
);

To verify refresh token functionality locally, call POST /api/v1/auth/token with a token-exchange request, then call the same endpoint with grant_type: "refresh_token" and the returned refresh_token. Confirm rotation behavior by retrying the old refresh token after the configured reuse window.

Monitor refresh token usage through logs:

Terminal window
# Token issuance
grep "Generated new refresh token" auth-service.log
# Token refresh
grep "Rotated refresh token" auth-service.log
# Failed refresh attempts
grep "Invalid refresh token" auth-service.log

1. Refresh token not returned

  • Check if Keycloak or Entra is configured; token exchange is not registered for Auth0 or generic OIDC
  • Verify refresh token service initialization
  • Check database migrations

2. Refresh fails with “invalid_grant”

  • Token may be expired (check REFRESH_TOKEN_EXPIRY_DAYS)
  • Token may have been rotated (check logs)
  • IP/Device binding may be failing

3. Old refresh token still works

  • Check if rotation is enabled (REFRESH_TOKEN_ROTATION)
  • May be within reuse window (REFRESH_TOKEN_REUSE_WINDOW)

4. Maximum chain length exceeded

  • User has refreshed token too many times
  • Increase REFRESH_TOKEN_MAX_CHAIN_LENGTH or re-authenticate
  1. Store tokens securely

    • Use secure storage (not localStorage for sensitive apps)
    • Consider using httpOnly cookies for web apps
  2. Handle refresh failures gracefully

    • Implement retry logic with exponential backoff
    • Redirect to login on permanent failure
  3. Monitor token usage

    • Track refresh patterns
    • Alert on suspicious activity
  4. Set appropriate expiry times

    • Short access tokens (1 hour)
    • Longer refresh tokens (30 days)
    • Balance security with user experience