# 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 |
| `exp` | Expiry (15 minutes from issue) |

### Signing

JWTs are signed with HMAC-SHA256 using your `BETTER_AUTH_SECRET`. No additional configuration is needed — this uses the same secret you already set for Better Auth.

---

## 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);
```

### Invalidation on Role Changes

When an admin changes a user's role or removes them from an organization, the server broadcasts an `auth:token-invalidated` event via WebSocket. The client automatically clears the cached JWT, forcing the next request to fall back to session auth and mint a fresh token.

```
Admin changes user role
  → databaseHooks fires on member update
  → Broadcasts auth:token-invalidated via realtime
  → Client clears localStorage JWT
  → Next request uses session cookie → gets fresh JWT
```

### Expiry

JWTs expire after 15 minutes. 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.

**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) | 15-minute expiry limits exposure window. `httpOnly` cookies protect the session. |
| Token replay | Short expiry + `set-auth-token` rotation on session fallback |
| Stale permissions | WebSocket `auth:token-invalidated` broadcast on role changes |
| 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 secrets or configuration needed — uses your existing `BETTER_AUTH_SECRET`
- 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 Overview](/stack/auth) — Setup and configuration
- [Auth Security](/stack/auth/security) — Cookie security, rate limiting, CORS
- [API Keys](/stack/auth/api-keys) — Programmatic API access
