# 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](/define/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.

> **This JWT surface is opt-in**
>
> Quickback emits this custom JWT surface only when `auth.jwt` is present. Omit
> it and the generated output has no generic `/api/v1/token` endpoint, no
> identity JWT mint, and no Bearer-JWT instructions in `llms.txt` or RFC 8414
> discovery metadata. Better Auth cookie and signed session-token bearer
> authentication remain available.
>
> **Scopes are the exception.** A project that declares
> [`authz.scopes`](/define/scopes) always emits `src/lib/jwt.ts` (scope tokens
> are signed JWTs) *and* a scope-only verification block that admits them —
> without the token endpoint or the identity fast-path. Declaring scopes does
> not open the credential surface.
>
> One gap to know about: that block admits **sessionless** scope tokens
> (`ctx.mintScope`). A token minted from a session — `POST /scope/v1/enter`, or
> a namespace with `mintScope: true` — carries user claims instead of the
> scope-only marker, and without `auth.jwt` nothing can admit it, so the caller
> gets 200 from the mint and 401 when presenting it. The compiler warns. Declare
> `auth: { jwt: {} }` if you rely on those routes.
>
> Use `auth: { jwt: {} }` to opt into the defaults documented below.


### Security posture at a glance

The short version, before the knob-by-knob detail:

