Quickback Docs

Diagnostics

The compile-time authz / authn analyzer. Reads the lowered policy IR and flags empty or shadowed permissions, unreachable roles and views, capability-ceiling breaches, the not-over-resource null-trap, and pushdown cost. Zero-false-positive checks fail the compile with an explicit authz.analyzer.allow override surface; heuristic checks warn, with a QUICKBACK_AUTHZ_STRICT opt-in that promotes warnings too. Source-analysis codes check authored action, lib, and template files for raw database handles, Better Auth table writes, in-body role checks, undeclared env reads, and unescaped HTML.

Quickback compiles your authorization model into a single normalized policy IR — relations, permissions, arrows, scopes, grants, and per-resource firewalls — and then runs a set of static checks over it. The analyzer never changes what's emitted; it reports problems it can prove from the policy graph: a permission that can never be satisfied, a scoped role no grant reaches, a resource that hands a scope role more than its profile allows, a negation that can silently pass.

The console channel

Diagnostics render to the existing [quickback:authz] / [quickback:authn] console channel — the same prefix the compiler has always used for security warnings, so any CI grep you already key on keeps working. Each line carries a stable, greppable code:

[quickback:authz] AUTHZ_EMPTY_PERMISSION: permission "event:locked" can never be satisfied — it requires organizerOf AND its negation simultaneously.
  at resource=sessions authz.permissions["event:locked"]
  hint: Remove the contradictory arm, or split into separate permissions.
  override: authz: { analyzer: { allow: [{ check: 'AUTHZ_EMPTY_PERMISSION', target: 'event:locked', reason: '<why this is acceptable>' }] } }

The first line stays greppable ([quickback:<channel>] CODE: message); the source location, fix hint, and — for promoted errors — the exact override recipe render on continuation lines.

Errors vs warnings

The checks split into two tiers:

  • Promoted (compile errors). A trip is a definite misconfiguration — never a legitimate config. These fail the build by default. Each one can be explicitly accepted per finding via authz.analyzer.allow.
  • Heuristic (warnings) and reports (info). Advisory findings where a legitimate config could trip the detector. These print and the compile continues. Set QUICKBACK_AUTHZ_STRICT=true to promote these warnings to errors as well (overridden findings stay warnings even under strict — an override is an explicit, reasoned acceptance).

The analyzer remains compile-time only — zero runtime blast radius. A defect in the analyzer itself is caught and downgraded to a single warning, so an analyzer bug can never take down an otherwise-valid compile. Only real findings fail builds.

Authz codes

These read the permission graph projected from the policy IR and run on every compile.

