Quickback Docs

Auth & JWT

App-level auth settings — provider roles, 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.

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

auth.roles

Declares exact role names that your authentication provider supplies and your project intentionally uses. This is the acknowledgement mechanism for dynamic provider roles: declared names do not produce AUTHZ_UNKNOWN_ROLE_NAME typo warnings, while nearby misspellings still do.

auth: {
  roles: ['bookkeeper'],
  roleHierarchy: ['member', 'admin', 'owner'],
}

auth.roles does not create privilege inheritance. Put a role in auth.roleHierarchy only when it belongs in that lowest-to-highest ordering and should participate in role+ expansion.

With Quickback's generated Better Auth organization plugin, declared roles are also registered as custom organization roles with member-equivalent Better Auth permissions, so invitations and member-role updates can assign them. Other auth providers must include the declared names in their authenticated role claims. If you provide your own Better Auth organization-plugin roles or ac, that explicit provider configuration wins and Quickback skips auto-registration.

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's JWT is signed with Better Auth's jwt plugin keyring — an EdDSA key pair in the jwks table, enabled automatically whenever the project signs tokens — and carries the orgId, raw role, tiers, and scope claims the fast-path needs to reconstruct AppContext without a database round-trip. The identity token is also available from the plugin's own GET /auth/v1/token; scope tokens are Quickback's, signed with the same key. See Relation to Better Auth's JWT Plugin.

This JWT surface is opt-in

Quickback emits this JWT surface only when auth.jwt is present. Omit it and the generated output has no identity JWT mint (Better Auth's GET /auth/v1/token is disabled), no JWT fast-path, 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 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'. Revoked tokens are rejected within seconds; revocation stamps are written automatically on sign-out and session revocation (per session), on password change with revokeOtherSessions, account deletion, user ban, org-member removal, role change, and changes to scope-conferring relationship rows. Long-lived tokens are therefore safe to pair with it: a remote "sign out everywhere" kills a lost device's bearer.

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.

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 no JWT is present, the middleware falls back to session auth. If a JWT this project minted fails to verify (rotated key, expired, tampered, revoked), the request is refused with 401 AUTH_INVALID_TOKEN / AUTH_EXPIRED — never downgraded to "unauthenticated" — so clients can re-mint on 401 alone.
  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).

Applies to every identity mint: Better Auth's GET /auth/v1/token (the compiler emits it as the plugin's expirationTime) and the session-path refresh alike, so a project on expiresIn: 604800 gets 7-day tokens from both. Scope-bearing tokens are clamped to 180 s regardless — see Scopes.

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 / CI

issuer?: string — iss claim

Default quickback:<project name> — a compile-time constant, so every worker that verifies (API, files) and the plugin's GET /auth/v1/token agree without any env plumbing.

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).

It must differ from the OAuth provider's issuer (oauth.issuerUrl, default BETTER_AUTH_URL + /auth/v1) — the middleware tells a Quickback token from an OAuth access token by iss, so the compiler rejects an issuer equal to the OAuth one, or any URL whose path is the auth base path. OAuth access tokens carry the OAuth issuer whenever the oauthProvider plugin is on, however it was enabled: oauth.enabled, agents.mcp.requireAuth, a named MCP server's requireAuth, or agentAuth. Quickback's own tokens keep this issuer in every case.

audience?: string — aud claim

Default quickback:<project name> (the same constant as issuer).

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 / algorithm — removed

Both knobs are gone. Tokens are signed with Better Auth's JWKS keyring (EdDSA), whose private key is encrypted at rest with BETTER_AUTH_SECRET — there is no separate signing secret to name and no algorithm to choose. Declaring either key is a compile error that points here. Verification needs only the public keys (GET /auth/v1/jwks), so no worker holds a secret just to verify.

GET /auth/v1/token — mint an identity JWT

Better Auth's jwt plugin endpoint. Session-authenticated callers (cookie or session-token bearer) mint a short-lived JWT carrying sub, sid, ict, orgId, role, userRole, tiers, email, name, plus the pinned iss / aud. An x-api-key cannot mint here — it stands in for a session only on GET /auth/v1/get-session. Two invariants:

  • orgId is only ever signed beside a membership role, and the verifier ignores an orgId claim that arrives without one — so a token can never be a bare tenant grant.
  • The org claim follows the session's active organization. A guest or crew client that must not carry the user's org membership into a scope clears it first (organization.setActive with organizationId: null) and mints; a session that never sets one mints org-less tokens by construction. The former { "dropOrg": true } body flag is gone with the generated /api/v1/token route.

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

Default 'none'.

ValueBehaviorCost per verify
'none'No revocation check. Stolen / pre-revocation tokens valid until TTL expires (scope claims: until the carry-forward cap; org / role claims riding a scope token: until their identity proof ict passes the same cap — /scope/v1/enter and the refresh never restart it). 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' (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:

