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(),
  },
  // "+" 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 the ENCRYPTION_KEK worker secret. 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.
  • 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 — explicit await 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 (503 MISSING_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_KEK

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 (the Cloudflare default). Anything else — including Neon's websocket mode — is a compile error, 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 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 with ctx.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:

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

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 (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/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. 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.

On this page