Quickback Docs

Encryption

Field-level envelope encryption with per-organization keys. Mark a column .encrypted() and a database breach 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(),
  },
  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)

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 (the sealed/E2EE tier in the encryption roadmap addresses that — see apps/compiler/research/encryption-vault-spec.md).

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.

On this page