Skip to navigation

JWT Lifecycle

View as Markdown

The platform uses two-token rotation for browser- and SDK-style clients. Every login (OTP, OAuth, DID-Auth) produces an access_token (short-lived, bearer-signed JWT) and a refresh_token (long-lived opaque or JWT, single-use).

13.5.1 Token claims

// access_token payload (HS256, signed with JWT_SECRET)
{
"sub": "8b1f...c2e0", // userID (UUID)
"email": "alice@example.com",
"role": "issuer",
"name": "Alice Example",
"iss": "ida-api",
"aud": "ida-portal",
"iat": 1745672400,
"exp": 1745673300, // 15 minutes
"jti": "a1b2c3..."
}
// refresh_token payload (HS256)
{
"sub": "8b1f...c2e0",
"typ": "refresh",
"iat": 1745672400,
"exp": 1748264400, // 30 days
"jti": "9z8y7x..." // tracked in Redis allowlist
}
TokenTTLStorage on clientStorage on serverPurpose
access_token15 minutes (default)Memory (SPA) / secure storage (mobile)None (stateless verify)Bearer auth on every API call
refresh_token30 days (default)httpOnly cookie or secure storageRedis allowlist (refresh:jti)Re-issue access tokens

Configurable: JWT TTLs are configured in packages/api/internal/service/auth.go (default 15 min access / 7 days refresh). They are not currently exposed as env vars — JWT_ACCESS_TTL / JWT_REFRESH_TTL do not exist in packages/api/internal/config/config.go as of v2.0. Promotion to env-var-driven configuration is planned for v2.1.

13.5.2 Issue flow (OTP example)

# Step 1 — request OTP
curl -X POST https://adid.dev/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com"}'
# 200 { "ok": true }
# Step 2 — verify OTP, receive token pair
curl -X POST https://adid.dev/api/v1/auth/verify-otp \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","code":"482917"}'
# 200 { "accessToken":"eyJ...", "refreshToken":"eyJ...", "expiresIn":900 }

The SDK exposes the same flow:

import { IDAClient } from '@infinia/ida-sdk';
const ida = new IDAClient({ baseURL: 'https://adid.dev/api/v1' });
await ida.auth.login('alice@example.com'); // sends OTP
const tokens = await ida.auth.verifyOtp('alice@example.com', '482917');
ida.setAccessToken(tokens.accessToken); // injects Bearer header

13.5.3 Refresh flow

curl -X POST https://adid.dev/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJ...current..."}'
# 200 { "accessToken":"eyJ...new...", "refreshToken":"eyJ...rotated...", "expiresIn":900 }

Behaviour guarantees (verified against service/auth.go):

  1. Single-use: the old jti is removed from the Redis allowlist as soon as the new pair is issued. Re-using the old refreshToken returns 401 INVALID_REFRESH_TOKEN.
  2. Rotation cascade: every refresh produces a new (accessToken, refreshToken) pair. The expiry clock on the refresh token is reset to a fresh 30 days.
  3. Replay detection: if the same jti is presented twice, all sessions for that user are revoked (the user is forced to log in again).

13.5.4 Revocation flow (logout)

curl -X POST https://adid.dev/api/v1/auth/logout \
-H "Authorization: Bearer eyJ..."
# 200 { "ok": true }

Server steps:

  1. Read access-token claims to find the user.
  2. Delete the user’s most recent refresh:jti from Redis (or all of them, with ?all=1).
  3. Subsequent POST /auth/refresh returns 401.

Note: access tokens are stateless — they remain valid until their exp. To force-evict every active session immediately, rotate JWT_SECRET (every existing access token will become unverifiable on next call). Plan this for off-hours; clients will all receive 401 simultaneously.

13.5.5 Client-side patterns

Auto-refresh middleware (TS SDK / Portal):

// packages/portal/src/lib/apiFetch.ts (illustrative)
export async function apiFetch(url: string, init: RequestInit = {}) {
let res = await fetch(url, { ...init, credentials: 'include',
headers: { ...init.headers, Authorization: `Bearer ${tokens.access}` } });
if (res.status === 401) {
const refreshed = await refreshTokens(); // POST /auth/refresh
if (refreshed) {
res = await fetch(url, { ...init,
headers: { ...init.headers, Authorization: `Bearer ${refreshed.access}` } });
}
}
return res;
}

Storage hygiene:

ClientRecommended
Browser SPAaccessToken in memory only; refreshToken in httpOnly Secure SameSite=Lax cookie (server-set)
Mobile (Expo)Both in expo-secure-store (Keychain / Keystore)
Server-side workerUse API key, not JWT

Anti-pattern: never put the refresh token in localStorage — XSS makes it trivially stealable. httpOnly cookies prevent JS access.