KeyRevokesWritten automatically on
qbrev:sid:<sessionId>every JWT minted from one Better Auth session (tokens carry the minting session as sid — including scope tokens from /scope/v1/enter, namespace mints and their refreshes)every session deletion, via databaseHooks.session.delete.before: sign-out, revoke-session(s), revoke-other-sessions, password reset (/reset-password and /email-otp/reset-password — revokeSessionsOnPasswordReset is on by default), delete-user, the admin plugin's /admin/revoke-user-session(s) and /admin/stop-impersonating, and any adapter-level session delete from your own code — a lost device's token dies with its session while the user's other devices keep theirs
qbrev:user:<userId>every JWT for the useruser ban (databaseHooks.user.update.after — flipping banned stamps the key and, when outbound webhooks are enabled, emits a user.banned event); revoke-sessions, revoke-other-sessions, change-password with revokeOtherSessions, and delete-user (endpoint hooks); the target (body.userId) of the admin plugin's /admin/set-role, /admin/remove-user, /admin/update-user, and /admin/revoke-user-sessions; and every ctx.auth write. Call revokeUserTokens for custom flows
qbrev:member:<userId>:<orgId>the user's org-membership claimsmember removal / role change (Better Auth organizationHooks)
qbrev:scope:<userId>:<kind>:<id>the user's carried scope claimUPDATE/DELETE of a scope-conferring relationship row (audit-wrapper chokepoint + the single DELETE route)

Separately from revocation, every mint clamps exp to the session's own expiresAt, so a JWT can never outlive the session it came from.

src/lib/jwt-revocation.ts exports revokeUserTokens / revokeSessionTokens / 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 marker, 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.
  • 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. The exception is ctx.auth: your action asked for the change, so a stamp that cannot be written fails the action with 503 AUTH_REVOCATION_UNAVAILABLE rather than letting it report success with live tokens.
  • 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:

FieldDefault
expiresIn180
issuerquickback:<project name>
audiencequickback:<project name>
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). 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.organizationRoster — members read on the JWT

auth: { organizationRoster: { access: { roles: ['member'] } } } emits GET /auth/v1/organization/roster, the active organization's members (id, name, email, image, role). Better Auth's list-members needs a session; this works on the JWT bearer. See Listing organization members.

auth.cloudflareAccess — Cloudflare Access in front of routes

Verifies the Cf-Access-Jwt-Assertion header and maps the Access identity (email, groups, service token) to a Better Auth user and role. Inside its scope it is the only credential. See Cloudflare Access.

A row addressed to someone by email before they have an account — a guest list, an invitation, a pre-registered attendee — should belong to that account once it exists. Declare the link and the compiler emits it as a Better Auth database hook:

quickback.config.ts
auth: {
  claims: [
    { table: 'guests', emailColumn: 'emailAtInvite', userColumn: 'linkedUserId', when: 'emailVerified' },
  ],
},

On the named event the hook runs one UPDATE per claim: rows of table whose emailColumn equals the account's email (case-insensitive) and whose userColumn is still NULL get the user id. Soft-deleted rows are never linked.

whenFires on
emailVerifiedthe update that verifies the account's email, and the creation of an account that is already verified (social, email-OTP, or magic-link sign-in)
userCreatedaccount creation
signInevery new session

Only a verified email links, whatever when says. A claim keyed on an unverified address would let anyone who typed the victim's email at sign-up take their rows, so userCreated and signIn skip an unverified account. A failed link is logged and never blocks sign-up or sign-in.

The compiler checks the claim against the table and refuses to compile when the table is not a q-authored feature table with a q.id() key, when either column is missing, when emailColumn is encrypted (ciphertext cannot be matched), or when userColumn is NOT NULL, a scope or stamp column, or encrypted. Claims run on the Cloudflare runtime with Better Auth, over D1 or Postgres (the service lane).

The link is a compiler-owned write: it stamps modifiedBy: 'system:auth-claims' and fires no realtime event, webhook, or trigger. It is generated code, so it needs none of your project code (auth hooks cannot import it).

Backfill — rows that predate the account

The hook only sees accounts as they verify. For rows created after their account already existed, call ctx.auth.claims.backfill() from an action:

features/guests/actions/backfillClaims.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Link guest rows to verified accounts.",
  path: "/claims/backfill",
  input: z.object({ dryRun: z.boolean().optional(), cursor: z.string().nullable().optional() }),
  access: { roles: ["owner", "admin"] },
  async execute({ ctx, input }) {
    return ctx.auth.claims.backfill({ dryRun: input.dryRun, cursor: input.cursor ?? null });
  },
});

It returns { dryRun, results: [{ table, userColumn, scanned, matched, linked }], cursor }. Each call scans up to limit rows (default 500, at most 5000); call again with the returned cursor until it is null. dryRun: true reports matches without writing. An org-bound caller scans only its active organization's rows (and nothing from a claim table without an organization column); an operator — a caller whose platform role is one of the admin plugin's adminRoles — scans every row. Matches are looked up through Better Auth's adapter and must be verified accounts.

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).

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. There is no wrong-role redirect, and no auth.profileUrl.

auth.profileUrl is not supported. The config schema is strict, so a profileUrl key 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).

On this page