- **Default: 180-second TTL, auto-refreshed.** Every authenticated call returns a fresh token via the `set-auth-token` header, so the low TTL is invisible to logged-in users. The worst-case window for a stolen or pre-revocation token is 3 minutes — and you can shrink it to 30 seconds with one config line.
- **Need revocation faster than the TTL?** Set [`revocationCheck: 'kv'`](#revocationcheck-none--kv--token-revocation-strategy). Revoked tokens are rejected within seconds; revocation stamps are written automatically on user ban, org-member removal, role change, and changes to scope-conferring relationship rows.

Those two knobs — `expiresIn` and `revocationCheck` — cover the vast majority of deployments. The honest residual for the strictest threat models: the KV check revokes per-principal (not per-session) and propagates cross-PoP in ~60s. It **fails closed** — if KV can't be read, the bearer isn't trusted and a bearer-only caller gets a retryable `503`, so enabling it ties bearer availability to KV. If you need per-session kill semantics, set `expiresIn: 30` for a hard 30-second ceiling — and [tell us about your use case](https://github.com/kardoe/quickback/issues); an opaque-session mode is under consideration.

### 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` and `revocationCheck` are the two most-tuned knobs below: the first bounds the window, the second shrinks it to seconds.

### `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 / pre-revocation tokens valid until TTL expires (scope claims: until the carry-forward cap). Use a low `expiresIn` to bound the replay window. | 0 |
| `'kv'` | Fast-path checks per-principal revocation timestamps in the project's existing `KV` binding before trusting a verified token. Fails closed: an unreadable store means the bearer is not trusted. | 2+ KV reads (~5-15ms, edge-cached) |

`'kv'` (v0.45+, **Cloudflare runtime only**) keeps the fast-path stateless against the auth DB (still 0 D1 queries). There is nothing extra to provision — entries ride the `KV` binding every project already has, prefixed `qbrev:` and self-expiring after the maximum token lifetime, so the store never accumulates.

A token is rejected when its mint timestamp (`iat`/`iatMs`, or the scope claim's proof time) is **at or before** the stored timestamp for any principal key it rides on:

| Key | Revokes | Written automatically on |
|---|---|---|
| `qbrev:user:<userId>` | every JWT for the user | user ban (`databaseHooks.user.update.after` — flipping `banned` stamps the key and, when outbound webhooks are enabled, emits a `user.banned` event). Call `revokeUserTokens` for custom flows (revoke-all, etc.) |
| `qbrev:member:<userId>:<orgId>` | the user's org-membership claims | member removal / role change (Better Auth `organizationHooks`) |
| `qbrev:scope:<userId>:<kind>:<id>` | the user's carried scope claim | UPDATE/DELETE of a scope-conferring relationship row (audit-wrapper chokepoint + the single DELETE route) |

`src/lib/jwt-revocation.ts` exports `revokeUserTokens` / `revokeMemberTokens` / `revokeScopeTokens` for explicit calls from actions. A rejected token falls through to session auth, which re-checks membership and re-mints — an active legitimate session recovers transparently.

Semantics worth knowing:

- **Revoke-on-touch for scopes:** any update or delete of a conferring row stamps the revocation, without evaluating whether the change actually ended the relationship. A false positive just forces a cheap re-prove (`/scope/v1/enter` or a minting namespace route).
- **Scope proof time is per kind:** a token can carry several scope kinds proven at different instants, so each entry carries its own proof stamp (`sctMs`, `m<ms>`) and is compared against its own marker. Proving one kind never restamps the others — re-entering `event` cannot bring a revoked `location` claim back to life. Entries minted before the release that added the stamp carry none, and fall back to the token-level `sct`/`sctMs` until they expire (bounded by the carry-forward cap plus one TTL).
- **No same-second gap:** markers are stored in epoch milliseconds (written `m<ms>` — an explicit format version, not a magnitude the reader guesses at) and tokens carry millisecond companions (`iatMs` / `sctMs`) alongside the standards-compatible seconds claims, 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. Tokens and markers written by v0.60.4 or earlier are handled **conservatively**: with no sub-second information on one side, the whole marker second is revoked. That over-revocation is bounded — the last old token expires within its TTL, and the last old marker within the entry lifetime. Like any rejected token it costs the caller a `401`, not a silent recovery: the bearer falls through unauthenticated (its cookie having been stripped, per the next bullet), and the client re-authenticates for a fresh token.
- **Do not straddle the v0.61.0 upgrade.** A worker from **before** v0.61.0 reads `m<ms>` markers as `NaN` and treats them as *no revocation at all* — not as a corrupt entry. So rolling back across this release, or pointing two differently-versioned deploy targets at one shared KV namespace, means every revocation written by the new version is **silently unenforced** by the old one until the entry expires (`ENTRY_TTL_SECONDS`, ~19 minutes at default settings). Upgrade all targets sharing a namespace together; if you must roll back, treat outstanding bearer tokens as unrevoked for that window — lower `expiresIn`, or purge the `qbrev:` keys and re-stamp from the old version.
- **Fails closed:** a missing binding, a failed read, or a corrupt entry means the token's revocation state is *unknown* — and an unknown bearer is not authenticated. The caller gets `503 AUTH_REVOCATION_UNAVAILABLE` (RFC 9457, retryable). Recovery is a **separate cookie-only request**: a request carrying `Authorization` has its cookie stripped before session auth, so within that one request there is nothing left to fall back to. Reads are never silently skipped, because the outage that hides a revocation is exactly the moment this check exists for.
- **Availability coupling:** the flip side of the above. Bearer-token auth now depends on KV being readable. Alert on `AUTH_REVOCATION_UNAVAILABLE`; a sustained rate means the binding is missing on some deployment target, or KV is degraded. Every named environment must declare `bindings.kv` — the compiler rejects a target that doesn't.
- **Eventual consistency:** KV propagates cross-PoP in ~60s. "Immediate" means seconds at the writing location, bounded-seconds globally — still far inside the 15-minute carry-forward cap that bounds revocation without this check.
- **Writes stay best-effort:** the *stamps* are written after the authorization change has already committed (the ban row, the removed membership, the deleted conferring row). A failed stamp does not fail that write — the DB state is the durable cut. It is only the *read* side that fails closed.
- **Coverage gap:** batch *hard* deletes don't return rows through the chokepoint; call `revokeScopeTokens` explicitly if you bulk-hard-delete conferring rows.

## Defaults when the JWT surface is enabled

Adding `auth.jwt: {}` enables the surface with the historical defaults. 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:** set `revocationCheck: 'kv'` (Cloudflare runtime) — revoked tokens are rejected within seconds, and stamps are written automatically on user ban, member removal, role change, and scope-conferring row changes (see the [scope revocation notes](/define/scopes#revocation--ttl)). It fails closed, so bearer auth then depends on KV being readable — declare `bindings.kv` on every environment and alert on `AUTH_REVOCATION_UNAVAILABLE`. Or drop TTL to ~30s — similar effect with bounded delay, zero KV reads, and no availability coupling.

## `auth.loginUrl` — 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 gate needs 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',
  },
});
```

- **Unauthenticated visitor hits a CMS route** → `302 → <loginUrl>?redirect=<original-url>`

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

Signing in is the *only* thing the CMS gate redirects for. A signed-in caller who isn't admitted is served the CMS shell and shown a screen naming what they'd need — see [CMS access](/ui/admin#access-gate). There is no wrong-role redirect, and no `auth.profileUrl`.

> **Removed in v0.62:** `auth.profileUrl`. Its only consumer was the CMS wrong-role redirect. The config schema is strict, so a leftover `profileUrl` key now fails the compile — delete it.


### Resolution precedence

Most-specific wins:

1. Explicit `auth.loginUrl` — 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`).
