| name | stash-drizzle |
| description | Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle/v3 (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. |
CipherStash Stack - Drizzle ORM Integration
Guide for integrating CipherStash field-level encryption with Drizzle ORM using @cipherstash/stack-drizzle/v3 (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query.
In EQL v3 every encrypted column is a concrete Postgres domain (public.eql_v3_text_search, public.eql_v3_integer_ord, ...) whose query capabilities are fixed by the type you pick — there is no capability config object. See the stash-encryption skill's "Schema Definition" section (the types catalog) for the full catalog and capability suffixes (Eq, Ord/OrdOre, Match, Search, Json).
When to Use This Skill
- Adding field-level encryption to a Drizzle ORM project
- Defining encrypted columns in Drizzle table schemas with the v3
types.* factories
- Querying encrypted data with type-safe, auto-encrypting operators
- Sorting, filtering, and encrypted-JSONB querying on encrypted columns
- Migrating an existing plaintext column to encrypted
- Building Express/Hono/Next.js APIs with encrypted Drizzle queries
Installation
npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm
Version note: npx stash init is the preferred install path — it pins
every @cipherstash/* package to the versions matching your CLI release.
If you install manually as above, verify what actually resolved
(node -p "require('@cipherstash/stack/package.json').version"): bare
dist-tag installs can lag behind a release, and stash init will warn on
the version skew.
The Drizzle integration ships as its own first-party package,
@cipherstash/stack-drizzle, which depends on @cipherstash/stack. Install both.
The v3 surface documented here lives on the @cipherstash/stack-drizzle/v3 subpath.
It is distinct from the older, separate @cipherstash/drizzle package (which is
@cipherstash/protect-based, with different symbol names) — that package is
deprecated and no longer published; do not install it. This package replaces it.
Database Setup
Runner note. stash init adds stash to the project as a dev dependency, so stash <command> runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples in this skill show this bare form. Before init has run, prefix with your package manager's one-shot runner: bunx, pnpm dlx, yarn dlx, or npx. The CLI's behaviour is identical across all of them.
Install the EQL v3 SQL
EQL (Encrypt Query Language) provides the PostgreSQL functions and domains that make encrypted columns searchable. Install version 3 directly against the database:
stash eql install --eql-version 3
v3 installs via the direct path only — the v2 stash eql install --drizzle Drizzle-migration flow is not supported for v3 (--drizzle, --migration, --migrations-dir, and --latest are v2-only flags). EQL v3 ships one SQL bundle for every target, including Supabase.
Column Storage
Each encrypted column is a concrete Postgres domain named public.eql_v3_<name>:
CREATE TABLE users (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
email public.eql_v3_text_search,
age public.eql_v3_integer_ord,
profile public.eql_v3_json,
role VARCHAR(50)
);
You don't usually hand-write this: the types.* factories below emit the domain as the column's SQL type, so drizzle-kit generate produces the ADD COLUMN email public.eql_v3_text_search DDL for you.
Schema Definition
Use the types namespace from @cipherstash/stack-drizzle/v3 to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type:
import { pgTable, integer, timestamp, varchar } from "drizzle-orm/pg-core"
import { types } from "@cipherstash/stack-drizzle/v3"
const usersTable = pgTable("users", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch("email"),
age: types.IntegerOrd("age"),
notes: types.Text("notes"),
profile: types.Json("profile"),
role: varchar("role", { length: 50 }),
createdAt: timestamp("created_at").defaultNow(),
})
Capability suffixes at a glance (full catalog: stash-encryption skill, "Schema Definition" — the types namespace):
| Factory shape | Domain | Enables |
|---|
types.Text, types.Integer, ... (no suffix) | eql_v3_text, ... | Storage only |
types.TextEq, types.IntegerEq, ... | eql_v3_text_eq, ... | eq, ne, inArray, notInArray |
types.IntegerOrd, types.TimestampOrd, ... | eql_v3_integer_ord, ... | equality + gt/gte/lt/lte/between/asc/desc |
types.IntegerOrdOre, ... | eql_v3_integer_ord_ore, ... | as Ord, with block-ORE ordering (superuser-only install — see Sorting) |
types.TextMatch | eql_v3_text_match | matches (fuzzy free-text) only |
types.TextSearch | eql_v3_text_search | equality + order/range + matches |
types.Json | eql_v3_json | contains + selector (encrypted JSONB) |
Value families: Integer/Smallint/Numeric/Real/Double (number), Bigint (bigint), Date/Timestamp (Date), Text (string), Boolean (boolean, storage only), Json (a JSON document — object or array, not a top-level scalar).
makeEqlV3Column(builder) wraps a column builder from @cipherstash/stack/eql/v3 (e.g. makeEqlV3Column(v3types.TextEq("email"))) — types.TextEq("email") from the Drizzle subpath is shorthand for the same thing.
Initialization
1. Extract Schema from Drizzle Table
import { extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from "@cipherstash/stack-drizzle/v3"
import { EncryptionV3 } from "@cipherstash/stack/v3"
const usersSchema = extractEncryptionSchemaV3(usersTable)
2. Initialize the Encryption Client
const encryptionClient = await EncryptionV3({
schemas: [usersSchema],
})
EncryptionV3 returns a strongly-typed client: plaintext types are pinned to each column's domain, and query methods only accept queryable columns.
3. Create Query Operators
const ops = createEncryptionOperatorsV3(encryptionClient)
createEncryptionOperatorsV3(client, { lockContext, audit }) optionally sets defaults applied to every operand encryption; the async encrypting operators (eq, ne, inArray, notInArray, gt/gte/lt/lte, between/notBetween, matches, contains, and the comparison methods returned by selector(...)) also take an optional trailing { lockContext, audit } argument per call. asc/desc and the passthrough operators (isNull, isNotNull, not, and, or, exists, notExists) encrypt nothing and take no such argument.
4. Create Drizzle Instance
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
const db = drizzle({ client: postgres(process.env.DATABASE_URL!) })
Insert Encrypted Data
Rows are pre-encrypted with the client before db.insert — Drizzle only ever handles the encrypted EQL envelope:
const encrypted = await encryptionClient.encryptModel(
{ email: "alice@example.com", age: 30, role: "admin" },
usersSchema,
)
if (!encrypted.failure) {
await db.insert(usersTable).values(encrypted.data)
}
const encrypted = await encryptionClient.bulkEncryptModels(
[
{ email: "alice@example.com", age: 30, role: "admin" },
{ email: "bob@example.com", age: 25, role: "user" },
],
usersSchema,
)
if (!encrypted.failure) {
await db.insert(usersTable).values(encrypted.data)
}
Query Encrypted Data
Operators auto-encrypt their plaintext operands into EQL v3 query terms — you pass plaintext, the emitted SQL compares encrypted values. Comparison operators are async (they encrypt), so await them (or hand them lazily to ops.and/ops.or, below).
Equality
const results = await db
.select()
.from(usersTable)
.where(await ops.eq(usersTable.email, "alice@example.com"))
Free-Text Search (matches)
matches(col, needle) is fuzzy bloom-token matching on a TextMatch/TextSearch column — not SQL pattern matching. There are no like/ilike operators on the v3 surface, by design; don't pass % wildcards.
const results = await db
.select()
.from(usersTable)
.where(await ops.matches(usersTable.email, "alice"))
Semantics to know:
- Fuzzy and one-sided. The needle's downcased token set is bloom-tested as a subset of the column's — order- and multiplicity-insensitive. A match may be a false positive; a non-match never is. Re-check candidates after decryption if you need exactness.
- Case-insensitive, and matches substrings of 3 characters or more.
- Short needles are rejected. A needle shorter than the tokenizer's token length (3 by default) produces no tokens and would silently match every row, so the operator throws
EncryptionOperatorError instead.
Range Queries
const results = await db
.select()
.from(usersTable)
.where(await ops.gte(usersTable.age, 18))
const results = await db
.select()
.from(usersTable)
.where(await ops.between(usersTable.age, 18, 65))
Array Membership
const results = await db
.select()
.from(usersTable)
.where(await ops.inArray(usersTable.email, [
"alice@example.com",
"bob@example.com",
]))
inArray/notInArray reject an empty list and encrypt the whole list in a single batch crossing.
Sorting
const results = await db
.select()
.from(usersTable)
.orderBy(ops.asc(usersTable.age))
const results = await db
.select()
.from(usersTable)
.orderBy(ops.desc(usersTable.age))
ops.asc/ops.desc emit ORDER BY eql_v3.ord_term(col) (ord_term_ore(col) for the *OrdOre domains). The ORE-flavoured domains require a superuser install and are unavailable on managed Postgres (Supabase, RDS, etc.) — prefer the plain Ord domains there; ordering works everywhere EQL v3 installs.
Encrypted-JSONB Containment (contains)
contains(col, subDoc) on a types.Json column is exact encrypted containment (jsonb @> semantics, no false positives). The needle is a ciphertext-free query_jsonb term. Array containment is position-independent — { roles: ["admin"] } matches any document whose roles array includes "admin":
const results = await db
.select()
.from(usersTable)
.where(await ops.contains(usersTable.profile, { roles: ["admin"] }))
An empty-object needle ({}) is rejected — doc @> '{}' holds for every document, so it would silently match every row. Omit the predicate if you want all rows.
types.Json carries no equality or ordering: eq/gt/asc on a Json column throw.
JSONPath Selector-with-Constraint (selector)
ops.selector(col, path) returns comparison methods bound to the encrypted value at a JSONPath inside a types.Json column. Its unique power over contains is ordering at a path:
const results = await db
.select()
.from(usersTable)
.where(await ops.selector(usersTable.profile, "$.age").gt(25))
const results = await db
.select()
.from(usersTable)
.where(await ops.selector(usersTable.profile, "$.user").eq("zoe@example.com"))
Available methods: .eq, .ne, .gt, .gte, .lt, .lte. Rules:
- Paths are dot-notation object keys only (
"$.a.b"). Array-index and wildcard syntax ($.items[0]) is rejected.
- Leaves are scalars only:
string, number, boolean, Date, or bigint. An object or array leaf is rejected — use contains for sub-object matching. A boolean leaf is rejected under the ordering methods (booleans have no ordering).
- A scalar needle does not match an array at the path.
selector(col, "$.tags").eq("a") will not match { tags: ["a"] } — use contains(col, { tags: ["a"] }) for that.
- Absent-path semantics:
eq and the ordering methods exclude rows whose document lacks the path; ne includes them ("not equal to X" covers "has no X").
- Interim ciphertext disclosure (cipherstash/protectjs-ffi#137): the selector's right-hand value is currently a storage-encrypted needle, so its ciphertext appears in the WHERE clause (and therefore in Postgres logs that capture query text). The comparison itself only reads the needle's index terms. Once #137 lands, the needle becomes ciphertext-free like every other operand.
Batched Conditions (and / or)
Use ops.and() and ops.or() to combine encrypted conditions. Pass the operators lazily (no await) so they resolve concurrently, then await the outer call:
const results = await db
.select()
.from(usersTable)
.where(
await ops.and(
ops.gte(usersTable.age, 18),
ops.lte(usersTable.age, 65),
ops.matches(usersTable.email, "example"),
eq(usersTable.role, "admin"),
),
)
const results = await db
.select()
.from(usersTable)
.where(
await ops.or(
ops.eq(usersTable.email, "alice@example.com"),
ops.eq(usersTable.email, "bob@example.com"),
),
)
Both accept undefined conditions, which are filtered out — useful for conditional query building:
await ops.and(
maybeEmail ? ops.eq(usersTable.email, maybeEmail) : undefined,
ops.gte(usersTable.age, 18),
)
NULLs and Non-Encrypted Columns
- A
null operand throws — use ops.isNull(col) / ops.isNotNull(col) for NULL checks.
- No plaintext-column fallback. Every v3 operator requires an encrypted v3 column and throws
EncryptionOperatorError otherwise. Use regular Drizzle operators (eq, gte, ...) for non-encrypted columns — mixing the two inside ops.and/ops.or is fine.
Decrypt Results
Selected rows hold encrypted envelopes; decrypt with the client. The v3 decryptModel/bulkDecryptModels take the schema table as the second argument:
const decrypted = await encryptionClient.decryptModel(results[0], usersSchema)
if (!decrypted.failure) {
console.log(decrypted.data.email)
}
const decrypted = await encryptionClient.bulkDecryptModels(results, usersSchema)
if (!decrypted.failure) {
for (const user of decrypted.data) {
console.log(user.email)
}
}
Date columns are reconstructed to real Date instances on decrypt; bigint columns round-trip as native bigint. Non-schema fields pass through unchanged.
Migrating an Existing Column to Encrypted
The hard case: a Drizzle table that already exists in production with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data and break NOT NULL constraints.
CipherStash splits this into two named steps with a hard production-deploy gate between them: an encryption rollout (schema-add + dual-write code) and an encryption cutover (backfill + rename + drop). (If using CipherStash Proxy, the rollout also includes stash db push to register the encryption config with EQL.) The stash-encryption skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape.
⚠️ v3 backfill tooling status. The CLI backfill/cutover tooling (stash encrypt backfill, stash encrypt cutover, and the underlying @cipherstash/migrate) currently targets EQL v2 columns. v3 compatibility is tracked in cipherstash/stack#648. The lifecycle below (schema-add → dual-write → deploy gate → backfill → cutover → drop) is the correct shape for v3 either way — until #648 lands, run the backfill/rename steps with your own scripts (encrypt with bulkEncryptModels, write in chunks) instead of the stash encrypt commands.
Where am I? Run stash status first (substitute the runner per the note above). It shows you which Drizzle tables/columns are mid-rollout, which are post-deploy, and what the next move is. Re-run after every transition.
Starting state
You have:
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: text('email').notNull(),
})
And an INSERT INTO users (email) VALUES (...) somewhere in your app code.
Step 1 — Encryption rollout (one PR, one deploy)
Everything below lands in one PR. The deploy of that PR is the gate.
Schema-add: declare the encrypted twin
Add an email_encrypted column alongside email. Crucially, the encrypted column is nullable at creation — never .notNull(), because rows that already exist will have NULL in this column until backfill catches them.
import { types } from '@cipherstash/stack-drizzle/v3'
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: text('email').notNull(),
email_encrypted: types.TextSearch('email_encrypted'),
})
Update the encryption client to harvest the encrypted columns from the table:
import { EncryptionV3 } from '@cipherstash/stack/v3'
import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3'
import { users } from '../db/schema'
const usersEncryptionSchema = extractEncryptionSchemaV3(users)
export const encryptionClient = await EncryptionV3({ schemas: [usersEncryptionSchema] })
Generate the migration with drizzle-kit generate. The generated SQL should be a single ALTER TABLE ... ADD COLUMN email_encrypted public.eql_v3_text_search;. Apply with drizzle-kit migrate. (This requires the EQL v3 SQL to be installed first — see Database Setup.)
Using CipherStash Proxy?
If your app queries encrypted data through CipherStash Proxy, register the new encryption config with EQL:
stash db push
If this is the project's first encrypted column, db push writes directly to the active EQL config (nothing to rename). If an active config already exists, db push writes the new config as pending — that's expected. The pending row will be promoted to active by stash encrypt cutover in the cutover step.
SDK-only users can skip this step.
Dual-writing: write to both columns from app code
Find every code path that writes to users.email and update it to encrypt and also write to email_encrypted:
await db.insert(users).values({ email: input.email })
const encrypted = await encryptionClient.encryptModel({ email_encrypted: input.email }, usersEncryptionSchema)
if (encrypted.failure) throw new Error(encrypted.failure.message)
await db.insert(users).values({
email: input.email,
email_encrypted: encrypted.data.email_encrypted,
})
Same shape for UPDATE: if your app updates email, it must also re-encrypt and update email_encrypted in the same statement.
The dual-write rule. Every persistence path that mutates this row writes both columns, in the same transaction, on every code branch. Insert sites, update sites, upserts, ON CONFLICT clauses, seeders, fixtures, CSV importers, admin actions, background jobs, third-party webhook handlers — all of them. A single missed branch means rows inserted in production after deploy land in plaintext only, and backfill won't catch them. Grep for every site that touches users.email before declaring this step done.
After this phase, existing rows still have email_encrypted = NULL. App reads still come from email. Nothing has broken.
⛔ Deploy gate
Stop. Ship this PR to production. The deployed environment must be running the dual-write code before any cutover-step work is safe.
When the deploy is live:
stash status
stash plan
stash impl will refuse to run a cutover-step plan if cs_migrations has no dual_writing event for users.email. That refusal is the safety net for cases where someone runs cutover work locally before the code is actually live.
Step 2 — Encryption cutover
Once dual-writes are live in production and cs_migrations records dual_writing:
Backfill: encrypt the historical rows
Until #648 lands, the commands in this step target v2 columns — for v3 columns, replicate the same shape with a script (chunked bulkEncryptModels + UPDATE inside transactions, resumable and idempotent).
stash encrypt backfill --table users --column email
Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination order, encrypts each chunk via the encryption client, and writes the ciphertext into email_encrypted inside transactions that also checkpoint to cs_migrations. SIGINT-safe.
If something goes wrong (e.g. you discover the dual-write code wasn't actually live when backfill ran), re-run with --force to re-encrypt every row regardless of current state.
SDK-only note: stash encrypt cutover currently requires a pending EQL configuration set by stash db push. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. Workaround: run stash db push once before stash encrypt cutover.
Cutover: rename swap and activate
First, update the Drizzle schema to the post-cutover shape — switch email to the encrypted type and remove the email_encrypted column.
Using CipherStash Proxy?
If using Proxy, re-push the encryption config so EQL has a pending row that points at email (no _encrypted suffix):
stash db push
Now run the cutover:
stash encrypt cutover --table users --column email
Inside one transaction it: (1) renames email → email_plaintext and email_encrypted → email, (2) promotes the pending EQL config to active (and the prior active to inactive), (3) records a cut_over event in cs_migrations.
The Drizzle schema you just edited now matches the physical DB shape — email is the encrypted column. Keep the temporary email_plaintext: text('email_plaintext') declaration in the schema file until the drop step:
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'),
email_plaintext: text('email_plaintext'),
})
App code that does SELECT email FROM users now returns ciphertext that must be decrypted via the encryption client. This is the moment that breaks read paths if they aren't decrypting.
Update read paths to decrypt:
const rows = await db.select().from(users).where(eq(users.id, id))
const email = rows[0].email
const rows = await db.select().from(users).where(eq(users.id, id))
const decrypted = await encryptionClient.decryptModel(rows[0], usersEncryptionSchema)
if (decrypted.failure) throw new Error(decrypted.failure.message)
const email = decrypted.data.email
For queries that filter on email, switch to the encrypted operators from createEncryptionOperatorsV3 — eq, matches, gte, etc. (See ## Query Encrypted Data above.)
Drop: remove the plaintext column
Once read paths are updated and you're confident reads are decrypting correctly, generate the drop migration:
stash encrypt drop --table users --column email
The CLI emits a Drizzle migration file with ALTER TABLE users DROP COLUMN email_plaintext;. Review and apply with drizzle-kit migrate. Update the schema to remove email_plaintext:
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'),
})
Also remove the dual-write code from app paths — email_plaintext is gone; only email (encrypted) is written now.
Inspecting progress at any time
stash status
stash encrypt status
stash encrypt plan
All three are read-only.
Complete Operator Reference
All comparison/containment operators auto-encrypt their operands and are async; asc/desc and the passthroughs are sync.
Encrypted Operators (async)
| Operator | Usage | Required column capability (domain suffix) |
|---|
eq(col, value) | Equality | equality (Eq, Ord, OrdOre, TextSearch) |
ne(col, value) | Not equal | equality |
gt / gte / lt / lte (col, value) | Comparison | order/range (Ord, OrdOre, TextSearch) |
between(col, min, max) | Inclusive range | order/range |
notBetween(col, min, max) | Negated range | order/range |
inArray(col, values) / notInArray(col, values) | Membership (single-batch encryption; empty list rejected) | equality |
matches(col, needle) | Fuzzy free-text token match (short needles rejected) | free-text (TextMatch, TextSearch) |
contains(col, subDoc) | Exact encrypted-JSONB containment ({} rejected) | Json |
selector(col, path).eq/ne/gt/gte/lt/lte(value) | JSONPath selector-with-constraint (dot-notation paths, scalar leaves) | Json |
Sort Operators (sync)
| Operator | Usage | Required capability |
|---|
asc(col) | ORDER BY eql_v3.ord_term(col) ascending | order/range |
desc(col) | ORDER BY eql_v3.ord_term(col) descending | order/range |
(ord_term_ore for *OrdOre domains — superuser-only, unavailable on managed Postgres.)
Logical Operators (async, concurrent)
| Operator | Description |
|---|
and(...conditions) | Conjunction — accepts lazy (un-awaited) operators and undefined, resolves concurrently |
or(...conditions) | Disjunction — same |
Passthrough Operators (sync, no encryption)
isNull, isNotNull, not, exists, notExists — re-exported from Drizzle and work identically.
Other v3 Exports
types, makeEqlV3Column, getEqlV3Column, isEqlV3Column, extractEncryptionSchemaV3, createEncryptionOperatorsV3, EncryptionOperatorError, and the codec helpers v3ToDriver / v3FromDriver / EqlV3CodecError — all from @cipherstash/stack-drizzle/v3.
Error Handling
Operators throw EncryptionOperatorError (exported from @cipherstash/stack-drizzle/v3) whenever the query cannot be answered safely:
- the column is not an encrypted v3 column (there is no plaintext fallback);
- the column's domain lacks the operator's capability (e.g. ordering a
TextEq column, eq on a Json column);
- the operand is
null (use isNull/isNotNull), an empty list (inArray), an empty object (contains), or a too-short needle (matches);
- a
selector path is malformed / uses array syntax, or its leaf value is a non-scalar;
- operand encryption itself fails.
import { EncryptionOperatorError } from "@cipherstash/stack-drizzle/v3"
class EncryptionOperatorError extends Error {
context?: {
tableName?: string
columnName?: string
operator?: string
}
}
There is no EncryptionConfigError on the v3 path — capability problems surface as EncryptionOperatorError with the offending column/table/operator in context.
Encryption client operations (encryptModel, bulkDecryptModels, ...) don't throw — they return Result objects with data or failure. Check .failure before using .data.
Legacy: EQL v2
The original v2 integration — encryptedType config-flag columns, extractEncryptionSchema, and createEncryptionOperators (with like/ilike) from the @cipherstash/stack-drizzle package root, over the eql_v2_encrypted column type installed via stash eql install --drizzle (the deprecated standalone @cipherstash/drizzle package shipped its own generate-eql-migration bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the /v3 surface documented above. Note: stash init --drizzle currently pins EQL v2 because the v2 Drizzle-migration install path has no v3 equivalent yet.