> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.adid.dev/llms.txt.
> For full documentation content, see https://docs.adid.dev/llms-full.txt.

# JWT Lifecycle

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

```jsonc
// 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..."
}
```

```jsonc
// refresh_token payload (HS256)
{
  "sub": "8b1f...c2e0",
  "typ": "refresh",
  "iat": 1745672400,
  "exp": 1748264400,                        // 30 days
  "jti": "9z8y7x..."                        // tracked in Redis allowlist
}
```

| Token           | TTL                  | Storage on client                      | Storage on server               | Purpose                       |
| --------------- | -------------------- | -------------------------------------- | ------------------------------- | ----------------------------- |
| `access_token`  | 15 minutes (default) | Memory (SPA) / secure storage (mobile) | None (stateless verify)         | Bearer auth on every API call |
| `refresh_token` | 30 days (default)    | `httpOnly` cookie or secure storage    | Redis 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)

```bash
# 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:

```ts
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

```bash
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)

```bash
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):**

```ts
// 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:**

| Client             | Recommended                                                                                          |
| ------------------ | ---------------------------------------------------------------------------------------------------- |
| Browser SPA        | `accessToken` in memory only; `refreshToken` in `httpOnly` `Secure SameSite=Lax` cookie (server-set) |
| Mobile (Expo)      | Both in `expo-secure-store` (Keychain / Keystore)                                                    |
| Server-side worker | Use API key, not JWT                                                                                 |

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

***