Auth & JWT
App-level auth settings — role hierarchy, JWT bearer-token fast-path, and external-sign-in URLs for cookie-trusting consumer Workers.
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.
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.
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
- The browser SPA gets a JWT via the
set-auth-tokenresponse header on every authenticated call. - On subsequent requests the SPA sends
Authorization: Bearer <jwt>. - The auth middleware's fast-path verifies the signature only — no DB query, no session lookup.
AppContextis reconstructed from JWT claims. - If verification fails (or no JWT is present), the middleware falls back to session auth.
- 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.
auth: { jwt: { expiresIn: 30 } } // 30s — tight post-revocation window
auth: { jwt: { expiresIn: 3600 } } // 1h — longer-lived tokens for CLI / CIissuer?: 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).
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_SECRETdirectly. If you overridesecretEnv, set both env vars to the same value or the files worker's JWT verification will fall back to DB session lookup. ThreadingsecretEnvthrough 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. | 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, or sct for the relationship-scope claim) predates 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/enteror a minting namespace route). - Fails open: a missing binding or KV outage skips the check (logged) — revocation degrades to the TTL / carry-forward-cap bound rather than locking every bearer out.
- 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.
- Coverage gap: batch hard deletes don't return rows through the chokepoint; call
revokeScopeTokensexplicitly if you bulk-hard-delete conferring rows.
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: 3600for one-hour bearer tokens. Pair withissuer/audienceif multiple services consume the same JWT. - Need immediate revocation: set
revocationCheck: 'kv'(Cloudflare runtime). Or drop TTL to ~30s — similar effect with bounded delay and zero KV reads.
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).
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'platform-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:
- Explicit
auth.loginUrl/auth.profileUrl— the cookie-trusting consumer-Worker case (this section). https://<account.domain>/login— inferred when this project hosts its own Account SPA on a custom domain./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).
Security Headers
Configure CSP, COOP, COEP, CORP, X-Frame-Options, HSTS, Referrer-Policy, and Permissions-Policy on every response from your generated Worker.
Compile Targets
Quickback compiles your definitions into a complete Hono API running on Cloudflare Workers, backed by Cloudflare D1 or Neon Postgres.