CodeTierWhat it catches
AUTHZ_EMPTY_PERMISSIONerrorA permission that can never be satisfied — allOf(a, not(a)), a fact and its exact negation at the same AND-level. The detector is conservative (exact-leaf contradictions only), so a trip is structurally unsatisfiable.
AUTHZ_CAPABILITY_CEILINGerrorA resource's firewall grants a scope:<kind>:<role> role read access beyond its capability profile — either the role has no profile at all, or the profile doesn't list this resource. The M0.2 pillar guarantee.
AUTHZ_SHADOWED_ARMwarnAn anyOf arm subsumed by a broader arm: a leaf X and an allOf(X, …) in the same union — the narrower arm can never widen the result.
AUTHZ_UNREACHABLE_ROLEwarnA scoped role declared under authz.scopes with no grant profile — it can authorize no read, action, or channel. Dead config, deny-safe.
AUTHZ_UNREACHABLE_VIEWwarnA grant's read names a resource:view whose resource has no firewall — no role can reach that view. (A reachability proxy — heuristic by design.)
AUTHZ_NOT_OVER_RESOURCEwarnA not whose operand resolves to a resource (SQL) site — NOT EXISTS over a nullable join can silently pass (the null-trap). The site resolution is conservative, so this stays advisory.
AUTHZ_RECORD_PREDICATE_NOT_ON_LISTwarnA constant read.access.record predicate (status: { equals: "published" }, id: { equals: "never" }). access.record runs against a loaded row on GET /:id only — GET /, POST /batch, and the MCP list tool stop at the role gate, so the rows still list. Omit read to hide a resource; use the firewall to filter. $ctx-relative predicates are the per-record shape and stay silent.
AUTHZ_PUSHDOWN_COSTinfoPer-permission report: how many SQL subqueries the permission lowers to, plus the max recursive-CTE depth. Advisory, never fails a build.
AUTHZ_ROOM_ACCESS_INHERITEDwarnA resource-backed realtime room with no access.roles (connections gated on the table's CRUD read.access, so narrowing the room means narrowing CRUD reads) or no access.writeRoles (every admitted caller may write). Declare both on the binding. Printed while the config is checked, before the analyzer runs, so QUICKBACK_AUTHZ_STRICT does not promote it and there is nothing to override.

A few codes are reserved in the diagnostic enum for the milestones that emit them and don't fire on a shipped config today: AUTHZ_UNBOUNDED_RECURSION (an unbounded recursive arrow with no claim path — currently a hard compile error raised at config validation, before the analyzer runs), AUTHZ_UNENFORCEABLE_FACT, and AUTHZ_CAVEAT_SITE_MISMATCH (M3 caveats).

The capability ceiling

The check that backs scope grant profiles: a scoped role is deny-by-default and may touch only what its grants profile lists. The analyzer walks every resource firewall, follows each { permission } arm into the permission graph to find the scope roles it references, and fails the build if a resource grants a scope role access the profile doesn't cover. This is what makes the grant profile the single auditable manifest of what an external principal can see.

Authn codes

Five structural authentication checks — proving properties over the generated route + credential surface rather than the authz policy. All are promoted (compile errors):

CodeTierWhat it catches
AUTHN_ROUTE_COVERAGEerrorA route that reaches data with an unclassifiable auth gate and isn't declared PUBLIC — the signup / flag-gap bug class (data delivered to an unauthenticated ctx). Fires only on a gate the compiler cannot classify; zero-FP by construction.
AUTHN_SURFACE_INVARIANTerrorA surface contract breach: an /mcp or agent route not gated bearer-only, or an INTERNAL-gated route mounted on an HTTP-reachable surface.
AUTHN_SECRET_CONSISTENCYerrorA credential's mint site and verify site don't share key / issuer / audience — every minted token would fail verification (the ws-ticket "401 on every upgrade" trap).
AUTHN_CREDENTIAL_DELIVERYerrorA fire-and-forget credential send (the OTP-email shape) not anchored to waitUntil / await — the Worker may drop the promise and never deliver (the OTP-500 bug class).
AUTHN_CLAIM_PROVENANCEerrorAn identity ctx field written from request input rather than a credential verifier — client-asserted identity, which is forbidden. Unprovable provenance also fails (fail closed).

These check definitions are wired and fully unit-tested, but their inputs — the per-route and per-credential metadata (RouteNode / MintSite / VerifySite / ClaimWrite) — are not yet emitted by the route, middleware, and credential generators. Until those generators emit that metadata the analyzer runs the authn checks against empty route/credential graphs, so they are effectively no-ops on a real compile. The severity contract is locked in now: they fail builds the moment they can fire. (The emitted route surface is separately guarded today by the IR's unbound-route invariant — a hard error that predates this promotion and owns that surface; one subject, one error.)

Schema and config codes

These read the lowered project — each table's resolved create/update field sets, its firewall arms, authz.relationships, and each action's access — and flag the shapes that silently skip a security mechanism Quickback already has. They run on every compile.

CodeDefaultWhat it catchesOpt-out
SCHEMA_UNDECLARED_FKerrorA *Id / *_id column that names a feature table in the same database (eventId → events or event), is client-writable (in the effective createable / updatable set) or sits in a firewall or authz.relationships arm, and declares no .references(). Without it no firewalled FK check runs, so a caller can point the row at another tenant's parent. Never fires for auth-owned ids (userId, ownerId, organizationId, teamId, memberId, audit ids) or when no matching feature table exists.references: { eventId: { references: false, reason: '…' } } in defineTable
SCHEMA_FK_MATCH_REQUIREDwarnA declared FK where the child and its target both carry an org column, with no match on the org column. A match on another parent column counts when that column is itself org-matched.references: { eventId: { match: false, reason: '…' } } in defineTable
SCHEMA_JSON_UNGUARDEDwarnA JSON column (q.json, json / jsonb) clients can write with no keys policy — anyone who may update the row may set every key of the document.// quickback-allow SCHEMA_JSON_UNGUARDED: <reason> in the table file
ACTION_PUBLIC_WITHOUT_RATELIMITwarnAn action whose access admits PUBLIC with no explicit rateLimit (it shares the project-default write bucket, keyed by client IP), or with rateLimit: false.// quickback-allow ACTION_PUBLIC_WITHOUT_RATELIMIT: <reason> in the action file

Why two defaults:

  • SCHEMA_UNDECLARED_FK is an error because the fix is always available (.references() works on q-DSL and raw Drizzle columns alike) and the gap is a cross-tenant write.
  • SCHEMA_FK_MATCH_REQUIRED is a warning because match can only be written on a q-DSL column — on a raw Drizzle table the only way to clear an error would be an opt-out that misstates intent — and generated CRUD writes already run the firewalled FK check. The remaining gap is action-side writes, which the match trigger backstops. Under QUICKBACK_AUTHZ_STRICT=true it, and the other two warnings, fail the build.

Every opt-out needs a non-empty reason. The compiler rejects an opt-out without one, a references entry it does not recognise, or one naming a column the table does not have.

export default defineTable(guests, {
  references: {
    cventEventId: { references: false, reason: 'Cvent-side id; no row in this database' },
    partnerEventId: { match: false, reason: 'guests may be invited to a partner org event' },
  },
  // ...
});

Migration window

The error honours compiler.securityContracts.failMode. While you add the missing declarations, set it to 'warn' and every finding prints, marked as downgraded, without failing the build:

export default defineConfig({
  compiler: { securityContracts: { failMode: 'warn' } },
  // ...
});

failMode: 'warn' also downgrades the security contract checks, so treat it as temporary.

The override surface

Every promoted finding can be explicitly accepted — per finding, with a required reason:

authz: {
  analyzer: {
    allow: [
      {
        check: 'AUTHZ_CAPABILITY_CEILING',
        target: 'sessions/scope:event:organizer',
        reason: 'temporary exception while the organizer grant profile is implemented',
      },
    ],
  },
}

An entry downgrades the exact (check, target) finding back to a warning that cites the reason. The rendered error prints the exact recipe — copy the check and target from it. Target formats:

Checktarget format
AUTHZ_EMPTY_PERMISSIONthe permission name — event:locked
AUTHZ_CAPABILITY_CEILING<resource>/scope:<kind>:<role> — sessions/scope:event:organizer
AUTHN_ROUTE_COVERAGE, AUTHN_SURFACE_INVARIANT<METHOD> <path> — GET /mcp
AUTHN_SECRET_CONSISTENCY, AUTHN_CREDENTIAL_DELIVERYthe credential kind — ws-ticket
AUTHN_CLAIM_PROVENANCEthe ctx field — ctx.userId

Guard rails, all compile errors:

  • check must be a promoted id — unknown ids and keep-warn ids (there is nothing to override) are rejected at config validation.
  • reason is required and non-empty: overrides are self-documenting.
  • An allow entry that matches no current finding is a dead override and fails the compile — fix or remove the stale entry. Override lists can never silently outlive the problems they suppressed.

Reading the pushdown-cost report

AUTHZ_PUSHDOWN_COST is an info line per permission that pushes down to SQL — it never fails a build, but it surfaces the query cost of an authorization rule before you ship it:

[quickback:authz] AUTHZ_PUSHDOWN_COST: permission "section:inTree" lowers to 1 SQL subquery, recursive CTE depth 6 (sites: resource).

A high subquery count or a deep recursive CTE (from a recursive arrow) is a signal to reconsider the rule — not an error, but worth a look.

Source codes

The checks above read the policy graph. These read your authored source: action files, the feature lib/ files, and the project's lib/, hooks/, plugins/, email-templates/, and middlewares/ files. They catch code that goes around a pillar instead of through it. The analysis is syntactic (no type checker), it runs on every compile, and it renders on the [quickback:source] channel:

[quickback:source] ACTION_RAW_DB_WITHOUT_UNSAFE: action "listMyEvents" reaches a raw database handle (drizzle(env.DB)) without declaring `unsafe` — the firewall does not apply to it and no audit row is written.
  at site=action listMyEvents features/user-profiles/actions/listMyEvents.ts:61:20
  hint: Use the scoped `db`, or declare `unsafe: { reason: "…" }` on the action and use `unsafeDb` (audited).
  opt-out: // quickback-allow ACTION_RAW_DB_WITHOUT_UNSAFE: <reason>
CodeTierScopeWhat it catches
ACTION_RAW_DB_WITHOUT_UNSAFEerroraction files, and every project file they importA raw database handle reached by an action with no unsafe: declaration.
ACTION_AUTH_DB_WRITEerror (warn in hooks/)every authored file except plugins/, plus queue, schedule, and email handlersAn insert, update, or delete against a Better Auth table that does not go through Better Auth.
ACTION_ROLE_CHECK_IN_BODYwarnthe execute function of action filesA role check written in the handler body instead of in the declared access.
ENV_UNDECLAREDerrorevery authored file (Cloudflare runtime)An env.X read where nothing declares X.
TEMPLATE_UNESCAPEDerrorevery authored fileA ${} value in an HTML template literal that is not escaped.
SOURCE_ALLOW_INVALIDerror—A quickback-allow directive with no reason, an unknown code, or nothing left to suppress.

"Every project file they import" is transitive. It follows relative imports and the ~/ alias through the feature lib/ and the project lib/, hooks/, and plugins/ files. A finding in a shared file names the action that reached it. Packages from node_modules are not followed.

ACTION_RAW_DB_WITHOUT_UNSAFE

It fires on these, in an action that has no unsafe::

  • c.get('db')
  • a database binding read off the environment: env.DB, c.env.AUTH_DB, env.HYPERDRIVE, and so on. That includes env.DB.prepare(…).
  • a drizzle driver import (drizzle-orm/d1, drizzle-orm/postgres-js, …)
  • a call to drizzle(…), createDb(), or createAuthDb()
  • auth.$context (Better Auth's adapter)
  • the action's own unsafeDb. This only fires in the action file. An imported helper may receive unsafeDb from an unsafe caller.

Opt-out: declare unsafe: { reason: "…" } on the action (or unsafe: true, or firewall: { exception: true } on a deliberately unscoped action), so the call is audited. See Unsafe mode. A quickback-allow directive also works when a raw handle is in a shared file the action imports but does not call.

ACTION_AUTH_DB_WRITE

Only Better Auth's API writes Better Auth's tables. A direct write skips Better Auth's hooks, session revocation, and qbrev stamping. It is a compile error in actions and every file they import, the feature and project lib/, middlewares/, email-templates/, and the queue, schedule, and email handlers under services/ — and in every file those import.

In hooks/ it is a warning, whatever failMode says: an account hook sometimes has no Better Auth endpoint it can call (see Account hooks). plugins/ is not swept — a custom Better Auth plugin is part of Better Auth's API — unless an action imports it. Reads are never flagged.

It fires on .insert(T), .update(T), and .delete(T) when T is:

  • a table from the generated auth schema (…/auth/schema, …/auth-schema): imported by name, through a namespace (authSchema.user, const { member } = authSchema), or destructured from a dynamic await import("…/auth/schema"), or
  • anything, when the receiver IS the auth database handle: authDb, c.get("authDb"), env.AUTH_DB, your configured authBinding, a call wrapping one (drizzle(env.AUTH_DB)), or a variable bound to one. A variable that only uses the handle (new Set((await authDb.select()…))) is not one.

It also fires on raw SQL that writes a Better Auth table, in a SQL context — a sql tagged template, an argument of prepare / run / execute / query / exec / batch (and the other driver calls), or a const named like SQL (deleteSql, query, stmt):

  • INSERT INTO / REPLACE INTO <t>
  • UPDATE [ONLY] <t> [AS alias] SET
  • DELETE FROM [ONLY] <t> [alias] WHERE | USING | RETURNING
  • TRUNCATE [TABLE] <t>
  • schema-qualified or quoted ("auth"."session")
  • a ${} that is an auth-schema table (sql`DELETE FROM ${user} WHERE …`)
  • in a file that opens the auth database, a write whose table is any ${} (`DELETE FROM ${where}`) — it cannot be proven not to be an auth table

Prose that reads like SQL (an email subject "Delete from organization") is not in a SQL context and is not flagged.

The check is a syntactic, best-effort lint: SQL assembled across variables or a handle passed through several functions can slip past it. On Postgres, the RLS backstop is what actually refuses a caller-lane write to Better Auth's tables.

The table list is your compile's: every table Better Auth creates for the plugins you enable, plus whatever the generated src/auth/schema.ts declares, in SQL (two_factor), export (twoFactor), and plural spellings. A project table with the same name is yours and is excluded. A project with no Better Auth provider has nothing to check.

The message names the call to make instead, and the hint says where that call lives for the kind of file — an action, a hook, a handler, or a lib file:

WriteCall instead
insert userauth.api.createUser (admin plugin) or auth.api.signUpEmail
update userauth.api.updateUser; to ban, ctx.auth.ban(userId, reason)
delete userctx.auth.deleteAccount(userId), auth.api.deleteUser, or auth.api.removeUser (admin plugin)
insert sessiona sign-in endpoint: signInEmail, signInEmailOTP, signInMagicLink, passkey sign-in
delete sessionauth.api.revokeSession / revokeSessions, or revokeUserSessions
organization, member, invitationcreateOrganization / updateOrganization / deleteOrganization, addMember / ctx.auth.setRole(userId, role) / removeMember, createInvitation / cancelInvitation

Plain unsafe does not silence it. unsafe lets an action read across tenants; it is not a licence to write Better Auth's tables.

Opt-out, in an action: when Better Auth has no endpoint for the write, declare unsafe: { reason, crossTenant: false, authWrites: true }. authWrites turns unsafe mode on: the call is audited with your reason, and unsafeDb is in scope. Set crossTenant: false — unsafe mode defaults to crossTenant: true, which makes the action sysadmin-only. unsafeDb is the features database on D1, so reach the auth tables through c.get("authDb"):

// features/account/actions/anonymiseAccount.ts
import { z } from "zod";
import { eq } from "drizzle-orm";
import { defineAction } from "../.quickback/define-action";
import { user } from "../../../auth/schema";

export default defineAction({
  description: "Anonymise a deleted account",
  path: "/anonymise",
  input: z.object({}),
  access: { roles: ["AUTHENTICATED"] },
  unsafe: {
    reason: "GDPR anonymisation keeps the row; Better Auth only deletes",
    crossTenant: false,
    authWrites: true,
  },
  async execute({ c, ctx }) {
    await c.get("authDb").update(user).set({ name: "Deleted user" }).where(eq(user.id, ctx.userId!));
    return { ok: true };
  },
});

authWrites: true with no reason, or with enabled: false, fails the compile. It covers the action file and the files it imports. A shared file stays an error while any other importer — another action, a hook, a handler — reaches it without the same opt-in.

Opt-out, anywhere else (hooks, handlers, lib): // quickback-allow ACTION_AUTH_DB_WRITE: <reason> on the line or the line above, or quickback-allow-file for the whole file.

ACTION_ROLE_CHECK_IN_BODY

Inside execute only, it fires on:

  • a membership test on a roles array: ctx.roles.includes(…), (ctx.roles ?? []).some(…), and every, indexOf, find, findIndex, has on anything named roles, userRoles, or memberRoles.
  • an equality comparison (===, !==, ==, !=) between a role literal and the caller's role. A caller's role is an expression rooted at ctx whose last name contains role (ctx.userRole, ctx.member?.role), or a bare local with such a name (role). input.role and row.role are data and are not flagged.
  • ['admin', 'owner'].includes(<caller role>) and switch (<caller role>) with role-literal cases.

A role literal is owner, admin, member, any name in auth.roleHierarchy or authz.roles, or any role named in any action's access.

It is a warning: role checks in the body still run. The problem is that the declared access and the route manifest stop describing who can call the action. Move the gate into access (an anyOf arm per caller class), or name the role set once in authz.roles.

Opt-out: // quickback-allow ACTION_ROLE_CHECK_IN_BODY: <reason>. Use it for response shaping that is not a gate.

ENV_UNDECLARED

It fires on env.X, (env as any).X, env["X"], and const { X } = env, where the environment is env, c.env, ctx.env, or this.env. process.env and import.meta.env are not checked. X is declared if it is:

  • a member of the generated CloudflareBindings interface (src/env.d.ts): the compiler's own bindings plus everything under bindings, including bindings.vars and bindings.secrets, or
  • provided by the generated wrangler.toml: [vars] and per-environment [env.<target>.vars], every binding = "…", and the [*.secrets] required lists.

This is the same set wrangler deploys with, so an ENV_UNDECLARED read is undefined at runtime unless something outside the config sets it.

Fix: declare it: bindings: { secrets: [{ name: "X" }] } or bindings: { vars: { X: "…" } }. Opt-out, for a value set outside the config (for example wrangler deploy --var): // quickback-allow ENV_UNDECLARED: <reason>. On a non-Cloudflare runtime the check does not run.

TEMPLATE_UNESCAPED

A template literal counts as HTML when any of these is true:

  1. its literal text contains a tag: <p, </div, <!DOCTYPE.
  2. it is passed directly to a function whose name contains html (c.html(…), sendHtml(…)), or bound to a variable, property, or assignment target whose name ends in html (html, bodyHtml, innerHTML; not htmlR2Key).
  3. the file is under an email-templates/ or templates/ directory, and the template is not bound to a name containing subject, text, or plain.

Each ${} value in an HTML template must be one of these:

  • a call to an escaping function: esc(…), or any name containing escape or sanitize (escapeHtml, escapeAttr, sanitizeUrl)
  • a .replace / .replaceAll chain whose patterns together cover & and < (s.replace(/[&<>"']/g, …))
  • a template tagged with a tag whose name contains html, escape, or sanitize (html`…`). An escaping tag also exempts the template itself.
  • a literal; arithmetic, comparison, or a unary expression; x.length; Number(…), parseInt(…), parseFloat(…), Math.*(…), .toFixed(…); encodeURIComponent(…), encodeURI(…)
  • a nested template, a cond ? a : b, a && b, a || b, a ?? b, or a + b whose parts are all allowed
  • xs.map(cb).join(…), or […].join(…), where every value cb returns (or every element) is allowed. cb can be an escaping function (.map(escapeHtml)).
  • a same-file binding whose every value is allowed: a const, or a let/var when its initializer and every = / += to it in the declaring block are allowed
  • a call to a same-file function whose every return is allowed. Recursive renderers are fine.

Anything else is unescaped: function parameters, values imported from another module, and property reads such as input.name. One finding is reported per template and lists the offending values. A helper whose own template is reported is not reported again where it is used.

Fix: escape the value at the interpolation, or build the markup with an escaping html tag. Opt-out, for a value that is already markup (a fragment from another module's renderer, a server-built constant): // quickback-allow TEMPLATE_UNESCAPED: <reason> on the line where the template starts, or above it.

Opting out: quickback-allow

Every source code has the same opt-out: a comment that names the code and gives a reason.

// quickback-allow TEMPLATE_UNESCAPED: badgeSvg is rendered server-side from a fixed icon set
const badge = `<div class="badge">${input.badgeSvg}</div>`;

const showBilling = ctx.roles?.includes("owner"); // quickback-allow ACTION_ROLE_CHECK_IN_BODY: response shaping, not a gate
  • // quickback-allow <CODE>: <reason> covers findings of <CODE> on the same line or the line below it.
  • // quickback-allow-file <CODE>: <reason>, anywhere in a file, covers every <CODE> finding in that file.
  • /* … */ comments work too.

An allowed finding does not print; the directive in the source is the record. SOURCE_ALLOW_INVALID (an error) fires when a directive:

  • has no reason,
  • names an unknown code, or
  • suppresses nothing, because the finding was fixed or the file is outside the code's scope. Remove the directive.

Migration window: failMode: "warn"

Source-code errors fail the compile by default. To adopt them on an existing project, set compiler.securityContracts.failMode to "warn". Every source error, including SOURCE_ALLOW_INVALID, then prints as a warning marked downgraded by compiler.securityContracts.failMode = "warn", and the compile continues. The same switch governs the generated-output security contracts. ACTION_ROLE_CHECK_IN_BODY is a warning in both modes.

See also

  • Permissions — the anyOf / allOf / not DSL the empty / shadowed / not-over-resource checks analyze.
  • Arrows — recursive-CTE depth feeds the pushdown-cost report; the unbounded-recursion guard is a hard error.
  • Scopes & capability grants — the grant profiles the capability-ceiling check enforces.
  • Firewall — the { permission } arms the ceiling check follows into the permission graph.

On this page