# Auth & JWT

The top-level `auth` config block holds provider-agnostic auth knobs that affect how Quickback emits the auth middleware, JWT helpers, and access-rule expansion.

```ts title="quickback.config.ts"
export default defineConfig({
  // ...
  auth: {
    roleHierarchy: ['member', 'admin', 'owner'],
    jwt: {
      expiresIn: 60,
      issuer: 'my-api',
      // audience, secretEnv, algorithm, revocationCheck...
    },
  },
});
```

## `auth.roleHierarchy`

Lowest-to-highest privilege list. Lets access rules use `'role+'` shorthand to mean "this role and above". Documented in detail under [Access — Role hierarchy](/compiler/definitions/access#role-hierarchy).

## `auth.jwt` — JWT bearer-token fast-path

Quickback ships its own custom HMAC-SHA256 JWT (not Better Auth's JWT plugin) so it can embed `orgId` and `role` claims the fast-path needs to reconstruct `AppContext` without a database round-trip. The signing secret is shared with Better Auth (`BETTER_AUTH_SECRET`) by default — users only manage one secret.

### Behavior recap

1. The browser SPA gets a JWT via the `set-auth-token` response header on every authenticated call.
2. On subsequent requests the SPA sends `Authorization: Bearer <jwt>`.
3. The auth middleware's **fast-path** verifies the signature only — no DB query, no session lookup. `AppContext` is reconstructed from JWT claims.
4. If verification fails (or no JWT is present), the middleware falls back to session auth.
5. After successful session auth, a fresh JWT is minted and returned via `set-auth-token`. The cycle continues.

The fast-path is intentionally stateless: it trusts the claims for the full TTL. **Stolen or pre-revocation tokens are valid until they expire.** That's the trade-off the fast-path makes — and the reason `expiresIn` is the most-tuned knob below.

### `expiresIn?: number` — TTL in seconds

Default `180` (3 minutes).

Lowering this shrinks the worst-case window where a stolen / pre-revocation token still works. 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.

```ts
auth: { jwt: { expiresIn: 30 } }   // 30s — tight post-revocation window
auth: { jwt: { expiresIn: 3600 } } // 1h — longer-lived tokens for CLI / CI
```

### `issuer?: string` — `iss` claim

Default omitted.

Set this when other services need to distinguish JWTs minted by this Worker from other issuers. The compiler bakes the issuer into both the mint side (added to claims) and the verify side (rejects tokens with mismatched `iss`).

### `audience?: string` — `aud` claim

Default omitted.

Set this when this Worker's JWTs are consumed by a sibling service that should reject tokens minted for a different audience. Same mint-and-verify treatment as `issuer`.

### `secretEnv?: string` — signing secret env var

Default `'BETTER_AUTH_SECRET'`.

Override this if you want JWT signing isolated from the Better Auth secret (e.g. rotating one without invalidating the other).

```ts
auth: { jwt: { secretEnv: 'CUSTOM_JWT_SECRET' } }
```

The compiler emits `c.env.CUSTOM_JWT_SECRET` (or `process.env.CUSTOM_JWT_SECRET` on Bun/Node) in the auth middleware's verify and mint sites and the `/api/v1/token` endpoint.

> **Note:** the files-worker (separate Worker for R2 access) still reads `BETTER_AUTH_SECRET` directly. If you override `secretEnv`, set both env vars to the same value or the files worker's JWT verification will fall back to DB session lookup. Threading `secretEnv` through the files worker is on the roadmap.

### `algorithm?: 'HS256'`

Only `HS256` is supported in 0.10.14. `RS256` (asymmetric, public-key verify / private-key sign) is on the roadmap.

### `revocationCheck?: 'none' | 'kv'` — token revocation strategy

Default `'none'`.

| Value | Behavior | Cost per verify |
|---|---|---|
| `'none'` | No revocation check. Stolen tokens valid until TTL expires. Use a low `expiresIn` to bound the replay window. | 0 |
| `'kv'` | Fast-path consults a denylist KV namespace before accepting the token. Banning a user / revoking sessions writes the user's `sub` to the denylist. | 1 KV read (~5-15ms, edge-cached) |

The `'kv'` path keeps the fast-path stateless against the auth DB (still 0 D1 queries) — KV reads on Workers are an order of magnitude cheaper than a D1 round trip *and* give sub-second revocation.

> **`'kv'` is deferred to 0.10.15.** Setting `'kv'` in 0.10.14 errors at compile time so the migration path is explicit; the schema accepts it now so users can author their final config today.

## Defaults preserve 0.10.13 behavior

Adding the `auth.jwt` block without setting any field is a no-op vs. the 0.10.13 baseline. Each missing field falls back to its hardcoded value:

| Field | Default |
|---|---|
| `expiresIn` | 180 |
| `issuer` | omitted |
| `audience` | omitted |
| `secretEnv` | `'BETTER_AUTH_SECRET'` |
| `algorithm` | `'HS256'` |
| `revocationCheck` | `'none'` |

## Tuning recommendations

- **B2C SaaS, mostly browser traffic:** keep defaults. The 180s TTL + auto-refresh model just works.
- **Pentest engagement / enterprise compliance:** set `expiresIn: 30`. Tokens stay invisible to logged-in users (auto-refreshed every authed call) but stolen-token windows shrink to 30s.
- **CLI-heavy / long-running CI tokens:** `expiresIn: 3600` for one-hour bearer tokens. Pair with `issuer` / `audience` if multiple services consume the same JWT.
- **Need immediate revocation:** wait for 0.10.15's `revocationCheck: 'kv'`. Or, today, drop TTL to ~30s — same effect with bounded delay.

## `auth.loginUrl` / `auth.profileUrl` — external sign-in for consumer Workers

When a Quickback Worker doesn't host its own Account SPA — e.g. an internal-app mesh where one Worker on the parent domain owns identity (`auth.example.com`) and other Workers share cookies — the compiler-emitted CMS access gates need to redirect unauthenticated visitors to the central sign-in surface, not the local `/account/login` (which doesn't exist there).

```ts title="quickback.config.ts"
export default defineConfig({
  // ...
  auth: {
    loginUrl: 'https://auth.example.com/login',
    profileUrl: 'https://auth.example.com/profile',
  },
});
```

Both fields override compiler-emitted browser redirects in the CMS shell:

- **Unauthenticated visitor hits a CMS route** → `302 → <loginUrl>?redirect=<original-url>`
- **Authenticated user fails the `cms.access: 'admin'` role check** → `302 → <profileUrl>`

The `?redirect=…` query param is preserved unchanged so the central sign-in surface can bounce the user back after authentication.

### Resolution precedence

Most-specific wins:

1. Explicit `auth.loginUrl` / `auth.profileUrl` — the cookie-trusting consumer-Worker case (this section).
2. `https://<account.domain>/login` — inferred when this project hosts its own Account SPA on a custom domain.
3. `/account/login` — built-in fallback, when neither knob is set.

### Independent from `account.domain`

`account.domain` does *two* things: redirect targets *and* hostname-based asset routing for an Account SPA hosted by this project. Consumer Workers don't host Account at all — they only need the URL. `auth.loginUrl` is a redirect-target string with no routing side effects, which is why it's a separate knob.

### When both are set

Setting `auth.loginUrl` *and* `account.domain` is legal — the explicit override beats the inferred URL at the redirect site, while `account.domain` still drives the local Account SPA's hostname routing as usual. Useful when you want to host Account on one custom domain (`account.foo.com`) but redirect *somewhere else* for sign-in (e.g. an SSO surface at `auth.foo.com`).
