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

1// access_token payload (HS256, signed with JWT_SECRET)
2{
3 "sub": "8b1f...c2e0", // userID (UUID)
4 "email": "alice@example.com",
5 "role": "issuer",
6 "name": "Alice Example",
7 "iss": "ida-api",
8 "aud": "ida-portal",
9 "iat": 1745672400,
10 "exp": 1745673300, // 15 minutes
11 "jti": "a1b2c3..."
12}
1// refresh_token payload (HS256)
2{
3 "sub": "8b1f...c2e0",
4 "typ": "refresh",
5 "iat": 1745672400,
6 "exp": 1748264400, // 30 days
7 "jti": "9z8y7x..." // tracked in Redis allowlist
8}
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:

1import { IDAClient } from '@infinia/ida-sdk';
2const ida = new IDAClient({ baseURL: 'https://adid.dev/api/v1' });
3
4await ida.auth.login('alice@example.com'); // sends OTP
5const tokens = await ida.auth.verifyOtp('alice@example.com', '482917');
6ida.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):

1// packages/portal/src/lib/apiFetch.ts (illustrative)
2export async function apiFetch(url: string, init: RequestInit = {}) {
3 let res = await fetch(url, { ...init, credentials: 'include',
4 headers: { ...init.headers, Authorization: `Bearer ${tokens.access}` } });
5
6 if (res.status === 401) {
7 const refreshed = await refreshTokens(); // POST /auth/refresh
8 if (refreshed) {
9 res = await fetch(url, { ...init,
10 headers: { ...init.headers, Authorization: `Bearer ${refreshed.access}` } });
11 }
12 }
13 return res;
14}

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.