Encryption
Field-level encryption in two tiers — .encrypted() (envelope, per-org keys, server-readable) and .sealed() (end-to-end, the server structurally cannot read it). A database breach, or a full Worker compromise, yields only ciphertext.
Mark a text column .encrypted() and Quickback envelope-encrypts it at rest with a per-organization key:
export default feature('guests', {
columns: {
id: q.id(),
organizationId: q.scope('organization'), // required — keys are per-org
name: q.text().required(),
emergencyContact: q.text().encrypted(),
dietaryNotes: q.text().encrypted(),
},
// "+" role expansion requires auth.roleHierarchy in quickback.config.ts
read: { access: { roles: ['member+'] } },
create: { access: { roles: ['member+'] } },
});That one modifier changes what a database breach means: a leaked D1 backup, export, or stolen database token yields only ciphertext for those columns. The keys live separately, wrapped under a worker secret the database never sees.
What the compiler generates
org_data_keys— one row per organization holding a random 256-bit data key (DEK), stored wrapped under theENCRYPTION_KEKworker secret. Created lazily on the org's first encrypted write.- Write path — every write (CRUD routes, batch routes, and
db.insert/db.updateinside your actions) flows through the same chokepoint, which AES-256-GCM-encrypts the column before anything else sees it. Audit snapshots, webhook payloads, and realtime deltas therefore carry ciphertext by construction. - Read path — generated routes decrypt behind the firewall and then apply masking, so authorized callers see plaintext and everyone else sees the redaction — never ciphertext.
ctx.crypto— explicitawait ctx.crypto.decrypt(value)/ctx.crypto.encrypt(value)for action code that processes the field server-side (building a manifest, sending an email). Scoped to the caller's active organization.- Boot guard — a project with encrypted columns refuses to serve without
ENCRYPTION_KEK(503MISSING_ENV), instead of silently storing plaintext.
Values at rest look like qbenc:1:<keyVersion>:<iv>:<ciphertext> — the prefix makes accidental plaintext detectable, and the embedded key version supports rotation with lazy re-encryption on write.
Setup
Scaffolded projects already have a generated ENCRYPTION_KEK in .dev.vars, so local dev works with zero setup. For production:
openssl rand -hex 32 | wrangler secret put ENCRYPTION_KEKRules the compiler enforces
Ciphertext cannot be queried, so encrypted columns are excluded from every query surface at compile time — these are errors, not warnings:
- No
.filterable()/.searchable(), and never listed in a view'squery.{searchable,filterable,sortable} - No
.unique()(AES-GCM randomizes ciphertext per write), no primary keys, no.references(), noq.scope()columns, no defaults (a SQL default would bypass the encrypting chokepoint) - Never an
embeddings.fieldssource (embedding a value ships its plaintext to the model) - The table must carry a
q.scope('organization')column — keys are per-organization - Text-shaped columns only (v1)
- Database provider must be Cloudflare D1 or Neon over HTTP (the Cloudflare default). Anything else — including Neon's websocket mode — is a compile error, never silently-plaintext output. On Neon,
org_data_keyslives in theappPostgres schema with org-scoped row-level security: a request-scoped connection can read and lazily create exactly its own organization's wrapped-key row; rotation and crypto-shredding are service-role operations.
Encrypted columns may appear in view projections and responses — they decrypt through the generated prepare path. Masking composes on top as defense-in-depth.
Per-organization keys: blast radius and crypto-shredding
Each organization's data is encrypted under its own DEK, so one compromised key never exposes another tenant. Deleting an org's org_data_keys row makes every encrypted value it ever wrote permanently unreadable — the cleanest possible GDPR-erasure and offboarding story.
What this tier does and doesn't claim
The envelope tier protects against storage compromise: backups, exports, dashboard access, leaked database tokens, SQL injection, and insiders with database read access all get ciphertext. The server can decrypt — that's the point; your actions still process the field. It does not protect against full Worker compromise — for that, use the sealed tier below, where the server structurally cannot read the value at all.
Two practical notes:
- Action responses: rows returned from
db.insert(...).returning()inside your own actions contain ciphertext — decrypt explicitly withctx.crypto.decrypt()if your action returns the field. Generated CRUD routes handle this automatically. - No server-side equality lookups on encrypted values (blind indexes are a roadmap item). If you need to filter by it, it probably shouldn't be encrypted — or it needs a separate non-sensitive derived column.
Sealed tier — end-to-end encryption
Mark a text column .sealed() instead of .encrypted() and you cross a line the envelope tier never claims: the server cannot read the value. It holds only ciphertext plus a content key wrapped to a public key it has no private half for. A full Worker compromise — leaked secrets, malicious code, a coerced admin — still yields nothing but ciphertext. Decryption happens only on the client, after a fresh step-up.
export default feature('candidates', {
columns: {
id: q.id(),
organizationId: q.scope('organization'), // required — keys are per-scope
name: q.text().required(),
governmentId: q.text().encrypted(), // envelope tier: server CAN read (to mask)
backgroundCheck: q.text({ maxLength: 8000 }).sealed(), // sealed tier: server CANNOT read
},
read: { access: { roles: ['member+'] } },
create: { access: { roles: ['admin+'] } },
});Use it for data that is legally sensitive and need-to-know, where you want a database breach and the server itself to yield only ciphertext: background checks, health findings, raw identity documents, anything you'd rather not be able to read by accident.
The page below is the usage view. For the on-the-wire contract — the versioned qbseal:1 envelope format, the full /seal/v1 lifecycle, the Argon2id floor, passkey expectations, canonical conformance vectors, and the first-party Swift / Kotlin / TypeScript SDKs (with install + usage) — see Sealed protocol.
The key model
Three nested keys. A breach of any layer above plaintext yields only more wrapped keys:
USER key ──unwrap──▶ SCOPE private key ──unwrap──▶ content key ──decrypt──▶ plaintext- USER key — derived on the client from a vault passphrase (Argon2id → an Ed25519 signing key + an X25519 wrap key). This is never the Better Auth login password — reusing the login password would collapse the zero-knowledge property back to server-trust. The server stores only the Ed25519 public key (to verify step-ups) and the Argon2id params.
- SCOPE keypair — one per scope (per organization). The content key is wrapped to the scope's public key, so any authorized reader can be granted access without re-encrypting the data.
- Content key — a random AES-256-GCM key, one per sealed value, wrapped to the scope.
A writer needs only the scope's public key — no key of their own. Only readers (staff doing a reveal) need a grant of the scope private key, wrapped to their USER key.
The client ships with your backend
For any project with a .sealed() column the compiler emits the E2EE client into your project — there's nothing to npm install:
src/
├─ lib/
│ ├─ seal-routes.ts # server: the /seal/v1/* reveal rails (verify-only)
│ └─ seal-client/ # client: the browser SDK you import — emitted for you
│ └─ index.ts sdk.ts flow.ts wrap.ts kdf.ts envelope.ts ...@noble/curves and @noble/hashes are added to your package.json automatically. The client is emitted from the same compiler version as the server routes, so the wire format (qbseal: envelopes, the step-up message, the key-wrap method) can never drift between the two — a class of silent decrypt-time bug that a separately-versioned package would reintroduce.
Writing a sealed value
The writer seals the plaintext to the scope's public key, then writes the result like any other field. The server stores ciphertext and is none the wiser.
import { SealClient } from './lib/seal-client';
const seal = new SealClient(transport); // transport = POST to /seal/v1/*
// Fetches the scope's PUBLIC key and seals to it — the writer needs no key.
const sealed = await seal.sealField(
'scope:organization:org_123',
new TextEncoder().encode('Background check: clear'),
);
// Write it like any field. At rest this is a `qbseal:` sealed-cell envelope.
await api.post('/api/v1/candidates', { name: 'Ada', backgroundCheck: sealed });Revealing a sealed value
A reveal is a deliberate, audited, step-up-gated action — not an ambient read. The SDK runs the whole handshake; you provide the reader's passphrase-derived session:
// Derive the reader's keys once from their vault passphrase (NOT their login password).
const session = seal.session(passphrase, enrollment);
const plaintext = await seal.reveal({
session,
scopeRef: 'scope:organization:org_123',
vaultItemId: candidate.id,
});
new TextDecoder().decode(plaintext); // "Background check: clear"Behind that one call:
/seal/v1/my-grant→ the reader's wrapped scope private key./seal/v1/challenge→ a fresh, single-use nonce bound to this reader + item.- The client signs a step-up proof over the nonce (Ed25519 from the passphrase, or a passkey assertion whose WebAuthn challenge is the nonce).
/seal/v1/revealverifies the proof, writes an append-only reveal-log entry, and mints a short-lived (≤180s)scope:seal:revealcapability./seal/v1/key(bearer that capability) passes three fail-closed gates — tenant, capability, live-grant — and returns the still-wrapped content key.- The client unwraps the scope key, unwraps the content key, and decrypts. None of this touches the server with a readable key.
Passkey reveal is the same call as revealWithPasskey({ ..., getAssertion }), where getAssertion(nonce) runs the WebAuthn ceremony.
Rules the compiler enforces
Sealed values are opaque references at rest, so they're fenced off from every surface that would need to read them — these are compile errors:
- Never in a view projection,
?search, or amaskingrule (there's no plaintext to project, search, or redact — it leaves the system only through the reveal rails). - No
.filterable()/.searchable()/.unique()/ defaults /embeddings.fields(same reasons as the envelope tier). - The table must carry a
q.scope('organization')column — keys are per-scope. - Reading a sealed column server-side is a type error: the column is branded
$type<SealedValue>(), an opaque non-string, sodb.select()on it won't typecheck. The server's structural inability to read it is enforced bytsc, not convention. - Sealed projects force-enable KV-backed JWT revocation (so a revoked reader's in-flight reveal token dies); a project with sealed columns and no KV binding fails to compile.
- Database provider must be Cloudflare D1 or Neon over HTTP (the Cloudflare default) — anything else fails to compile. On Neon, the seal/vault tables carry deny-all row-level security for request connections: only the worker-internal service-role lane (the generated
/seal/v1routes,ctx.seal, and the sealed write chokepoint) can touch them, and every one of those queries is still explicitly organization-scoped.
What this tier does and doesn't claim
The sealed tier protects against full Worker compromise — leaked secrets, malicious or buggy server code, a coerced operator. All of them get ciphertext; the keys to read it never exist server-side. What it asks in return:
- A reveal needs a real second factor. The vault passphrase (or passkey) is separate from login and never leaves the client. Lose it with no recovery grant and the data is unrecoverable by design — that's the guarantee, not a bug. Recovery/escrow and rotation re-granting are roadmap.
- No server-side processing. Your actions can't read, mask, search, or index a sealed value — it's plaintext only in a client that just revealed it. If the server needs to process the field, you want the envelope tier instead.
- Passkey reveal verify logic is in place but should be exercised with a real browser/authenticator before production; the passphrase lane is fully round-trip tested.
Rate Limit - Per-Operation Request Caps
Per-resource, per-operation rate limiting backed by Cloudflare's native Rate Limiting binding. Defaults apply to every resource automatically; override or opt out per-resource or project-wide.
Sealed protocol (qbseal:1)
The versioned wire format + /seal/v1 lifecycle contract for the SEALED tier (E2EE). Build a conforming client in any language against a published spec and canonical conformance vectors — not by reverse-engineering emitted code.