Quickback Docs

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(),
    ...q.audit(),
    ...q.softDelete(),
  },
  // "+" 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 its random 256-bit data keys (DEKs), stored wrapped under the ENCRYPTION_KEK worker secret: the current version in wrapped_dek / key_version, and any versions it has rotated away from in retired_deks. Created lazily on the org's first encrypted write.
  • Write path — every write (CRUD routes, batch routes, and db.insert/db.update inside 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.
  • Plaintext in, from clients — CRUD, batch, and changeset request bodies reject a value that starts with qbenc: (400). Clients send plaintext; the qbenc: prefix is reserved for stored ciphertext. Action code can still write ctx.crypto.encrypt() output directly — the chokepoint passes ciphertext through untouched.
  • 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. A stored value that will not decrypt fails the request with 422 ENCRYPTION_UNREADABLE, naming the table, column, and row id — never a silent null.
  • ctx.crypto — explicit await ctx.crypto.decrypt(value, { table: 'guests', column: 'emergencyContact' }) / ctx.crypto.encrypt(value, { table, column }) for action code that processes the field server-side (building a manifest, sending an email). Every call names the column the value belongs to — the SQL table name and the column's property name — because that location is bound into the ciphertext; the generated EncryptedColumn type only admits real .encrypted() columns, and the runtime refuses any other. Scoped to the caller's active organization. ctx.crypto is only hydrated for authenticated requests — inside schedules, queue consumers, email handlers, and PUBLIC actions it is undefined. There, import the primitives directly: import { encryptValue, decryptValue } from '../lib/encryption' and call decryptValue(env, db, organizationId, stored, { table, column }). Writes through the withInternalContext handle still encrypt automatically.
  • Boot guard — a project with encrypted columns refuses to serve when ENCRYPTION_KEK is missing or malformed (503 MISSING_ENV; the log says which entry is wrong), instead of silently storing plaintext or failing on the first encrypted write.

Values at rest look like qbenc:2:<keyVersion>:<iv>:<ciphertext>. The prefix makes accidental plaintext detectable; keyVersion selects which of the org's DEKs opens the value, so values written before a rotation stay readable. The ciphertext is sealed with AES-GCM additional data binding the organization, table, and column — a stored value copied into another column, table, or org fails to decrypt and surfaces as ENCRYPTION_UNREADABLE, instead of reading back as someone else's field. To move a value between columns, decrypt it and write the plaintext. The row id is deliberately not bound: an update's .set() applies one value to every row its WHERE matches, so the id is not known where the chokepoint encrypts — swapping a value between two rows of the same column in the same org is not detected.

Values written as qbenc:1:… (before location binding) carry no additional data and keep decrypting in place; they become qbenc:2 the next time the field is written. Values that do not start with qbenc: are pre-encryption plaintext and are returned unchanged.

Each wrapped DEK records the kid of the key that wrapped it and is bound to its organization and version (AES-GCM additional data), so a wrapped key copied into another org's row, or into another version slot, fails to unwrap. Wraps written before kids existed (2:<iv>:<ct> and the older <iv>:<ct>) are still read — every configured key is tried, primary first — and are rewrapped to the current format the first time they load (on D1; on Neon the request connection has no UPDATE on org_data_keys, so they stay in the older format until rewrapOrgDataKeys runs).

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_KEK

ENCRYPTION_KEK is a comma-separated list of 64-hex-char keys. The first key wraps every new DEK; all of them can unwrap, each selected by its kid — the first 8 hex chars of the SHA-256 of the key's bytes. Validation is strict: an entry that is not exactly 64 hex characters (including an empty entry from a stray comma) refuses to boot rather than being skipped; whitespace around entries and duplicate keys are ignored.

Key rotation

Both levels rotate without downtime and without re-encrypting stored values. The generated src/lib/encryption.ts exports the two helpers; call them from code you control (a one-off schedule or an operator-only action). Each needs a database handle that can UPDATE org_data_keys: on D1 any handle works; on Neon pass the service-role handle (createServiceDb(env) — schedules already receive one), since the request connection is denied by row-level security and the helpers report that instead of pretending.

Rotating ENCRYPTION_KEK

  1. Generate a new key and prepend it: ENCRYPTION_KEK=<new>,<old>. Deploy. New DEK wraps use the new key; existing wraps still unwrap under the old one by kid.
  2. Run rewrapOrgDataKeys(env, db). It rewraps every organization's DEKs — current and retired — under the primary key, and returns { total, rewrapped, remaining }. Re-run until remaining is 0; a row that no configured key unwraps stays counted and is logged as encryption.dek_rewrap_failed.
  3. Drop the old key: ENCRYPTION_KEK=<new>. A wrap whose kid is no longer configured fails with an explicit error naming the missing kid — it never silently reads as empty.
quickback/services/schedules/rewrap-keys.ts
import { defineSchedule } from "quickback";
import { rewrapOrgDataKeys } from "../../lib/encryption";

export default defineSchedule({
  name: "rewrap-keys",
  cron: "0 3 * * *",
  execute: async ({ db, env }) => {
    console.log(JSON.stringify(await rewrapOrgDataKeys(env, db)));
  },
});

Rotating an organization's DEK

rotateOrgDataKey(env, db, organizationId) creates a fresh DEK, makes it the current version for new writes, and moves the previous version into retired_deks, where it stays readable for every value that records it. It returns { keyVersion }, and throws if the org has no key yet, if a concurrent rotation won, or if db cannot update the row. Existing values are not re-encrypted; each converges to the new version the next time its field is written. Warm Worker isolates cache an org's keys for up to five minutes, so they may write under the previous version for that long — reads of a newer version refresh immediately.

Rules 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's query.{searchable,filterable,sortable}
  • No .unique() (AES-GCM randomizes ciphertext per write), no primary keys, no .references(), no q.scope() columns, no defaults (a SQL default would bypass the encrypting chokepoint)
  • Never an embeddings.fields source (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/Hyperdrive. The deprecated Neon WebSocket mode and providers without the key-table plumbing are compile-time refusals, never silently-plaintext output. On Neon, org_data_keys lives in the app Postgres 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 wrote unreadable with the current ENCRYPTION_KEK — provided no copy of that wrapped key survives elsewhere (database backups, point-in-time restore, exports). The row holds every version of the org's DEK, so the delete shreds values written before a rotation too. Shredding is a manual, service-role delete today; there is no generated shred endpoint. After a delete, the next encrypted write for that org creates a fresh DEK, and reads of the old values fail with ENCRYPTION_UNREADABLE.

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 with ctx.crypto.decrypt(value, { table, column }) 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.

Secret columns — q.secret()

Some values are written once and only ever used by the server: a third-party client secret, a webhook URL that carries its own token. Nobody — not an admin, not the row's owner — needs to read them back. q.secret() is that column kind:

export default feature('integrations', {
  columns: {
    id:             q.id(),
    organizationId: q.scope('organization'),   // required — keys are per-org
    name:           q.text().required(),
    clientSecret:   q.secret({ maxLength: 500 }),
    ...q.audit(),
    ...q.softDelete(),
  },
  read:   { access: { roles: ['member+'] } },
  create: { access: { roles: ['admin+'] } },
  update: { access: { roles: ['admin+'] } },
});
  • Encrypted at rest with this envelope tier — same keys, chokepoint, qbenc: format, rules, and boot guard as .encrypted().
  • Never returned. The compiler gives the column a masking rule of show: 'never', so CRUD, views, realtime, and changesets redact it ([REDACTED]; null stays null, so a client can tell whether one is set) for every role, the owner included. The read path does not even decrypt it. A masking entry of your own that would reveal it — false, or any show other than 'never' — is a compile error.
  • Writable through create, update, batch, and actions like any column. Clients send plaintext.
  • Readable server-side only through ctx.unmask(row, column) inside an action, which opens it with the row's organization key and records a best-effort audit entry. ctx.crypto.decrypt refuses secret columns.
  • .sealed() and .array() on a secret are compile errors.
Stored asWho reads plaintext
.encrypted()envelope ciphertextallowed roles, through every read path (masking composes on top)
q.secret()envelope ciphertextnobody through a read path; action code through ctx.unmask
.sealed()client-sealed ciphertextonly a client holding a granted key — the server never can

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
    ...q.audit(),
    ...q.softDelete(),
  },
  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:

  1. /seal/v1/my-grant → the reader's wrapped scope private key.
  2. /seal/v1/challenge → a fresh, single-use nonce bound to this reader + item.
  3. The client signs a step-up proof over the nonce (Ed25519 from the passphrase, or a passkey assertion whose WebAuthn challenge is the nonce).
  4. /seal/v1/reveal verifies the proof, writes an append-only reveal-log entry (mirrored fail-closed into the security-audit DB as a vault_reveal event), and mints a short-lived (≤180s) scope:seal:reveal capability.
  5. /seal/v1/key (bearer that capability) passes three fail-closed gates — tenant, capability, live-grant — and returns the still-wrapped content key.
  6. 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. A PRF-capable passkey can also replace the passphrase prompt: the client wraps the passphrase-derived user master under the passkey's PRF output once (wrapMasterWithPrf), and from then on a single ceremony both proves the step-up and unlocks the keys (revealWithPasskey({ ..., prfUnwrap })). Sign-in passkeys are emitted PRF-capable at creation; native-app origins go in encryption.seal.extraOrigins. The derivation and envelope are specified in Multi-factor unlock (passkey PRF).

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 a masking rule (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, so db.select() on it won't typecheck. The server's structural inability to read it is enforced by tsc, 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/Hyperdrive — 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/v1 routes, 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. Its operating model is explicit:

  • A reveal needs a real second factor. The vault passphrase (or passkey) is separate from login and never leaves the client.
  • Recovery is opt-in and zero-knowledge. A key admin can enroll a scope key wrapped under a client-held recovery factor through /seal/v1/recovery/enroll — a recovery code, or (wrapMethod: 'prf-aesgcm') a hardware security key or shared vault passkey, one live row per method; /recovery/use returns only those opaque wrapped blobs. If no recovery is enrolled and every reader loses their factor, the data is unrecoverable by design.
  • Rotation includes re-granting. /rotate versions the scope key and returns the live-reader worklist and public keys so the key-admin client can wrap and grant the new private key without exposing it to the server.
  • 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 rollout check. The server verification path and nonce-bound WebAuthn contract are implemented; before enabling it for a production hostname, run the ceremony with that deployment's real browser, authenticator, RP ID, and origin. The passphrase lane is fully round-trip tested.

On this page