@cipherstash/stack-drizzle
v1.1.1
Published
CipherStash Stack Drizzle ORM integration: searchable, application-layer field-level encryption for PostgreSQL.
Readme
Why
Anyone with database access — a DBA, a leaked service account, a SQL injection — normally sees everything. CipherStash encrypts each value with its own key, derived at query time from the caller's identity. So a dump, an injection, or a compromised box yields ciphertext; you can only decrypt what you're explicitly authorized to, and every decryption is audited.
The trick is queries still work: we build searchable encrypted indexes using deterministic encryption, ORE, and bloom filters, so equality, range, and fuzzy-text queries run against native Postgres indexes in milliseconds without decrypting the table.
The trade-off is explicit and bounded: the indexes leak equality and order relationships, nothing else — it's not FHE, and we don't pretend it is. Security architecture →
Encrypted columns. Real Drizzle queries.
The email and age columns below are stored as ciphertext with a unique key per row — and the
queries still work, because they run on the ciphertext. No decrypt-and-scan, no query rewriting
layer, no proxy in the query path.
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'), // → eql_v3_text_search — the type is the config
age: types.IntegerOrd('age'), // → eql_v3_integer_ord
})
const rows = await db
.select()
.from(users)
.where(await ops.and(
ops.matches(users.email, 'alice'), // free-text match, on ciphertext
ops.between(users.age, 18, 65), // range, on ciphertext
))
.orderBy(ops.asc(users.age)) // ordered by encrypted valueThe operators mirror Drizzle's and encrypt their operands transparently:
| Query type | Operators | Docs |
|---|---|---|
| Equality | ops.eq, ops.ne, ops.inArray | Equality queries → |
| Range & ordering | ops.gt/gte/lt/lte, ops.between, ops.asc/desc | Range & ordering → |
| Free-text match | ops.matches | Text search → |
| Encrypted JSON | ops.contains (containment), ops.selector(col, path) | JSON → |
Each column's query capabilities are fixed by its type, so an unsupported operation is rejected loudly instead of silently scanning.
Quick start
About five minutes, starting on the free developer tier (sign up). The setup wizard handles authentication, the EQL install, and your schema:
npx stash initOr install manually (this package depends on @cipherstash/stack; install both), then run
stash eql install once — or generate a migration with stash eql migration --drizzle:
npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-ormFull guide: Drizzle quickstart →
Full example (EQL v3)
Each encrypted column is a concrete public.eql_v3_* Postgres domain whose query capabilities
are fixed by the types.* factory you choose — no per-column config object:
import { pgTable, integer } from 'drizzle-orm/pg-core'
import { Encryption } from '@cipherstash/stack/v3'
import {
types,
extractEncryptionSchema,
createEncryptionOperators,
} from '@cipherstash/stack-drizzle'
const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
email: types.TextSearch('email'), // equality + order/range + free-text
age: types.IntegerOrd('age'), // equality + order/range
})
const schema = extractEncryptionSchema(users)
const client = await Encryption({ schemas: [schema] })
const ops = createEncryptionOperators(client)
// Insert — encrypt models first (bulk helpers batch key operations
// through ZeroKMS instead of one round trip per row)
const enc = await client.bulkEncryptModels(
[{ email: '[email protected]', age: 30 }],
schema,
)
if (!enc.failure) await db.insert(users).values(enc.data)
// Query — operators auto-encrypt their plaintext operands
const rows = await db
.select()
.from(users)
.where(await ops.and(
ops.matches(users.email, 'alice'), // free-text token match over ciphertext
ops.between(users.age, 18, 65),
))
.orderBy(ops.asc(users.age))
// Decrypt after select
const dec = await client.bulkDecryptModels(rows, schema)For a types.Json column, ops.selector(column, path) supports encrypted comparisons and
ordering at a scalar JSONPath leaf. For example,
.orderBy(await ops.selector(users.profile, '$.age').asc()) lowers to
ORDER BY eql_v3.ord_term(...) over the selected encrypted entry.
Indexing encrypted columns
Encrypted predicates only use an index if one exists over the matching eql_v3.*
term-extractor expression — otherwise every encrypted query sequential-scans.
encryptedIndexes derives the recommended indexes for every encrypted column in a table;
spread it into pgTable's third-argument callback and drizzle-kit generate picks the
indexes up like any others:
import { integer, pgTable } from 'drizzle-orm/pg-core'
import { encryptedIndexes, types } from '@cipherstash/stack-drizzle'
export const users = pgTable(
'users',
{
id: integer('id').primaryKey(),
email: types.TextEq('email'),
bio: types.TextSearch('bio'),
},
(t) => [...encryptedIndexes(t)],
)Each column gets indexes matching its domain's capabilities, named
<table>_<column>_<capability> (equality btree, ordering btree, free-text GIN, JSON
containment GIN); storage-only and non-encrypted columns get none. After the migration
applies, run ANALYZE <table> — expression indexes have no statistics until then. For custom
names, subsets, or field-level selector indexes on encrypted JSON, declare individual
expression indexes instead; the bundled stash-indexing agent skill has the full recipes.
How it works
Every value is encrypted into an EQL payload: the ciphertext plus the searchable
terms its column type declares — an HMAC term for equality, an order-preserving term for
range and sorting, a bloom filter for text match, a structured-encryption vector for JSON.
The EQL SQL bundle defines the Postgres domains, operators, and term-extractor functions, so
WHERE email = $1 resolves to a comparison of equality terms and engages a functional index
over the extractor. Keys come from ZeroKMS — one per value — so bulk operations,
key revocation, and identity-bound decryption (lock contexts) work without the
database ever holding a secret. Runs on plain PostgreSQL, Supabase, and RDS/Aurora; the SQL
install needs no superuser.
Docs
- Drizzle integration guide →
- Searchable encryption concepts →
- Security architecture →
- The bundled
stash-drizzleandstash-indexingagent skills, installed into your repo bystash init
Not to be confused with
@cipherstash/drizzle, the older@cipherstash/protect-based package — deprecated and no longer maintained; this package replaces it.
