# JWT Optimization

Quickback automatically optimizes authenticated API requests using JWT tokens. After the first request (which uses session cookies), subsequent requests skip the database entirely by sending a signed JWT that encodes the full auth context.

## How It Works

### Request Flow

```
First request (no JWT cached):
  Browser → Cookie auth → 2 DB queries → Response + set-auth-token header
  Browser caches JWT in localStorage

Subsequent requests (JWT cached):
  Browser → Bearer JWT → Signature check only → Response (0 DB queries)
```

### What's in the JWT

The JWT encodes everything needed for auth middleware:

| Claim | Description |
|-------|-------------|
| `sub` | User ID |
| `orgId` | Active organization ID |
| `role` | Organization membership role (`owner`, `admin`, `member`) |
| `userRole` | Global user role (when admin panel is enabled) |
| `email` | User email |
| `name` | User display name |
| `iat` | Issued-at timestamp (unix **seconds**, the standard claim) |
| `iatMs` | Issued-at in epoch **milliseconds** — Quickback ordering metadata, not a standard claim. Same clock sample as `iat`, set by the signer only. Lets [revocation](#revocation--revocationcheck-kv) order a token against a marker written in the same second |
| `exp` | Expiry — **180 seconds** (3 minutes) after `iat` by default; set via [`auth.jwt.expiresIn`](/configure/auth) |

Scope-bearing tokens also carry `sct` (proof timestamp, unix seconds) and its millisecond companion `sctMs`, plus a per-kind `sctMs` stamp (`m<ms>`) inside each `scope.<kind>` entry — a mint proves one kind and carries the others forward with the proof time they already had. See [Scopes](/define/scopes). Read `iat` / `sct` from external tooling; the millisecond values exist only so revocation can break ties inside a second.

### Signing

JWTs are signed with HMAC-SHA256 using your `BETTER_AUTH_SECRET` — the same secret you already set for Better Auth, so there is only one secret to manage.

The signing behaviour is configurable under [`auth.jwt`](/configure/auth) in `quickback.config.ts`: `secretEnv` isolates JWT signing from the Better Auth secret, `issuer` / `audience` add the matching claims, and `algorithm` is `'HS256'` (the only value currently supported).

```ts title="quickback.config.ts"
auth: {
  jwt: {
    expiresIn: 180,              // default — TTL in seconds
    revocationCheck: 'none',     // default — 'kv' for sub-TTL revocation
    secretEnv: 'BETTER_AUTH_SECRET',
  },
},
```

> **This surface is opt-in by default**
>
> The custom JWT surface is emitted only when `auth.jwt` is present. Omit it and the generated output has no token endpoint and no JWT fast-path — cookie and signed session-token bearer auth still work. A project that declares `authz.scopes` does **not** need `auth.jwt`: it gets scope-token admission on its own, without the token endpoint or the identity fast-path. Declaring `auth.jwt` is what opts into the full credential lane. See [Auth & JWT config](/configure/auth).
>
> `src/lib/jwt.ts` is still emitted when the project declares [`authz.scopes`](/define/scopes): scope tokens are signed JWTs, so the helper is required for minting and verifying them even with no credential lane.


---

## Server-Side Validation

**Every authenticated API request is validated server-side before it reaches your handler.** There is no "trust the client" shortcut — the middleware runs full signature verification on each request.

Validation steps (in generated `src/middleware/auth.ts`):

1. Extract the `Authorization: Bearer <token>` header
2. Call `verifyJwt(token, BETTER_AUTH_SECRET)` from `src/lib/jwt.ts`
3. Decode header + payload, recompute the HMAC-SHA256 signature, and compare via `crypto.subtle.verify()` (constant-time — no timing-attack surface)
4. Check `exp` against the current time
5. On success: hydrate `AppContext` from claims (`sub`, `orgId`, `role`, `userRole`, `email`, `name`) — no DB query
6. On failure (bad signature, expired, malformed): **silently fall back** to Better Auth's session cookie auth; if that succeeds, mint a fresh JWT and return it in the `set-auth-token` response header

The Files worker (R2 uploads) performs the same verification using the shared secret — no network round-trip to the auth API for file operations.

---

## Token Lifecycle

### Automatic Minting

When a request arrives without a JWT (or with an expired one), the auth middleware:

1. Authenticates via session cookie (existing Better Auth flow)
2. Signs a JWT with the full auth context
3. Returns it in the `set-auth-token` response header

The Quickback API client (`quickback-client.ts`) automatically captures this header and stores the JWT in `localStorage`.

### Re-minting on Org Switch

When a user switches organizations, the JWT must be re-minted because `orgId` and `role` change. The auth UI handles this automatically:

```typescript
// This happens automatically in the org switcher
await authClient.organization.setActive({ organizationSlug: slug });

// Fetch fresh JWT with new org context
const res = await fetch('/api/v1/token', {
  method: 'POST',
  credentials: 'include',
});
const { token } = await res.json();
localStorage.setItem('bearer_token', token);
```

### Revocation — `revocationCheck: 'kv'`

The fast-path is stateless by default: it verifies the signature and **trusts the claims for the full TTL**. A ban, a member removal, or a role demotion does not reach an outstanding JWT — that token keeps working until it expires. Bounding that window is a server-side concern, and there are exactly two knobs for it.

**Lower the TTL.** `auth.jwt.expiresIn: 30` gives a hard 30-second ceiling. Browser SPAs auto-refresh via `set-auth-token` on every authed call, so a low TTL is invisible to logged-in users; only stale server-to-server bearer tokens feel it.

**Or enable sub-TTL revocation.** Set `revocationCheck: 'kv'` (v0.45+, Cloudflare runtime only):

```ts title="quickback.config.ts"
auth: {
  jwt: { revocationCheck: 'kv' },
},
```

The fast-path then consults per-principal revocation timestamps in your project's existing `KV` binding (keys prefixed `qbrev:`, self-expiring) *before* trusting a token. A token is rejected when its mint timestamp (`iat`/`iatMs`, or the proof time of the scope kind being checked) is **at or before** the stored timestamp. Cost: 2+ edge-cached KV reads per bearer verify.

There is no same-second gap: markers are epoch milliseconds (written `m<ms>`, v0.61.0+), tokens carry millisecond companions, and the comparison denies a tie — so a token minted a few milliseconds *before* the revocation is rejected, while one legitimately re-minted *after* it in that same second is still accepted. Pre-v0.61.0 workers don't understand that marker format and read it as "not revoked" — don't roll back across the upgrade or share one KV namespace between versions; see [`revocationCheck`](/configure/auth#revocationcheck-none--kv--token-revocation-strategy).

Revocation stamps are written **automatically** on:

- user ban
- org-member removal and member role change (Better Auth `organizationHooks`)
- `UPDATE` / `DELETE` of scope-conferring relationship rows

The generated `src/lib/jwt-revocation.ts` exports `revokeUserTokens`, `revokeMemberTokens`, and `revokeScopeTokens` for explicit calls from your own action code.

> **Honest limits**
>
> The KV check revokes **per principal**, not per session, and stamps propagate cross-PoP in roughly 60 seconds — "immediate" means seconds, not instantaneous.
>
> It **fails closed** for bearer auth: if the KV binding is missing or the read fails, the bearer is *not* authenticated — a signature is all that is known about it — and the caller gets a retryable **`503 AUTH_REVOCATION_UNAVAILABLE`**. Recovery is a **separate cookie-only request**, not a fallback inside the same one: a request carrying `Authorization` has its cookie stripped before session auth (bearer is an alternative to the cookie, not a supplement), so during a KV outage every JWT-bearer request gets the 503 until the client retries on its session cookie. A *revoked* token behaves the same way, and a cookie request re-checks membership and re-mints transparently.


> **Operational consequence**
>
> Enabling `revocationCheck: 'kv'` ties bearer-token availability to KV. Alert on `AUTH_REVOCATION_UNAVAILABLE` — a sustained rate means the binding is gone (check that every deployment target declares it) or KV itself is degraded. If you want the TTL bound *without* that coupling, leave `revocationCheck` at `'none'` and set `expiresIn: 30`.


Full knob-by-knob reference: [Auth & JWT config → `revocationCheck`](/configure/auth#revocationcheck-none--kv--token-revocation-strategy).

### Client-side invalidation broadcast (best-effort)

Separately from `revocationCheck`, when realtime is configured the server also broadcasts an `auth:token-invalidated` event after a member role change, and the Account SPA drops its cached JWT in response.

```
Admin changes user role
  → organizationHooks.afterUpdateMemberRole fires
  → Broadcasts auth:token-invalidated via realtime
  → Client clears localStorage JWT
  → Next request uses session cookie → gets fresh JWT
```

This is a **UX nicety, not an enforcement mechanism.** The broadcast is fire-and-forget with a 2.5s timeout, it only reaches clients that are connected and listening, and it does nothing about a token held outside the browser. Do not rely on it to revoke access — that is what `revocationCheck: 'kv'` and a low `expiresIn` are for.

### Expiry

JWTs expire **180 seconds** (3 minutes) after issue by default — `auth.jwt.expiresIn`, in seconds. This is a safety net: in practice JWTs are re-minted well before expiry through the `set-auth-token` response header on session-fallback requests.

When a JWT expires or is invalid:
1. The middleware silently falls back to session cookie auth
2. A fresh JWT is minted and returned in the response header
3. The client stores the new JWT for subsequent requests

---

## Token Endpoint

`POST /api/v1/token` mints a fresh JWT from the current session. This endpoint goes through normal auth middleware, so the user must have a valid session cookie.

**Session authentication is required — and enforced.** A request authenticated
by a bearer JWT (or an API key / OAuth access token) is rejected with `401
AUTH_SESSION_REQUIRED` ("Token refresh requires session authentication").
This is deliberate: only the session path re-validates against Better Auth's
session store on every request, so bans, member removals, role demotions, and
sign-outs take effect immediately. If a JWT could mint its own replacement, a
stolen or revoked token could roll a short-TTL JWT forever without ever
re-validating — the TTL would bound nothing.

**Request:**
```bash
curl -X POST https://your-api.example.com/api/v1/token \
  -H "Content-Type: application/json" \
  --cookie "better-auth.session_token=..."
```

**Response:**
```json
{
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}
```

Use this endpoint after operations that change auth context (like switching organizations) to get a JWT that reflects the new state.

---

## Client Integration

### Quickback API Client

The `quickback-client.ts` API client handles JWT auth automatically:

- **Sends** the cached JWT as `Authorization: Bearer <token>` on every request
- **Captures** refreshed JWTs from `set-auth-token` response headers
- **Clears** the JWT on `401` responses (falls back to session cookie)

No additional client configuration is needed.

### Custom API Calls

If you make direct `fetch()` calls to your API (outside the Quickback client), include the JWT:

```typescript
const jwt = localStorage.getItem('bearer_token');

const res = await fetch('https://your-api.example.com/api/v1/things', {
  headers: {
    'Authorization': `Bearer ${jwt}`,
    'Content-Type': 'application/json',
  },
  credentials: 'include', // Fallback to session cookie if no JWT
});

// Capture refreshed token
const newToken = res.headers.get('set-auth-token');
if (newToken) {
  localStorage.setItem('bearer_token', newToken);
}
```

### Sign Out

Clear the JWT on sign-out to prevent stale tokens:

```typescript
localStorage.removeItem('bearer_token');
await authClient.signOut();
```

---

## Security

### Threat Model

| Threat | Mitigation |
|--------|------------|
| Token theft (XSS) | 180-second default expiry bounds the exposure window; `expiresIn: 30` tightens it. `httpOnly` cookies protect the session. |
| Token replay | Short expiry + `set-auth-token` rotation on session fallback |
| Stale permissions | `revocationCheck: 'kv'` — server-side rejection within seconds of a ban, member removal, or role change |
| Token forgery | HMAC-SHA256 signature verified with `BETTER_AUTH_SECRET` |
| Timing attacks | `crypto.subtle.verify()` provides constant-time comparison |

### Key Points

- JWTs are a **performance optimization**, not a replacement for session auth
- Session cookies remain the source of truth — JWTs are derived from them
- If a JWT is lost, stolen, or cleared, the system gracefully falls back to cookie auth
- No additional **secret** is needed — signing reuses your existing `BETTER_AUTH_SECRET`. Behaviour *is* configurable: see [`auth.jwt`](/configure/auth) for `expiresIn`, `revocationCheck`, `issuer`, `audience`, `secretEnv`, and `algorithm`
- The JWT contains no sensitive data beyond what's already in the auth context

---

## Files Worker

The files worker (for Cloudflare R2 file uploads) also supports JWT authentication. When a request includes a JWT `Authorization` header, the worker verifies it directly instead of calling back to the auth API — eliminating a network round-trip for file operations.

---

## Relation to Better Auth's JWT Plugin

Quickback's JWT fast-path (documented above) is a **custom HMAC-SHA256 implementation**, separate from Better Auth's [`jwt` plugin](https://www.better-auth.com/docs/plugins/jwt). The distinction matters if you're comparing options.

### What Quickback ships by default

- Custom HMAC-SHA256 JWTs minted by the compiled API at `POST /api/v1/token`
- Payload includes `sub`, `orgId`, `role`, `userRole`, `email`, `name` — everything the middleware needs to reconstruct `AppContext` with zero DB queries
- Signed with `BETTER_AUTH_SECRET`
- Used by the generated auth middleware as a fast-path ahead of `getSession()`

### What Better Auth's JWT plugin gives you (opt-in)

Enable it with `plugins: ["jwt"]` in your auth config:

```ts
auth: defineAuth("better-auth", {
  plugins: ["jwt"],
}),
```

You get:
- JWKS endpoint at `/auth/v1/jwks` for asymmetric public-key verification by external services
- BA's own `/auth/v1/token` endpoint (asymmetric-signed, EdDSA by default)
- Standard OIDC-style claims via BA's `definePayload` hook

### Why Quickback doesn't use BA's plugin internally

Better Auth's `definePayload` hook only receives `{ user }`. It cannot embed the active organization context (`orgId`, org-member `role`) that Quickback's Firewall and Access layers need. Without that context in the JWT, the middleware would have to do a DB lookup per request — defeating the whole point of the fast-path.

### When to enable BA's JWT plugin anyway

Turn it on if **external services** need to verify tokens without sharing `BETTER_AUTH_SECRET` — e.g. a third-party worker that wants to confirm a user's identity via JWKS. The BA-issued tokens won't have Quickback's org context, so they're useful for identity assertions, not for hitting the Quickback API directly.

Enabling BA's plugin does **not** change how the compiled API authenticates requests — the middleware keeps using the custom HMAC JWT regardless.

---

## Related

- [Auth & JWT config](/configure/auth) — `auth.jwt` reference: `expiresIn`, `revocationCheck`, `issuer`, `audience`, `secretEnv`, `algorithm`
- [Auth Overview](/platform/auth) — Setup and configuration
- [Auth Security](/platform/auth/security) — Cookie security, rate limiting, CORS
- [API Keys](/platform/auth/api-keys) — Programmatic API access
