Neon
Use Neon serverless PostgreSQL with Quickback. Connection modes, Worker-scoped RLS, the set_config claim model, and the deploy workflow.
Neon provides serverless PostgreSQL for Cloudflare Workers. Choose it when the
application is designed around PostgreSQL: native JSONB and arrays, tsvector
with GIN indexes, PostGIS or other extensions, sustained concurrent writes, or
database-level Row Level Security alongside the generated Hono API. D1 also
supports JSON functions and FTS5; Neon is the choice when the PostgreSQL
implementation and ecosystem are the requirement.
Why Neon
- Full PostgreSQL — Advanced queries, joins, PostGIS, full-text search
- Database-level security — RLS policies enforce access even if the API is bypassed
- Serverless — HTTP connection mode (
@neondatabase/serverless) for Cloudflare Workers - Worker-scoped RLS — the Worker verifies each caller, then writes the trusted user/org/team context into transaction-local Postgres settings that RLS policies read (see How Worker-scoped RLS works)
- Better Auth integration — Works seamlessly with the Quickback auth provider
One database for auth and features
Quickback uses a single database provider per app. On Neon, Better Auth tables
and feature tables live in the same Postgres database: Better Auth runs on its
PostgreSQL/Drizzle adapter, and everything connects through one DATABASE_URL.
There is no AUTH_DATABASE_URL, and split-database mode is a D1-only option.
In Hyperdrive mode the same one-database rule holds, but the Worker connection
comes exclusively from env.HYPERDRIVE.connectionString: feature queries and
Better Auth share the generated database helpers. DATABASE_URL is then a
compiler/migration input, not a deployed Worker secret.
Where the tables go
One Neon project hosts one app, and that app uses the project's existing
database (neondb by default). Quickback's migrations create schemas inside
it — app for feature tables, auth for Better Auth, plus webhooks,
audit and drizzle when the project uses them.
Do not CREATE DATABASE per app inside a project. Postgres roles are
cluster-wide but grants, ownership and the migration ledger are per-database,
so an extra database silently splits the bootstrap: the roles look right in
pg_roles while the grants live somewhere the Worker never connects to. Point
DATABASE_URL at the project's own database and let the schemas do the
separating. Two apps means two Neon projects, not two databases.
Mixing providers — for example D1 for auth with Neon for features — is not
supported. The per-service config form is a type error under quickback check
(the config type has no auth / features keys) and is rejected at compile
time:
providers.database service-map form is not supported in v2 ({ auth, features } is the retired v1 shape).
Configure ONE database provider — auth and feature data share it:
database: defineDatabase("cloudflare-d1", { ... }) or database: { name: "neon", config: { ... } }.
On D1, auth keeps its own database by default (splitDatabases: true);
name the bindings with authBinding / featuresBinding.Configuration
import { defineConfig, defineRuntime, defineDatabase, defineAuth } from "@quickback/compiler";
export default defineConfig({
name: "my-app",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("neon", {
connectionMode: "auto", // detects runtime
pooled: true,
}),
auth: defineAuth("better-auth"),
},
});Connection modes
| Mode | Best for | How it works |
|---|---|---|
| HTTP (default) | Cloudflare Workers — batch writes | Stateless HTTP queries via @neondatabase/serverless |
| Hyperdrive | Cloudflare Workers — interactive transactions | postgres.js over a Cloudflare Hyperdrive binding (drizzle-orm/postgres-js) |
| WebSocket (deprecated) | Node.js, Bun | Persistent WebSocket (drizzle-orm/neon-serverless) — superseded by Hyperdrive |
| Auto | Mixed environments | Detects runtime: http on cloudflare, websocket on Node/Bun |
On the Cloudflare runtime, auto resolves to HTTP — batch-only, no binding,
and the mode the rest of this page assumes. Opt a project up to
connectionMode: 'hyperdrive' when its actions need real interactive
transactions (see Interactive transactions).
HTTP is the lightweight default: atomic predetermined batches
(db.batch([...])), no binding. It does not support interactive
db.transaction() — requesting one is a fail-closed compile error.
Hyperdrive is the full-featured path: everything HTTP does plus real
interactive transactions, and it keeps webhooks, cross-tenant unsafe actions,
and .encrypted() / .sealed() columns.
WebSocket is deprecated — Hyperdrive supersedes it. The old WebSocket client
was unscoped and fail-closed on webhooks / unsafe / encryption; new projects
that need transactions should use connectionMode: 'hyperdrive'.
Named Cloudflare deployment environments
Neon Hyperdrive projects can declare isolated Worker targets under
environments. A logical dev / prod pair on a project named attend-v2
deploys through Wrangler as attend-v2-dev and attend-v2-prod:
environments reads from either the top-level environments slot (the
preferred placement, and the one the config scaffold advertises) or the
original providers.database.environments slot shown below. Both are accepted
and behave identically; declaring the block in both places is a compile
error, because there would be no single source of truth.
export default defineConfig({
name: "attend-v2",
email: { provider: "cloudflare", from: "hello@attend.vip" },
bindings: {
secrets: [
{ name: "EVENT_PASS_HMAC_PEPPER_V1", required: true },
],
},
providers: {
runtime: defineRuntime("cloudflare"),
database: {
name: "neon",
config: {
connectionMode: "hyperdrive",
hyperdrive: { id: "ffffffffffffffffffffffffffffffff" },
},
environments: {
dev: {
// Wrangler worker name — required on every target, never derived.
name: "attend-v2-dev",
branch: "dev",
worker: {
env: "dev",
workersDev: true,
previewUrls: false,
vars: {
ENVIRONMENT: "dev",
BETTER_AUTH_URL: "https://attend-v2-dev.example.workers.dev",
},
requiredSecrets: [
"BETTER_AUTH_SECRET",
"EVENT_PASS_HMAC_PEPPER_V1",
],
bindings: {
hyperdrive: {
id: "11111111111111111111111111111111",
localConnectionString: "postgresql://quickback:dev-only@127.0.0.1:5432/attend_v2",
},
kv: { id: "22222222222222222222222222222222" },
rateLimits: {
RL_200_60: "61001200",
RL_1000_60: "61002000",
},
sendEmail: { name: "EMAIL" },
},
},
},
prod: {
name: "attend-v2",
branch: "production",
worker: {
env: "prod",
workersDev: true,
previewUrls: false,
vars: {
ENVIRONMENT: "prod",
BETTER_AUTH_URL: "https://attend-v2-prod.example.workers.dev",
},
requiredSecrets: [
"BETTER_AUTH_SECRET",
"EVENT_PASS_HMAC_PEPPER_V1",
],
bindings: {
hyperdrive: { id: "33333333333333333333333333333333" },
kv: { id: "44444444444444444444444444444444" },
rateLimits: {
RL_200_60: "62001200",
RL_1000_60: "62002000",
},
sendEmail: { name: "EMAIL" },
},
},
},
},
},
auth: defineAuth("better-auth"),
},
});Wrangler bindings and variables are non-inheritable. Quickback therefore emits
the generated vars, required-secret declaration, Hyperdrive, KV, rate-limit,
and Cloudflare Email blocks inside every [env.<name>] target. The base
Worker carries no deployable stateful binding and has workers_dev = false /
preview_urls = false, so a bare deploy cannot accidentally point at shared or
placeholder state.
The environment contract is intentionally exact and fails closed:
workersDev: trueandpreviewUrls: falsemust be explicit for each target.worker.placement({ mode }or{ region }) sets that target's placement; without it, the top-levelproviders.runtime.config.placementis emitted for the target.- Each target declares its own
name(the Wrangler worker name) and no two share one. It is the deployed script's identity — Durable Object state and the migration ledger belong to it — so it is never derived. An existing project must restate the name it already deploys under. - The auth provider must currently be Better Auth. External auth needs a non-inheritable service binding, so it remains rejected until named targets can require an explicit per-environment service-binding override.
- Hyperdrive and KV IDs must be real 32-character Cloudflare resource IDs and cannot be reused between environments.
- Named environments must include a logical
devtarget. It is selected bynpm run devand must own abindings.hyperdrive.localConnectionString. Quickback emits that development-only URL only inside its named Hyperdrive block and rejects it on every other target, soprodalways resolves its deployed connection from the remote Hyperdrive ID. rateLimitsmust contain exactly the binding names generated by the current feature set, with account-unique integer namespace IDs. Add or remove a rate limit and the next compile tells you which override changed.requiredSecretsmust exactly match the generated runtime inventory:BETTER_AUTH_SECRET,ENCRYPTION_KEKwhen envelope-encrypted columns are present, and every custom secret declared withrequired: true. It also includes the sharedAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYcredentials when generated Better Auth code uses SES, SNS, or AWS-backed anonymous-account upgrades; every enabled social provider'sAUTH_<PROVIDER>_CLIENT_IDandAUTH_<PROVIDER>_CLIENT_SECRET; plus the configured R2 account/access/secret env names when presigned uploads are enabled. Values remain out of source control; set them withwrangler secret put <NAME> --env dev(and again forprod).- Include
sendEmail: { name: "EMAIL" }when the generated runtime uses Cloudflare Email Service; omit it when email is not generated. - Every queue and R2 bucket the config generates needs a per-target entry
under
bindings.queues/bindings.r2Buckets, with names unique across targets — see Queues and R2 buckets per target. - Custom domains and routes are owned per target. Quickback emits no
top-level
routesblock for a named-environment project — Wrangler inherits that key, so a shared block would letdeploy -e devrebind the production hostname — and every generated source (providers.runtime.config.routes,domain, CMS/Account/Admin domains,auth.domain,api.domain, app domains and their aliases, and the derivedwebhook.{baseDomain}route) must be claimed by each environment throughdomains. Each target names its own all-in-one host withdomains.primary; none is inferred. See Named Environments for the key-by-key mapping, the single-hostnamedomainshorthand, and thedomains: 'none'workers.dev-only opt-out.
Generated npm run dev selects the dev target (wrangler dev --env dev).
Quickback omits the bare deploy script for named-environment projects and
generates explicit npm run deploy:dev / npm run deploy:prod scripts. Each
maps to its configured Wrangler environment. Apply that branch's migrations
first; migrations intentionally remain a separate target-owned step.
Projects that omit environments retain the existing wrangler.toml and
package scripts byte-for-byte. Named environments require Better Auth, plus
either Neon with connectionMode: "hyperdrive" (this page) or Cloudflare D1
(see Named deployment environments).
Quickback rejects other providers or partial binding sets instead of emitting a
Worker that can deploy but fail at runtime.
Environment variables
HTTP mode has three connection points, each read by a different tool:
| Where | File / store | Read by |
|---|---|---|
| Migrations (local/CI) | .env | drizzle-kit |
wrangler dev (local Worker) | .dev.vars | Wrangler |
| Production Worker | wrangler secret put | The deployed Worker |
The compiler emits .env.neon.example and .dev.vars.example as templates,
and gitignores .dev.vars so local secrets are never committed.
# Migrations read .env — copy the template and fill it in:
cp .env.neon.example .env
# .env holds:
# DATABASE_URL — pooled quickback (runtime) role URL
# DATABASE_MIGRATION_URL — unpooled quickback_admin role URL (optional)
# wrangler dev reads .dev.vars (NOT .env) — copy the template:
cp .dev.vars.example .dev.vars # gitignored; holds DATABASE_URL + BETTER_AUTH_SECRET
# Production: secrets live in Worker secrets, never in files:
npx wrangler secret put DATABASE_URL # pooled quickback role URL
npx wrangler secret put BETTER_AUTH_SECRETDATABASE_URL is the only connection string the Worker needs at runtime — auth
and feature queries share it. DATABASE_MIGRATION_URL is used only by
drizzle-kit: the generated drizzle.config.ts reads
process.env.DATABASE_MIGRATION_URL ?? process.env.DATABASE_URL, so migrations
run as the privileged quickback_admin role when it is set and fall back to
DATABASE_URL when it is not. Never deploy DATABASE_MIGRATION_URL to the
Worker.
Hyperdrive runtime credentials
With connectionMode: "hyperdrive", do not deploy DATABASE_URL to the
Worker. Configure the HYPERDRIVE binding with query caching disabled and keep
DATABASE_URL / DATABASE_MIGRATION_URL only in the compiler or CI
environment for Drizzle schema generation and migrations. Better Auth and
feature queries both use Hyperdrive at runtime, eliminating a second direct
Neon connection lane.
Create the config against the direct Neon host (uncheck "Connection pooling" in the Neon dashboard — Hyperdrive is the pool):
npx wrangler hyperdrive create my-app-db \
--caching-disabled \
--origin-connection-limit 60 \
--connection-string="postgresql://quickback:<password>@<endpoint>.<region>.aws.neon.tech/neondb?sslmode=require"--caching-disabled is mandatory: Hyperdrive keys its cache on query text
alone and never invalidates on write, so a cached SELECT would be replayed
to another tenant. --origin-connection-limit is the soft cap on connections
Hyperdrive opens to Neon (minimum 5; about 100 on a paid Workers plan). Each
in-flight request holds one origin connection per leased client, so size it
to your concurrency, not your statement count. Raise it later with
npx wrangler hyperdrive update <id> --origin-connection-limit <n>.
Tuning the Worker for Hyperdrive
- Set placement. Every authenticated request makes several sequential
round trips (session, membership, the query), so Worker-to-database
distance dominates latency: a single
SELECTmeasured ~180 ms from the caller's colo and ~90 ms withplacement.regionset to the database region. Setproviders.runtime.config.placement: { region: "aws:us-east-2" }(your Neon region; see runtime options), or per target withenvironments.<name>.worker.placement. The compiler warns when a Hyperdrive project has neither. - Keep the session cookie cache on. It is on by default
(
session.cookieCache, 300 s) and removes the session + user lookups — two of the three fixed statements on every authenticated request. See Session handling before turning it off. - Check Neon autosuspend. Hyperdrive keeps origin connections warm, but it does not keep an idle Neon compute awake: the first request after a suspend pays the compute start. Lengthen or disable autosuspend on the production branch.
Cached public reads: a second Hyperdrive binding
Hyperdrive's query cache keys on query text and parameters alone and never
invalidates on write, so it cannot sit under the claims-scoped HYPERDRIVE
binding. It can serve public, tenant-free data through a second binding
whose database role can see nothing else. Opt a resource in with
read.cache:
// features/venues/venues.ts
import { q, defineTable } from "@quickback/compiler";
export const venues = q.table("venues", {
id: q.id(),
name: q.text().required(),
city: q.text().required(),
...q.audit(),
...q.softDelete(),
});
export default defineTable(venues, {
firewall: { exception: true },
read: {
access: { roles: ["PUBLIC"] },
cache: "hyperdrive",
},
});GET /api/v1/venues, GET /api/v1/venues/:id and its named views then read
through HYPERDRIVE_CACHED as quickback_reader, a role the security
migration creates with one privilege: SELECT on the tables that declared
read.cache, each with a USING (true) SELECT policy for that role. The
cache can only ever hold rows that role can read, so cache safety is
structural, not a property of any query. Everything else (writes, actions,
every other table) stays on HYPERDRIVE.
The compiler refuses read.cache unless all of these hold:
- Postgres over Hyperdrive on the Cloudflare runtime.
hyperdrive.cached: { id }on the provider config, orbindings.hyperdrive.cachedon every named environment.firewall: { exception: true }— no tenant or owner row filter.read.accessis a roles-only tree that admitsPUBLIC. Record predicates, relationship / permission arms, principals and function-form access are rejected.- No FK display columns and no
includeon the resource — the reader role holds no grant on other tables.
Masking still applies per caller after the query. Reads can be stale for up
to the cached config's max_age (default 60 s, --max-age to change it;
--swr extends serving-while-revalidating). Server-Timing counts these
statements and the cached connection's open under stmts / cold.
Set it up once per database:
# 1. The security migration created quickback_reader NOLOGIN; give it a login.
psql "$DATABASE_SETUP_URL" -c "ALTER ROLE quickback_reader LOGIN PASSWORD '<reader-password>';"
# 2. A second Hyperdrive config, caching ON, as that role.
npx wrangler hyperdrive create my-app-db-cached \
--max-age 60 \
--connection-string="postgresql://quickback_reader:<reader-password>@<endpoint>.<region>.aws.neon.tech/neondb?sslmode=require"database: defineDatabase("neon", {
connectionMode: "hyperdrive",
hyperdrive: {
id: "<hyperdrive-config-id>",
cached: { id: "<cached-config-id>" },
},
}),With named environments, declare bindings.hyperdrive.cached: { id } on
every target; the dev target also needs
cached.localConnectionString (the reader role's local URL), as does the
provider-level hyperdrive.cached whenever hyperdrive.localConnectionString
is set — wrangler dev connects the cached binding as quickback_reader,
and the quickback role's URL reads nothing through it. The emitted
createCachedDb(env) is read-only by construction: a write or a
db.transaction() on it throws before reaching the database.
For local wrangler dev without named environments, set
providers.database.config.hyperdrive.localConnectionString to a local or
development-only Postgres URL. With named environments, put it on the target
selected by the generated local dev script instead:
environments: {
dev: {
worker: {
bindings: {
hyperdrive: {
id: "11111111111111111111111111111111",
localConnectionString: "postgresql://quickback:dev-only@127.0.0.1:5432/attend_v2",
},
},
},
},
}Quickback requires the logical dev target, rejects the top-level local
override when named environments exist, and rejects localConnectionString
on staging or production targets. Wrangler uses this field only for local
development; deployed Workers obtain their
connection string from the Hyperdrive configuration identified by id.
The generated setup uses three Postgres roles: quickback_owner (NOLOGIN object
owner for the app/auth schemas), quickback_admin (migration/setup actor — use
its unpooled URL for DATABASE_MIGRATION_URL), and quickback (the Worker
runtime role, the only deployed DB role — use its pooled URL for
DATABASE_URL). The first generated migration bootstraps these roles as
NOLOGIN; enable login and set passwords before use. It also gives quickback
statement_timeout, idle_in_transaction_session_timeout and (PostgreSQL 17+)
transaction_timeout — see Worker-role timeouts.
Full walkthrough in the generated docs/neon-setup.md.
Run the first db:migrate with your database owner credential (Neon's
neondb_owner) — that bootstrap run also grants quickback_admin everything
the Drizzle journal needs (CREATE on the database, plus ownership of the
drizzle schema and drizzle.__drizzle_migrations). Routine migrates after
bootstrap — applying new schema deltas — then run entirely as quickback_admin
via DATABASE_MIGRATION_URL, no owner credential required. This includes the
pinned runner's CREATE SCHEMA IF NOT EXISTS drizzle preamble, which needs
database-level CREATE even as a no-op and previously forced every migrate
back onto the owner credential. (A change to the security/RLS config appends
a new quickback_rls migration that re-runs the role bootstrap, so re-point
DATABASE_MIGRATION_URL at the owner credential for that one apply.)
Each security migration first removes direct PUBLIC and quickback runtime
privileges from the Drizzle schema, journal table, journal sequences, and
database-level CREATE, then restores the migration role's capability. The
audit schema/table likewise revoke prior direct grants before restoring only
runtime schema usage + table INSERT and admin forensic access. Reapplying a
migration therefore converges privilege drift instead of merely adding grants.
The role bootstrap's ownership sweep re-owns tables, views, materialized
views, and standalone sequences to quickback_owner, but skips sequences
linked to a serial/identity column — Postgres forbids re-owning those
independently (their ownership follows the table's automatically), so serial
columns never break a security migration.
The audit schema is latched: once a project has emitted it, every later
compile keeps emitting it even if no unsafe / PUBLIC / hard-delete action
remains — the compiler never generates DROP SCHEMA audit, and the audit
subsystem's CREATE statements are hardened to IF NOT EXISTS so
re-application can never collide. audit.events is an append-only forensic
sink; purging it is a deliberate operator action, never a generated migration.
Better Auth schema generation
src/auth/schema.ts is a compiler-owned artifact derived with the Better Auth
CLI during quickback build. The generated build script is deliberately
read-only with respect to generated source:
quickback build # regenerates src/auth/schema.ts and the project output
npm run build # runs tsc only; does not rewrite generated filesFor schema-generator diagnostics, the explicit auth:schema command expands
to:
npx better-auth generate --config ./src/lib/auth.ts --output ./src/auth/schema.ts --yes \
&& node scripts/quickback-qualify-auth-schema.mjsThe better-auth executable comes from the auth devDependency in the
generated package.json — that's the Better Auth CLI package (the
better-auth runtime package ships no bin).
The qualifier script adjusts the CLI output for Postgres schema placement.
auth:schema produces the intermediate Better Auth output and may remove
Quickback's generated-file envelope. Do not commit that intermediate file;
rerun quickback build to restore the canonical project bytes. Don't
hand-edit src/auth/schema.ts.
Recompiling also replaces previously generated package.json lifecycle
commands with their current definitions. An existing project whose build
script still runs auth:schema therefore converges to the TypeScript-only
build, while unrelated custom scripts remain intact.
Better Auth's tables stay vanilla Better Auth
Better Auth's own API is the authorization gate for its tables. Quickback does
not reimplement that gate in SQL — no policy re-derives membership, checks a
role, or splits by verb. What the compiler adds is the same backstop every
feature table gets, so a leaked DATABASE_URL or a mis-scoped handle cannot
read another user's or another organization's auth rows:
| Policy | Kind | On which tables |
|---|---|---|
<table>_deny_anon | RESTRICTIVE FOR ALL | Every managed table |
quickback_service_role_all | permissive FOR ALL | Every table in the auth schema, created by the role bootstrap |
<table>_scope | permissive FOR SELECT | Only tables with a caller-readable scope column |
Reads are tenant/self scoped; writes go through Better Auth only. The scope
policy is FOR SELECT with a USING clause and no WITH CHECK, so the caller
lane can read these rows and change none of them — a handle cannot set its own
member.role. Better Auth's adapter always stamps the service marker, so every
write it makes runs on the service lane. The predicate:
| Table | Scope |
|---|---|
user | id = auth.user_id() |
session, account, passkey | user_id = auth.user_id() |
apikey | reference_id = auth.user_id() — Better Auth's apikey table has no user_id |
organization | id = public.get_active_org_id() |
member, invitation, team | organization_id = public.get_active_org_id() |
team_member | team_id = public.get_active_team_id() |
Everything else is service-only — deny-anon plus the bootstrap lane is the
entire policy set, and no caller-scoped handle can read a row either: verification,
jwks, the Better Auth rate-limit counters, device_code, the
oauth_* provider tables, the agent_* / approval_request agent-auth
tables, two_factor (the twoFactor plugin's TOTP secrets and backup codes),
and wallet_address (siwe). Better Auth's own adapter always stamps the service marker, so its
reads and writes never consult any of this; the policies exist for the handles
that are not Better Auth.
A table with no obvious scope column is service-only by construction. The compiler will not invent a predicate for one — it fails closed.
Migrations
Neon uses PostgreSQL migrations, distinct from D1's SQLite migrations. One Drizzle journal covers auth tables, feature tables, indexes, FK/cascade semantics, and the RLS layer:
quickback build # generates migrations under quickback/drizzle/
npm run db:migrate # applies schema AND the RLS layer, in orderThe RLS layer (helper functions, policies, triggers) rides the same
journaled migration set as your schema: the compiler appends a
content-addressed <idx>_quickback_rls_<hash> migration, so one
db:migrate applies everything in order. The RLS migration is idempotent
(DROP POLICY IF EXISTS + re-create) and only a changed security config
appends a new entry — recompiles with unchanged security append nothing. No
manual SQL application step exists.
The default index set
Indexes are Drizzle's, not the security migration's: the compiler writes the
default set (tenant composite, FK columns, filtered columns, view-sortable
columns) into the schema source, so drizzle-kit generates and diffs them under
<table>_<column>_idx names. See
The default index set for the rules and
the ...q.indexes(t) placeholder.
Older projects also carry idx_<table>_<column> copies the security migration
used to create. Those are dropped in the security migration's trailing
"retired indexes" section, which the journal runs after the schema migration
that created the replacements. Only idx_* names are dropped.
Quickback also dependency-orders Drizzle's standalone constraint replacements.
When a composite unique or primary key and its consuming foreign key are both
replaced, the generated migration drops the dependent foreign key first, drops
and recreates the referenced key, then recreates the foreign key. This avoids
Postgres 2BP01 dependency failures without CASCADE and without requiring a
hand-edited migration. Already-correct migrations remain byte-identical.
Cross-schema and composite relationships that cannot live in one generated
Drizzle schema may be declared with
compiler.migrations.foreignKeys.
Quickback appends them to that same content-addressed migration after the
schema tables and target unique keys exist. Declarations are deterministic and
replay-safe: an existing constraint must match exactly, while existing orphan
rows fail the VALIDATE CONSTRAINT step. Foreign keys enforce integrity only;
keep authorization and business state transitions in the API action layer.
How Worker-scoped RLS works
Quickback does not send browser or mobile clients directly to Neon, and it does not use Neon Authorize / a JWKS endpoint. Clients authenticate with the Cloudflare Worker; the Worker verifies the Better Auth session or API key, runs the access checks, and then performs the database call as a trusted proxy.
For every feature-table query, the generated database client opens a claims-scoped transaction (an implicit Neon HTTP batch or a Hyperdrive transaction) whose first statement writes the verified caller context into transaction-local Postgres settings:
-- Injected by the Worker as the batch's first statement (is_local = true):
SELECT
set_config('quickback.service_role', '', true),
set_config('request.jwt.claim.sub', 'usr_123', true),
set_config('request.jwt.claim.org_id', 'org_abc', true),
set_config('request.jwt.claim.team_id', '', true),
set_config('quickback.principal.type', '', true),
set_config('quickback.principal.actor_type', '', true),
set_config('quickback.principal.actor_id', '', true),
set_config('quickback.principal.session_id', '', true),
set_config('quickback.principal.family_id', '', true),
set_config('quickback.principal.claims', '{}', true);On Hyperdrive the values are not bound parameters: each is hex-encoded and
decoded in SQL (convert_from(decode('…', 'hex'), 'UTF8')), so the preamble is
a single parameterless statement that shares a message with BEGIN and the SQL
text can never contain a claim value verbatim.
RLS policies read those settings through Quickback-defined helper functions —
there is no external JWT verification and no persisted session state in the
database. auth.user_id() is a compiler-generated shim, not a Neon Authorize
function:
-- Generated by Quickback (granted to the quickback runtime role):
CREATE OR REPLACE FUNCTION auth.user_id()
RETURNS TEXT AS $$
SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')
$$ LANGUAGE SQL STABLE SET search_path = public;Because the settings are transaction-local (set_config(..., true)), they
apply only to the current batch and never leak between requests. If the Worker
never stamps a user id, auth.user_id() returns NULL and the restrictive
deny_anon policy blocks the query unless a verified delegated principal is
present — fail closed. Account requests explicitly clear all principal fields;
delegated requests explicitly clear the account/org/team fields.
An API key's organization metadata is only a candidate scope. Before the
middleware stamps it into AppContext, the generated Neon membership lookup
opens a request-scoped database handle with the verified API-key owner as
userId and the candidate organization as orgId, then requires the member
row to match both values. A missing or RLS-denied row clears organization and
role authority.
Row Level Security
Generated RLS is a backstop, not a second copy of access. Every database
call goes through the API, and the API owns roles, status, transitions and
masking. RLS exists so that a bug in a custom route, or a handle that escaped
its scope, cannot cross a tenant, resurrect a soft-deleted row, or read another
owner's row. It is the floor under the application, not a second application.
Who RLS backstops. The request database handle stamps caller claims only
for callers the policies can key on: a signed-in account (ctx.userId) and
a delegated principal (whose databaseAccess lane is enforced only here).
Every other caller — a PUBLIC door, a sessionless scope principal such as an
event pass — runs on the service lane, and the generated app firewall scopes
each of its queries, exactly as on D1. Without that, those callers would read
nothing: no policy recognises an anonymous or scope-only caller, so a pass
holder's namespace gate and a scope mint's relationship lookup both came back
empty.
Quickback lowers your firewall config — and only your firewall config —
into policies that read the transaction-local claims described above through
auth.user_id(), get_active_org_id(), get_active_team_id(), get_scope()
and the get_principal_*() helpers. Each feature table gets exactly three
policies, plus a fourth only where a delegated-principal lane is declared:
| Policy | Kind | What it does |
|---|---|---|
<table>_deny_anon | RESTRICTIVE FOR ALL | Blocks any query carrying neither a stamped user id, nor the service marker — nor, on a table with a delegated lane, a verified delegated/scope principal |
<table>_service_role | permissive FOR ALL | Admits Worker-internal contexts — see Background jobs and cross-tenant actions |
<table>_scope | permissive FOR ALL | The firewall scope predicate. On a soft-deletable table USING also requires deleted_at IS NULL OR public.in_soft_delete() — see The soft-delete lane; WITH CHECK is the scope predicate alone, so a row can be soft-deleted but never resurrected. Every other isNull arm ({ field: 'projectId', isNull: true } inside an any:, say) is scope and stays in both clauses |
<table>_principal_fence | RESTRICTIVE FOR ALL | Only on tables with a databaseAccess lane or access.principals. Caps every permissive policy, so no policy can broaden a request that carries the wrong principal type |
The fence is per-lane, not per-table, because on a table no principal can reach
it would read get_principal_type() IS NULL — blocking every principal, which
is exactly what leaving them out of deny_anon already does. Two policies for
one outcome; the compiler emits neither.
Every helper call inside a policy predicate is emitted as (SELECT …). The
claims are transaction-local and stamped before the statement, so PostgreSQL
hoists the call into an InitPlan and evaluates it once per statement instead
of once per scanned row.
firewall: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
]CREATE POLICY "documents_scope"
ON "app"."documents" FOR ALL
TO quickback
USING (
organization_id = public.get_active_org_id()
AND (deleted_at IS NULL OR public.in_soft_delete())
)
WITH CHECK (
organization_id = public.get_active_org_id()
);The soft-delete lane
deleted_at IS NULL keeps soft-deleted rows out of every caller's reach. It is
in the scope policy of every table that carries deleted_at, exactly where the
API firewall adds its own isNull(deletedAt) — including a firewall: [...]
array that never mentions deletedAt (declare a predicate on deletedAt
yourself to take it over). On its own it also blocks the delete that put them
there.
PostgreSQL applies a SELECT-applicable policy's USING expression to the new
row of an UPDATE, not only the existing one, whenever the statement reads the
relation — which any UPDATE … WHERE does. The new row of a soft delete has
deleted_at set, so a bare deleted_at IS NULL refuses the statement with
42501, including the generated DELETE route's own write. Moving the filter to
a FOR SELECT policy does not help, and neither does dropping RETURNING: the
check follows the filter wherever it goes.
So the filter carries a lane. public.in_soft_delete() reads the
transaction-local quickback.soft_delete marker, and the generated write
chokepoint sets it for exactly the statement whose payload stamps
deletedAt — the same condition that stamps the audit columns. It is a
one-shot token: the claims preamble carries it into that statement's
transaction and clears it, so the next statement of the same request runs
without it.
It is the same trust model as the service marker: Worker-set, never derived from
request input. And it is narrower — it exempts the soft-delete filter and
nothing else. Tenancy, ownership and scope stay ANDed beside it, so a statement
inside the window still cannot cross a tenant; WITH CHECK never carries the
lane, so it cannot land a row the firewall rejects; and the emitted statement
keeps deleted_at IS NULL in its own WHERE, so it cannot resurrect one.
Exception tables (firewall: [{ exception: true }]) keep their permissive
<table>_all policy, and SYSTEM tables — idempotency keys, the webhook store,
Better Auth rate-limit counters, subscriptions, file metadata — stay
service-role only.
Better Auth's own tables carry the same backstop, in its two- or three-policy form: see Better Auth's tables stay vanilla Better Auth.
What is deliberately not in SQL
Roles, status, transitions, masking and per-operation access are not lowered into policies. That is a decision, not a gap:
- Roles.
access.rolesnames your application's vocabulary. Mirroring it into SQL made every role rename append a security migration, and left two enforcement points that could disagree — with the SQL copy being the one nobody reads. The API is the single gate. - Status and transitions. A policy sees one row and no request context, so
it cannot express "an archived invoice may only move to
restored, by the route that logs the transition". A half-expressed rule in SQL is worse than no rule in SQL. - Masking. Masking shapes a response. RLS decides which rows a caller may touch, never which columns come back redacted.
- Per-operation access. One
FOR ALLscope policy is the same fence for every verb. Four near-identical policies only made the tenant predicate harder to read and easier to get subtly wrong in exactly one of them.
The consequence is intentional: at the SQL layer, a caller whose
organization role is member can update a same-org row. The route they would
have to call still refuses them, and anything reaching the database outside a
generated route holds a caller-scoped handle whose tenant is already fenced.
The compiler enforces the shape at compile time. QB-RLS-SCOPE-POLICY requires
the three policies (and the fence wherever a delegated lane exists — and only
there), requires a tenant/owner/scope predicate in the scope policy's
USING clause, and fails the build if any policy body calls has_any_role.
Opt out with compiler.securityContracts.rls.requireScopePolicy: false —
requireCrudPolicies is the pre-016 name for the same key and is still honoured.
Upgrading an existing database
An existing project gets one appended quickback_rls security migration on
its next compile. It runs DROP POLICY IF EXISTS on the retired names before
creating the new ones, so the upgrade is append-only: prior migration files keep
their bytes and their journal hashes, and a second unchanged compile appends
nothing.
Apply it with the setup-owner credential, not quickback_admin. The
security migration reissues the helper functions, and PostgreSQL refuses that to
a role that does not own them (42501 must be owner of function ...) — the same
requirement that already applies to any changed security or bootstrap SQL.
The same migration also drops the five helper functions the generated policies
no longer reference — has_any_role, has_org_role, get_user_role,
is_owner and is_org_member — after the policy statements that stop
referencing them. Membership is proven by the Worker and stamped as the request
org, so nothing re-derives it in SQL.
The retired names include the per-verb auth-table policies
(users_select, members_insert, …) that older projects carry. They are
dropped, so an upgraded database converges on the narrower set rather than
keeping a wider policy nobody emits any more.
Column type changes over policies and triggers. Postgres refuses
ALTER COLUMN … SET DATA TYPE while a policy or trigger definition references
the column (cannot alter type of a column used in a policy definition). Every
soft-delete policy reads deleted_at, and sql: triggers pin it through
UPDATE OF and WHEN, so a bare type change — the 0.70 timestamp(3)
audit-column migration, for one — could not apply to a provisioned database.
Each generated SET DATA TYPE is therefore emitted inside a DO block that
reads pg_depend, drops the policies and triggers that reference that column,
alters it, and recreates them verbatim. Hand-written policies and triggers are
carried the same way. A 0.70.0–0.70.2 schema migration that was generated but
never applied keeps its bare statements (applied history is never rewritten);
regenerate it by deleting that unapplied file and its journal entry, then
quickback build again.
Indexes are rebuilt once. The compiler used to infer idx_<table>_<column>
indexes in the security migration, alongside the <table>_<column>_idx indexes
Drizzle generates from the schema. Drizzle now owns the whole default set, so
the schema migration creates the drizzle-managed index and the security
migration that follows it in the journal drops the compiler-owned copy — the two
overlap for the length of one db:migrate run and nothing is ever left
unindexed. Only idx_* names are dropped; a drizzle-managed index is never
touched. See the default index set.
Delegated principal RLS
Delegated principals require Cloudflare + Postgres over Hyperdrive: Neon with
connectionMode: "hyperdrive", or
PlanetScale Postgres, which emits the
same lookup policies and lanes. Their proof hook
returns only a digest and receives no database handle. Quickback then runs one
lookup under an internal lookup-only principal whose RLS policy admits only the
configured table row matching credentialDigest, plus any configured active
status condition. The generated unique lookup index is content-addressed: a
changed table, digest column, or definition drops the prior logical index and
creates the exact replacement. Names are hash-suffixed to avoid collisions and
remain inside PostgreSQL's 63-byte identifier limit.
After the lookup, the normal request database handle carries the resolved principal type, actor, optional session/family, and JSON claims in the same transaction-local preamble shown above. RLS reads them with:
| Function | Purpose |
|---|---|
get_principal_type() | Exact configured type such as event_delegate |
get_principal_actor_id() | Stable delegated actor id |
get_principal_session_id() | Optional session id |
get_principal_family_id() | Optional session-family id |
get_principal_claim(name) | One configured string claim such as eventId or personId |
Generated request types are a closed union of the configured principal names,
literal actor types, exact session shape, and configured claim keys. The Neon
database context uses the same resolved union plus separate compiler-internal
__credential_lookup__:<type> variants. Lookup-only authority therefore
cannot appear in an action AppContext, and a delegated action context cannot
carry account/org/team/membership fields.
resource.databaseAccess opens a delegated-principal lane at the database:
RLS only, no generic routes and no OpenAPI operations. It exists because a
delegated principal has no account identity — no userId, no active
organization, no membership roles — so the table's ordinary firewall predicate
can never match for it. The lane is the row rule that can: the principal's own
verified claims, read through get_principal_claim().
// The account lane is the FIREWALL. The delegate lane is databaseAccess.
firewall: [
{ field: "organizationId", equals: "ctx.activeOrgId" },
],
databaseAccess: {
select: {
principals: ["event_delegate"],
record: {
eventId: { equals: "$ctx.principal.claims.eventId" },
personId: { equals: "$ctx.principal.claims.personId" },
},
},
}That emits exactly one permissive policy on the table:
CREATE POLICY "attendance_evidence_principal_scope"
ON "app"."attendance_evidence" FOR SELECT TO quickback
USING (public.get_principal_type() = 'event_delegate'
AND "event_id" = public.get_principal_claim('eventId')
AND "person_id" = public.get_principal_claim('personId'));Several principal types on one table are several OR arms of that same policy, never several policies. Postgres ANDs the record comparisons inside each arm.
The policy's command is the lane's declared reach: FOR SELECT with no
WITH CHECK when only databaseAccess.select is declared, FOR ALL with the
same predicate as WITH CHECK as soon as any of insert / update / delete
is. Whether a delegate lane may write is a property of the lane, declared once
by the author, so the database enforces it as the policy's command; WHICH verb
a caller may reach is still the API's decision.
The lane lowers principals + record and nothing else. Two shapes are
compile errors, with the same message: roles anywhere (authored roles are
enforced by the API and never reach SQL, so a role arm here promised an
authorization the database did not enforce), and a record equality with no
principals gate (that is an owner/tenant rule, and those belong in
firewall, the one pillar that lowers to SQL). Every authorization path must
still carry at least one SQL-lowerable record equality; an unbounded or arm
or a principal-only leaf fails the compile.
The policy is permissive, and PostgreSQL ORs permissive policies together,
so databaseAccess opens a lane beside <table>_scope rather than narrowing
it — that is the point (the delegated caller has no active organization, so the
tenant predicate can never match), but it means a databaseAccess arm is never
a place to write a restriction. A row that satisfies the arm is reachable even
when the table firewall would not have matched it, and even when it is
soft-deleted. Write the restriction in firewall; the restrictive
<table>_deny_anon and, where a lane is declared, <table>_principal_fence are the only arms
that cap every lane. The fence also means a table admits a principal type only
where its access (or its lane) names that type — a ctx.principal.claims.*
firewall arm on a table whose access names no principal is unreachable.
Upgrading. The four per-operation <table>_database_<op> policies are
retired. One appended quickback_rls migration drops them on every table and
creates the single <table>_principal_scope where a lane is declared —
append-only, like every other security migration, so no applied file changes.
Firewall patterns
Every pattern is the same one FOR ALL scope policy with a different
predicate — no per-operation variants, no role arms.
Organization-scoped:
CREATE POLICY "projects_scope" ON "app"."projects" FOR ALL TO quickback
USING (organization_id = public.get_active_org_id())
WITH CHECK (organization_id = public.get_active_org_id());User-scoped (owner_id, the auto-firewalled ownership column):
CREATE POLICY "preferences_scope" ON "app"."preferences" FOR ALL TO quickback
USING (owner_id = auth.user_id())
WITH CHECK (owner_id = auth.user_id());Relationship-scoped ({ field: 'projectId', via: 'projectMemberOf' }) — see
Relationship arms in SQL:
CREATE POLICY "tasks_scope" ON "app"."tasks" FOR ALL TO quickback
USING (organization_id = public.get_active_org_id()
AND project_id IN (SELECT r.project_id FROM public.qb_rel_project_member_of() r))
WITH CHECK (organization_id = public.get_active_org_id()
AND project_id IN (SELECT r.project_id FROM public.qb_rel_project_member_of() r));Exception tables (firewall: [{ exception: true }]) — no scope predicate,
so the restrictive deny-anon policy is the only fence left:
CREATE POLICY "categories_all" ON "app"."categories" FOR ALL TO quickback
USING (true) WITH CHECK (true);Relationship arms in SQL
Every relationship lookup is enforced by the database, and the generated API reads through the same place. Each declared relationship gets one helper, emitted in the RLS migration ahead of the policies that call it:
CREATE OR REPLACE FUNCTION public.qb_rel_project_member_of()
RETURNS SETOF "app"."project_members"
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
AS $$
SELECT a.*
FROM "app"."project_members" a
WHERE a."user_id" = auth.user_id()
AND a."deleted_at" IS NULL
$$;
ALTER FUNCTION public.qb_rel_project_member_of() OWNER TO quickback_owner;
REVOKE ALL ON FUNCTION public.qb_rel_project_member_of() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.qb_rel_project_member_of() TO quickback;The scope policy calls it as
"project_id" IN (SELECT r."project_id" FROM public.qb_rel_project_member_of() r),
and so does the generated API: a firewall via: arm, a relationship-role access
check, the action resolver's match row, the /scope/v1/enter probe and the
ws-ticket probe all read the caller's relationship rows from the helper, never
from the relationship table under its own policy.
- Same lookup as D1. The body is the relationship subquery the API runs on
D1: the subject column against the caller's claim, the
where:literals (SQL-quoted), and the related table's soft-delete filter — the API'sskipTenantScopesbody. Multi-hop (hops) relationships join the same chain and return the first hop's rows. - Why
SECURITY DEFINER. Under the relationship table's own policy the lookup would recurse for a relationship that reads the table it protects (projectMemberOfonproject_members), and the table's tenant scope would zero a caller with no active org — the case the API skips on purpose. The helper runs asquickback_owner, which reads the related table through one<table>_quickback_owner_readpolicy (FOR SELECT TO quickback_owner USING (true)). Feature tablesFORCE ROW LEVEL SECURITY, so the owner needs it; it grants nothing new — an owner can always liftFORCEon its own table — and the runtime rolequickbackis not a member ofquickback_owner. - Locked down.
search_path = ''and fully-qualified names;EXECUTEis revoked fromPUBLICand granted toquickbackonly. The caller comes from the transaction's claims, so a lookup on the service handle (no caller) returns nothing. - Evaluated once per statement. The helper takes no arguments, so
col IN (SELECT … FROM helper() r)is an uncorrelated subquery — one hashed subplan per statement, the same InitPlan discipline as the claim helpers. A per-rowhelper(row.col)call could never be inlined. - Returns the table's own rows. One helper serves every projection a lookup
needs (the resource column, the tenant columns the action resolver stamps,
scope sub-keys), and
CREATE OR REPLACEstays valid as the table's columns change. A helper whose relationship now returns a different table is dropped first — with the generated policies that call it, which the same migration re-creates. - Tenant binding. Beside an org arm in an
any:group the arm becomes(project_id IN (SELECT …) AND (organization_id = <active org> OR <no active org>))— exactly where the API applies the same binding. - Permissions. A
{ permission }arm expands as the API expands it; arrow hops becomeqb_arrow_…helpers (recursive arrows are amaxDepth-bounded closure that re-checks the tenant on every row; a relation-backed arrow keeps the hop's rows whose FK is in the relationship'sqb_rel_…set), called by the API too. - Still API-only. Literal status filters (a conjunct would refuse
status-changing updates, because PostgreSQL checks an UPDATE's new row against
the read policy) and claims the Worker never writes into the transaction —
ctx.scope.<kind>.<subKey>, area anchors such asctx.event.id, and relationships whosesubjectbinds one. These lower totruein the policy, and the API keeps the table query for such a relationship.
Helpers no compile references any more are dropped at the end of the migration.
Helper functions
| Function | Purpose |
|---|---|
auth.user_id() | Reads the verified user id from the transaction-local request.jwt.claim.sub setting |
get_active_org_id() | Reads the request-scoped org from the transaction-local request.jwt.claim.org_id setting |
get_active_team_id() | Reads the request-scoped team from request.jwt.claim.team_id (teams mode) |
is_admin() | Checks the auth.admins table (admin mode). No scope policy calls it. |
There is no role-lookup helper and no membership helper. Roles are the API's
gate, not SQL's: QB-RLS-SCOPE-POLICY fails the build if any policy body calls
has_any_role. Membership is Better Auth's gate: the Worker proves it once and
stamps the active organization, and policies compare against that stamp.
is_admin() is no part of that gate either. Enabling the admin plugin does
not widen any scope policy: "admin" is a role, and a scope policy is the
firewall predicate, full stop. An admin's cross-tenant read goes through the
API on the service lane — an unsafe action stamps
quickback.service_role, which <table>_service_role admits — not by SQL
re-deriving admin from auth.admins. Older databases carry the previous
(public.is_admin() OR (…)) form until the new security migration replaces
those policies; it does, in the same append-only pass.
All helpers are TEXT-typed to match Better Auth's prefixed ids (org_abc123).
The request org/team scope is passed by the Worker per transaction, not read
from a persisted mirror. There is no compiler-owned user_sessions table and no
session hook that syncs the active organization into the database — switching to
"no org" simply means the Worker stamps an empty request.jwt.claim.org_id, and
SQL-side access is revoked immediately. Fail closed.
The only compiler-owned support tables the RLS layer reads live in
src/db/rls-support.schema.ts: a profiles table (populated by the
sync_profile_display_name() trigger and protected by profiles_deny_anon
plus a self-scoped, read-only profiles_scope) and, when admin/sysadmin helpers are
emitted, an admins table (read
by is_admin() / is_sysadmin()). Better Auth's own sessions table may still
carry an active_organization_id, but Quickback RLS never reads it.
RLS coverage includes Better Auth's own tables — including those the compiler
force-enables (organization, admin, apikeys; plus the OAuth/agent table
families when those surfaces are on, which are service-only). Database-backed
Better Auth rate-limit counters also receive both ENABLE and FORCE ROW LEVEL SECURITY. Only the internal auth service context can read or change those
counters; ordinary caller claims grant no access. See Better Auth's tables
stay vanilla Better Auth for
the exact policy set per table.
Cloudflare uses database counters by default. Explicit memory storage or
inactive rate limiting does not generate policies for a missing counter table.
For existing databases, recompile and apply the newly appended security migration using the setup-owner credential, as required for changed security/bootstrap SQL, to protect existing counters. Applied migration files stay unchanged and existing counter rows are preserved.
Background jobs and cross-tenant actions
Some code runs with no HTTP request and therefore no caller claims — queue
consumers, cron schedules — and unsafe actions opt out of them. These acquire the
service-role handle createServiceDb(env) from the generated src/db
index. Instead of caller claims, it sets quickback.service_role = 'true' (with
all request claims cleared) — the same marker the Better Auth adapter's
transactions use. Every feature table carries a <table>_service_role RLS
policy that admits exactly that context, so background work can reach rows
across tenants while the request-scoped createDb() client stays caller-scoped.
The service-role marker is only ever set by Worker-internal chokepoints
(createServiceDb, the Better Auth adapter) — it is never derived from request
input. Every unsafe action gets this handle as unsafeDb, crossTenant
or not, so it reads what it would read on D1 — RLS does not narrow it to the
caller. Unsafe actions keep their mandatory audit contract: cross-tenant writes
still land audit events (the audit Postgres schema), unchanged from D1.
Reads through a user-anchored relationship
Some resources are readable not because you own them or belong to their org, but
because you are reachable from them through a chain of tables anchored on the
caller's user id. A conference attendee, for example, can read their own
registrations even though they are not an org member — the grant travels
person_account_links.user_id = ctx.userId → people → event_people → registrations.
Declare the chain once as an authz.relationships entry using the hops form
(each hop names the joined table, the column it is joined on, and the prev
column on the previous table that points at it). The terminal hop's table is
the anchor — the table carrying the caller's user id:
// quickback.config.ts
authz: {
relationships: {
attendeeReachable: {
from: 'person_account_links', // the anchor table
subject: { column: 'userId', equals: 'ctx.userId' },
resource: { column: 'eventPersonId' }, // the column on the resource this constrains
hops: [
{ table: 'event_people', on: 'id', prev: 'eventPersonId' },
{ table: 'people', on: 'id', prev: 'personId' },
{ table: 'person_account_links', on: 'personId', prev: 'id' },
],
},
},
},A resource opts into the grant by referencing the relationship from its
firewall — typically as one arm of an any: group so org members keep their
own path in:
// features/registrations/registrations.ts
firewall: [
{
any: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' }, // org-member grant
{ field: 'eventPersonId', via: 'attendeeReachable' }, // reachable-attendee grant
],
},
],The anchor must be non-forgeable (fail closed on both targets)
The reachability chain terminates in anchor.user_id = ctx.userId, so whoever
can write an anchor row can mint themselves reachability to a victim's data.
Quickback therefore refuses to compile a user-anchored multi-hop relationship
unless the anchor table is:
- caller-read-own on the subject column — its firewall scopes reads to
ctx.userId(e.g.firewall: [{ field: 'userId', equals: 'ctx.userId' }]), and - service-role-write-only — it exposes no caller-reachable
create/update/deleteendpoint, so the link can only be created by Worker-internal code.
This check is target-independent: it fires identically on Neon and D1, because the reachability join runs in the app layer on both. A forgeable anchor is a compile error everywhere, not a Neon-only concern.
How the read is served
Reads keep the caller-claims handle and the full RLS backstop, on Neon as
everywhere else. The reachability WHERE (buildFirewallConditions(...)) and the
resource's scope policy both call the relationship's
helper, which walks the hop chain without the
intermediate tables' tenant policies — so an attendee with no active org gets
exactly their reachable rows, from the API and from the database alike.
Earlier releases routed these reads through the service-role handle
(createServiceDb) instead, because the multi-hop join read the intermediate
tables under their own tenant scopes and came back empty. That bypassed RLS for
the whole read; the routing is gone.
The same config compiles identically on D1: the read handlers keep
c.get('db'), and the app-layer reachability WHERE runs the join directly.
Webhooks on Neon
Webhooks are supported on Neon over HTTP. The store (webhook_events,
webhook_endpoints, webhook_deliveries) lives in a dedicated webhooks
Postgres schema of the same database — following the audit schema
precedent — so there is no separate WEBHOOKS_DB binding.
- Set
webhooksBindingto enable webhooks. On Neon its value is a pure enable flag (no D1 binding is emitted); the queue names stay provider-neutral. - The D1-only keys
webhooksDatabaseIdandwebhooksDatabaseNameare rejected at compile time — they name a D1 database that cannot exist on Neon. - The webhook store is Worker-internal (endpoint signing secrets live there):
every table in the
webhooksschema has RLS enabled with a single service-role-only policy, so onlycreateWebhooksDb()(which wrapscreateServiceDb) can touch it. Route handlers gate access at the app layer (requireOrgMember/ endpoint-ownership checks) before querying. - Webhooks require
connectionMode: 'http'.
Managed file storage on Neon
fileStorage: { managed: true } is supported on http and hyperdrive. The
file metadata (buckets, objects) lives in a dedicated files Postgres
schema of the same database — the webhooks precedent again — so there is
no FILES_DB binding, no second drizzle config and no second migration target.
- The tables ride the project's single journal, reached through the
src/db/schema.tsbarrel. - Column shapes are 1:1 with the D1 version so no
/storage/v1/*body changes: ISO-8601 date strings, JSON-encoded role and MIME lists, the same object keys and the same soft-delete column. The one divergence is numeric width —sizeandfile_size_limitarebigint, because a Postgresintegerwould cap objects at 2 GiB. - The store is a SYSTEM table: RLS is enabled and forced with a single
service-role policy, so only
createFilesDb()(which wrapscreateServiceDb) can read or write it. There is no caller-scope policy and no role arm — public-versus-private file access is decided by the/storage/v1/*routes and the file-serving Worker before the handle is used, exactly as it was against the D1 binding. - The file-serving Worker reads the same database over the same transport:
a
HYPERDRIVEbinding on Hyperdrive projects, or the sameDATABASE_URLsecret on HTTP projects — set on that Worker too. Never a mix of the two. connectionMode: 'websocket'is rejected: it emits nocreateServiceDb.
See File Storage for the endpoints, bucket scopes and deployment steps.
Encryption on Neon
The encryption pillar — .encrypted() (envelope) and .sealed() /
q.vaultRef() (sealed) columns — is supported on Neon over HTTP and
Hyperdrive. The
org_data_keys and seal-vault tables fold into the single journaled migration
set, and the encryption RLS section rides the journaled RLS migration:
org_data_keys gets org-scoped RLS (the envelope DEK path needs caller claims),
and the vault spine is locked to service-role-only. The seal chokepoints
acquire createServiceDb.
The deprecated Node/Bun WebSocket mode is a fail-closed compile error for both tiers because it cannot provide the request-scoped and service-role handles the key tables require.
Batch transactions
Whether a batch handler (PATCH /batch, DELETE /batch, …) is truly atomic
depends on the connection mode, and the response advertises the guarantee via
meta.transactional:
| Mode | meta.transactional | Behavior |
|---|---|---|
| Neon HTTP (Cloudflare default) | false | Fail-fast per-record loop, no rollback — drizzle-orm/neon-http has no interactive transactions, so this matches D1's semantics honestly |
| Neon Hyperdrive (Cloudflare) | true | db.transaction() wraps the loop; any failure rolls back every write in the batch |
| Neon WebSocket (Node/Bun) | true | db.transaction() wraps the loop; any failure rolls back every write in the batch |
drizzle-orm/neon-http throws "No transactions support in neon-http driver" at
runtime, so the HTTP path deliberately treats batches like D1 (fail-fast,
non-transactional) rather than emitting code that would 500 on every failed
batch. Honest advertisement over runtime surprises. For true atomic batches and
interactive transactions on Cloudflare, use
connectionMode: 'hyperdrive'.
Interactive transactions
connectionMode: 'hyperdrive' runs the feature database over a Cloudflare
Hyperdrive binding using
postgres.js (drizzle-orm/postgres-js), which supports real interactive
transactions — read current state, lock it, decide in TypeScript, then write
dependent rows, all committed atomically:
import { z } from "zod";
import { eq } from "drizzle-orm";
import {
defineAction,
ActionError,
registrations,
registrationEvents,
} from "../.quickback/define-action";
export default defineAction({
description: "Advance a registration inside one interactive transaction.",
access: { roles: ["admin"] },
input: z.object({ registrationId: z.string() }),
async execute({ db, input }) {
return db.transaction(async (tx) => { // tx is fully typed
const [reg] = await tx.select()
.from(registrations)
.where(eq(registrations.id, input.registrationId))
.for("update"); // row lock held to commit
if (!reg) throw new ActionError("Not found", "NOT_FOUND", 404); // → rollback
const [updated] = await tx.update(registrations)
.set({ status: nextStatus(reg) })
.where(eq(registrations.id, reg.id))
.returning();
await tx.insert(registrationEvents).values({ registrationId: updated.id });
return updated; // atomic commit
});
},
});Note the tx.select()…for("update") form: Drizzle's relational query API
(tx.query.<table>.findFirst) is blocked on the scoped
db/tx
because the tenant firewall can't be injected into relational queries. The
select builder gives you the same row lock with scope conditions applied
automatically.
Configure it on the database provider:
providers: {
database: {
provider: "neon",
config: { connectionMode: "hyperdrive", hyperdrive: { id: "<binding-id>" } },
},
}The compiler emits the [[hyperdrive]] wrangler binding pointing at your Neon
direct connection string. Each Worker invocation (request, queue batch,
cron, inbound email) opens one lease pool of up to five single-connection
postgres.js clients, shared by every createDb / createServiceDb call in it
— the request db, Better Auth, idempotency, the audit writer. Statements are
prepared (Cloudflare's guidance for postgres.js on Hyperdrive, which caches
named prepared statements; an unprepared statement costs an extra
parameter-describe round trip) and type-fetch round trips are skipped;
Hyperdrive owns the origin pool.
Every write statement runs as one pipelined claims transaction on a
leased connection: BEGIN plus the claims preamble travel as one message, then
the statement, then COMMIT, all written before anything is awaited. That is
one round trip once the statement is prepared on that connection and three
while it is cold — down from six. It fails closed by construction: a preamble
that errors after BEGIN aborts the transaction so the statement is refused
rather than run unscoped, and a failed statement turns the pipelined COMMIT
into ROLLBACK.
Reads share one transaction per connection. The first read-only statement
on a leased connection opens BEGIN READ ONLY plus the claims preamble and
leaves that transaction open; every later read on the same connection with the
same claims runs bare, one round trip, with no BEGIN/COMMIT around it. A
read under different claims (a service-lane lookup between caller reads, a
scope minted mid-request) re-stamps the preamble inside the open transaction —
set_config(…, true) is transaction-local, so the previous claims are gone the
moment the new ones land. The read lane is rolled back before a write or a
db.transaction() takes the connection, after any read error, as soon as the
handler returns (before deferred waitUntil work runs — a deferred read opens
a fresh lane), and when the invocation scope closes the pool. That ROLLBACK is its own round trip, never
pipelined with the write's BEGIN: Hyperdrive's transaction pooler releases
the origin connection at the transaction end and loses anything pipelined past
it, which hung every write that followed a read in 0.70.10–0.70.12. A
SELECT the server refuses inside a read-only transaction (nextval(), a
volatile function that writes) falls back to the write lane automatically. Only statements that begin with SELECT or a
non-modifying WITH and carry no FOR UPDATE / FOR SHARE qualify; anything
else is a write.
Each read sees everything committed before it, including the request's own
writes — Postgres runs READ COMMITTED by default, and a read-only transaction
changes nothing about that. Hyperdrive pools in transaction mode, so an open
lane pins one origin connection until the handler returns (up to five under
Promise.all); idle_in_transaction_session_timeout (15 s on the deployed
role) and the scope close bound the worst case. A ten-statement read path costs
about eleven round trips instead of thirteen to thirty-four, which is where the
tx versus stmts ratio in Server-Timing
comes from.
The pool closes when the invocation ends, after its waitUntil work
(deferred audit writes, after-commit effects) settles. A client that is never
closed leaves its backends mid-transaction, holding Hyperdrive origin
connections until Postgres times them out. Code outside an invocation — a
Durable Object, a test — gets a fresh pool per createDb call and owns
closing it.
Measuring database cost: Server-Timing
Every response from a Hyperdrive project carries a
Server-Timing
header, and the Worker logs one structured line per request with the same
fields plus the matched route pattern:
Server-Timing: total;dur=312.6, db;dur=212.4;desc="stmts=11 tx=2 cold=1", gate;dur=31, action;dur=240.8{"event":"request.timing","requestId":"…","method":"POST","route":"/api/v1/tasks/:id/approve","status":200,"totalMs":312.6,"dbMs":212.4,"stmts":11,"tx":2,"cold":1,"gateMs":31,"actionMs":240.8}| Field | Meaning |
|---|---|
total | Wall time from the request-id middleware to the response |
db | Time statements held a leased connection, summed. Statements that run concurrently (Promise.all) each count in full, so db can exceed the time the request actually spent waiting |
stmts | Statements run, including those inside db.transaction() |
tx | Claims transactions opened (one per statement, one per db.transaction()) |
cold | Connections the lease pool had to open for this invocation |
auth | Time from the request-id middleware to the end of the auth middleware: Better Auth's session and user lookups plus the membership/role lookup for the active org. Paid by every authenticated request, CRUD and action alike |
gate / action | Action routes only: the generated pre-action pipeline (auth, access, firewall pre-reads, input validation) vs. the action's execute body |
Statement text and parameters are never logged. Work the invocation defers to
waitUntil (audit writes, after-commit effects) runs after the header is sent
and is not counted.
cold=5 tx=5 on every bundle request is the pool shape, not a leak. The
lease pool is per invocation and holds up to five connections. A
bundle runs its sections in one Promise.all, so five or
more sections lease five connections at once; each is opened cold for this
request and opens its own read lane (BEGIN READ ONLY + claims), which is
the tx count. Summed db time exceeds wall time because those leases
overlap. Connections cannot outlive the invocation on Workers, so there is
nothing to warm across requests; the cost of a cold connection is the
Hyperdrive handshake plus three round trips before its prepared statements
are cached. Before changing a bundle's shape, run SERVER_TIMING = "debug"
and read the first flags per lane: if most statements are first, the
request is paying for connections, and fewer, larger sections (or a lower
section count) is the lever; if few are, the cost is the statements
themselves.
The header and log line are on by default. Set the Wrangler var
SERVER_TIMING = "0" on a target (for example in environments.production.vars)
to turn both off there. SERVER_TIMING = "debug" adds a statements array to
the log line only (never the header): one entry per statement with the first
120 characters of the SQL, its duration, its lane (read, write, or tx for
a db.transaction()), and whether it was the first statement on its
connection. Parameters are never logged, and the trace is for dev and staging:
an N+1 shape shows up as the same SQL head repeated with per-row durations. Aggregate p50 / p95 by route with wrangler tail or
Workers Logs to find the endpoints where round trips, not the statements
themselves, are the cost.
Postgres authorizes SELECT … FOR UPDATE as both a read and a write: the row
must pass its SELECT and UPDATE policies. This matters for delegated
principals because a lane that declares only databaseAccess.select emits a
FOR SELECT policy — by design, since read-only is what the author declared.
Such a principal can read an eligible row but cannot lock it with
.for("update").
Keep read-only eligibility and reference-table lookups as ordinary scoped
selects. Lock the mutable grant, capacity, or redemption row that the action is
authorized to update. If an action legitimately must lock another table, declare
a narrowly bounded databaseAccess.update rule for that principal — which makes
its one lane policy FOR ALL with the same predicate as WITH CHECK. Do not
broaden the predicate or bypass the generated principal fence.
Security is preserved, unchanged. The verified caller claims run as the
first transaction-local statement inside the transaction (the same
set_config(..., true) preamble batches use), so RLS enforces inside the
callback and the claims reset at commit/rollback — they never leak across
Hyperdrive's connection pool.
Hyperdrive query caching must be disabled in the Hyperdrive configuration: its cache keys on query text,
not the caller's RLS claims, so a cached read would leak across tenants. HTTP
response ETags provide caching at the correct post-auth layer. The compiler
emits the binding but does not inspect or change the remote caching setting;
quickback deploy does not yet verify that setting either. Requesting an
interactive db.transaction() under any other connectionMode is a fail-closed
compile error.
Capability limits (fail closed)
Anything not yet ported to Postgres is a compile-time error on Neon, never silently-broken output:
- Managed file storage with
connectionMode: 'websocket'is rejected: the websocket db index emits nocreateServiceDb, and bothcreateFilesDband the file-serving Worker wrap that handle. Managed storage is supported onhttpandhyperdrive— the buckets/objects metadata lives in afilesschema of this same database (one journal, noFILES_DBbinding). See File Storage. - The Better Auth
subscriptionsplugin is supported. Its table is created asauth.subscriptionsin the same journal as everything else and is a SYSTEM table — service-role only, like the webhook store — because entitlement state is written by the Stripe queue consumer and the admin endpoints and read by tier-role resolution, all of which run on the internal service lane. Rejected on WebSocket mode, which emits nocreateServiceDb. - Raw
sqliteTableinterop sources on a Postgres target are rejected — write child tables withpgTable(see below). - WebSocket mode (deprecated) rejects webhooks, cross-tenant
unsafeactions, and encrypted/sealed columns. UseconnectionMode: 'hyperdrive'instead — it is the full-capability interactive-transaction path and supersedes WebSocket.
Mixed database providers (e.g. D1 auth + Neon features) remain unsupported.
Table triggers are supported in both lanes here. A
sql: body is PL/pgSQL and lowers to a SECURITY INVOKER function plus its
trigger in the app schema, so a cross-table write from a trigger stays
subject to RLS and grants. Bodies are never translated between dialects — a
SQLite RAISE(ABORT, ...) on this provider is a compile error.
Deploying
Projects with no custom domain
A Worker that lives only on workers.dev (no providers.runtime.config.routes,
no domains) must tell the generated auth layer its own host. Without it the
JWT issuer defaults to the local dev URL and cookie-backed browser mutations are
refused as cross-site:
npx wrangler deploy \
--var EXTRA_APP_HOSTS:<name>.<account>.workers.dev \
--var BETTER_AUTH_URL:https://<name>.<account>.workers.devNamed environments set BETTER_AUTH_URL in worker.vars (see above);
EXTRA_APP_HOSTS is documented under
Multi-domain architecture.
For an unnamed target, the generated deploy script runs migrations, then
ships the Worker:
npm run deploy # runs db:migrate (drizzle-kit) then wrangler deployHTTP mode reads DATABASE_URL in the Worker, so set both runtime secrets before
its first deploy:
npx wrangler secret put DATABASE_URL # pooled quickback role URL
npx wrangler secret put BETTER_AUTH_SECRETThe generated HTTP client fails fast at boot if DATABASE_URL is missing.
Hyperdrive mode gets its runtime connection string from the HYPERDRIVE
binding and must not receive a Worker DATABASE_URL secret. Its
DATABASE_URL / DATABASE_MIGRATION_URL values remain local or CI migration
inputs only. Set BETTER_AUTH_SECRET and any other exact generated secret
inventory, then deploy:
npx wrangler secret put BETTER_AUTH_SECRET
npm run deployNamed Hyperdrive targets intentionally omit the bare deploy script and keep
the base Worker non-deployable. Apply the target branch's migrations, set each
secret with --env dev or --env prod, then use an explicit target:
npx wrangler secret put BETTER_AUTH_SECRET --env dev
npm run deploy:dev
npx wrangler secret put BETTER_AUTH_SECRET --env prod
npm run deploy:prodLive runtime smoke testing against a deployed Neon Worker is still being brought up; the compile/typecheck/migrate path is verified end-to-end (including executing the generated Postgres migrations under pglite), but treat a first production deploy as you would any new stack — exercise auth, a CRUD flow, and a batch write before relying on it.
Required migration canaries
quickback canary dev --required
quickback canary prod --required --applyCanary takes the logical target key and reads its branch from top-level
environments or legacy providers.database.environments. Declaring both
placements or naming an unknown target is an error. It only accepts the
neon provider: another Postgres provider cannot use Neon APIs even when
NEON_API_KEY and NEON_PROJECT_ID are set.
Set NEON_API_KEY and NEON_PROJECT_ID (or neon.projectId). --required
fails when credentials or branch mapping are missing; without it, an
unconfigured Neon canary deliberately skips. Use --required for CI gates.
Both rehearsal and parent apply request connection strings for the same
neon.database and neon.role. These select the migration database and
actor; the runtime quickback role is not a suitable routine migration actor.
The existing defaults remain neondb and neondb_owner. Set
neon.role: "quickback_admin" for routine migrations after bootstrap; first
bootstrap and security/RLS migrations still require the setup-owner capability
described above. Connection strings go to the Drizzle subprocess environment.
The command fingerprints the entire local quickback/drizzle/ inventory
before rehearsal, rechecks it after rehearsal, and checks again immediately
before parent apply. A changed journal, SQL file or other migration-state file
aborts parent apply and attempts canary cleanup. Keep migration files unchanged
throughout the command; this check does not freeze the working directory while
Drizzle runs. Cleanup failure returns a nonzero exit status even after successful
verification. --apply migrates the parent; it does not deploy the Worker.
Generated files
quickback/drizzle/
├── 0000_<name>.sql # schema migrations (drizzle-kit)
├── 0001_quickback_rls_<hash>.sql # journaled RLS layer (content-addressed)
└── meta/_journal.json # one journal covers both
src/
├── db/index.ts # createDb / createServiceDb (claim-scoped clients)
├── db/schema.ts # Drizzle schema barrel
├── db/rls-support.schema.ts # profiles (+ admins) — RLS support tables
└── auth/schema.ts # Better Auth schema (owned by the BA CLI)
.env.neon.example # migration connection strings (copy to .env)
.dev.vars.example # wrangler dev secrets (copy to .dev.vars)
docs/neon-setup.md # role bootstrap + deploy walkthroughRaw Drizzle interop tables must use pg-core
Internal child tables authored as raw Drizzle (a table file with no
export default defineTable(...)) are not dialect-translated by the
compiler — on Neon they must be written with pgTable from
drizzle-orm/pg-core. A sqliteTable interop source on a Postgres target
is a compile-time error: the mechanical sqlite→pg rewrite would leave
SQLite-shaped audit columns (text ISO strings instead of timestamptz,
which the RLS audit triggers stamp) and SQLite-only column options in the
Postgres schema.
// quickback/features/interviews/interview_scores.ts
// @quickback-internal — child table, no CRUD routes
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const interviewScores = pgTable('interview_scores', {
id: text('id').primaryKey(),
interviewId: text('interview_id').notNull(),
rating: integer('rating').notNull(),
// ── quickback:audit (compiler-managed — edits are validated, not merged) ──
createdAt: timestamp('created_at', { withTimezone: true, precision: 3 }).notNull().defaultNow(),
modifiedAt: timestamp('modified_at', { withTimezone: true, precision: 3 }).notNull().defaultNow().$onUpdate(() => new Date()),
createdBy: text('created_by'),
modifiedBy: text('modified_by'),
deletedAt: timestamp('deleted_at', { withTimezone: true, precision: 3 }),
deletedBy: text('deleted_by'),
});Interop tables are not exempt from the visible managed-column rule. The
validator runs over every table file in a feature directory
(compiler.ts:1210-1230 iterates feature.tables, which
raw-input/organize.ts:394 populates regardless of whether the file has a
resource config), so the six columns must be declared literally in Drizzle
form — ...q.audit() is a q-DSL spread and is not expanded in raw Drizzle
source. A child table with no crud config soft-deletes by default, so
deletedAt / deletedBy are required too.
Tables with defineTable(...) are unaffected — existing sqliteTable
sources with a resource config still compile to the Postgres dialect
automatically. Managed audit timestamps declared in the SQLite shape
(createdAt / modifiedAt / deletedAt as text) are emitted as
timestamptz(3), like every other Postgres table — the audit wrapper and the RLS
audit triggers both stamp timestamps, not strings. API responses carry the same
ISO-8601 string as on D1.
Managed audit timestamps are stored at millisecond precision (timestamptz(3)),
and the audit triggers stamp date_trunc('milliseconds', now()), so keyset
cursors page them exactly. Projects last compiled by 0.69.0 or earlier get a
type-change migration on the next compile that rounds existing values — see
How auto-injection works.
When to choose Neon vs D1
| Factor | Neon | D1 |
|---|---|---|
| SQL dialect | PostgreSQL | SQLite |
| Search | tsvector, language dictionaries, GIN indexes | FTS5 in custom SQL; generated Quickback ?search uses LIKE |
| Structured data | Native JSONB, arrays, rich Postgres types | JSON text with JSON path/operators; relational or JSON arrays |
| Security model | RLS + application-layer | Application-layer only |
| Transactions | HTTP batches; interactive transactions with Hyperdrive | Atomic statements/batches within D1's SQLite execution model |
| Operations | Neon HTTP is lightweight; Hyperdrive is optional for pooling/interactive transactions | No connection strings or pooling |
| Best for | Postgres-native, data-intensive, or high-concurrency applications | Cloudflare-native CRUD and conventional application workloads |
See also
Read replication
Serve reads from D1's regional replicas with the Sessions API — one config flag, one response header, sequential consistency per client.
PlanetScale Postgres
Use PlanetScale Postgres with Quickback through Cloudflare Hyperdrive — roles, connection usernames, the cache-disabled binding, and named environments.