Searchable, application-level encryption for building privacy-first apps.
$ npx -y skills add cipherstash/stack --agent claude-code
Field-level encryption is the strongest way to protect data. But if DB functionality or performance suffers, you need to justify the pain. Searchable Encryption nukes the trade-off: encryption-level security without the pain.
Takes 5-10 minutes, starts on the free developer tier ([sign up][signup]), includes agent handoff.
# Run this to start (or just ask Claude to)
npx stash init
| Quick start | Guide |
|---|---|
| Supabase | [Supabase quickstart →][supabase] |
| Prisma Next | [Prisma Next quickstart →][prisma-next] |
| Drizzle ORM | [Drizzle quickstart →][drizzle] |
Raw PostgreSQL (pg) | [PostgreSQL quickstart →][encryption] |
| DynamoDB | [DynamoDB quickstart →][dynamodb] |
The Stack also ships a
stashCLI for auth, schema, and database setup. See the [SDK reference][reference].
Encrypt individual fields and still run real queries against them — all on ciphertext, in PostgreSQL:
| Query type | Operations | Docs |
|---|---|---|
| Equality | =, IN | [Equality queries →][query-equality] |
| Free-text search | fuzzy matches | [Text search →][query-match] |
| Range & ordering | <, >, BETWEEN, ORDER BY, MIN/MAX | [Range queries →][query-range] |
| Encrypted JSON | containment (@>), JSONPath selectors | [JSON queries →][query-json] |
With [EQL v3][eql], the column type is the configuration. Declare a column with the encrypted type that names its data type and the operations it supports, and it's ready to query — there's no per-column search configuration to maintain in your client:
CREATE TABLE users (
id serial PRIMARY KEY,
username text, -- plaintext — business as usual
email eql_v3_text_match, -- encrypted · free-text search
ssn eql_v3_text_eq, -- encrypted · equality
salary eql_v3_integer_ord, -- encrypted · range + ORDER BY
preferences eql_v3_json_search -- encrypted · containment + selectors
);
Encrypted types exist for text, integers, floats, numerics, dates, timestamps, booleans, and JSON, so
your schema documents itself — and encrypted data stays indexable with standard Postgres indexes. No
special index engine, no SQL rewrites. ORMs pick the types up transparently: declare the column as
encrypted in schema.prisma or your Drizzle table and the Stack handles the rest. Only raw pg needs
a client-side [schema][schema] — declared with the same type names:
import { encryptedTable, types } from "@cipherstash/stack/v3";
const users = encryptedTable("users", {
email: types.TextMatch("email"), // ↔ eql_v3_text_match
salary: types.IntegerOrd("salary"), // ↔ eql_v3_integer_ord
});
→ [Searchable encryption][searchable-encryption] · [Schema][schema] · [Encrypt & decrypt][encrypt-decrypt] · [Bulk & model operations][model-ops]
How you authenticate to ZeroKMS depends on who's asking for keys:
npx stash auth login opens your
browser and saves credentials to your local CipherStash profile. No secrets in your repo or shell.CS_*
environment variables.// Access key (default) — reads CS_* env vars, no config needed
const client = await Encryption({ schemas: [users] });
// OIDC federation — every ZeroKMS request authenticates as the end user.
// getUserJwt returns the JWT of the user behind the current request; one
// strategy serves every user. (On this native entry the client calls getJwt
// from a background task, so a request read from AsyncLocalStorage is not
// visible there yet — pass it in, or build the client per request; see
// cipherstash/stack#1065.) create() returns a Result: unwrap it and pass the
// strategy itself, not the Result envelope.
const strategy = OidcFederationStrategy.create(workspaceCrn, () => getUserJwt(req));
if (strategy.failure) throw new Error(strategy.failure.error.message);
const client = await Encryption({
schemas: [users],
config: { authStrategy: strategy.data },
});
→ [Authentication][auth]
Key management is built in, powered by [ZeroKMS][zerokms] — no AWS KMS to wire up, no key vault to run, no rotation schedule to babysit:
CipherStash never sees your plaintext — or your keys. Data is encrypted in your app with a unique key per value, and keys are derived inside your application via [ZeroKMS][zerokms] — so a database breach leaks only ciphertext. [See the security architecture →][security-architecture]
Building on OIDC federation, you can bind a record's encryption key to the end user's identity, so only
that user can decrypt their data: .withLockContext({ identityClaim }) ties the data key to a claim in
the user's JWT, enforced cryptographically by ZeroKMS.
// Bind the data key to a claim — the same claim is required to decrypt
await client
.encrypt("alice@example.com", { table: users, column: users.email })
.withLockContext({ identityClaim: ["sub"] });
→ [Identity-locking encryption][identity]
Partition your keys into keysets — independent key hierarchies within a single workspace. Give each
tenant its own keyset (config.keyset) for cryptographic tenant isolation: every encrypt, decrypt, and
query is scoped to a keyset, so revoking a client's access to a keyset makes that tenant's data
unreadable until the grant is restored — without re-architecting your app. [Keysets →][keysets]
The email column below is stored as ciphertext with a unique key per row — and the search still works,
because the query runs on the ciphertext. No decrypt-and-scan, no query rewrites.
Supabase — same Supabase.js calls; the wrapper introspects your schema, encrypts filters on the way in, and decrypts results on the way out:
const db = await encryptedSupabase(supabaseUrl, supabaseKey);
const { data } = await db.from("users")
.select("id, name, email")
.eq("email", "alice@acme.com"); // encrypted equality — runs on ciphertext
Prisma Next — declare encrypted columns in schema.prisma, query with type-safe operators:
model User {
id String @id
email cipherstash.TextSearch()
}
const rows = await db.orm.public.User
.where((u) => u.email.eqlMatch("acme.com"))
.all();
Drizzle — encrypted column types in your table, auto-encrypting operators in your queries:
export const usersTable = pgTable("users", {
id: integer("id").primaryKey(),
email: types.TextSearch("email"), // → eql_v3_text_search — the type is the config
});
const results = await db.select().from(usersTable)
.where(await ops.matches(usersTable.email, "acme.com"));
Encryption happens in your application. Ciphertext is stored as an [EQL][eql] payload in your database; plaintext and keys never reach CipherStash. Per-value keys are issued in bulk by ZeroKMS (so millions of unique keys stay fast), and every decryption is logged for compliance.
→ [Security architecture][security-architecture] · [ZeroKMS][zerokms]
Encrypted queries stay fast — latency is flat from 10k to 10M rows. Measured on EQL v3 in [cipherstash/benches][benches]:
| Operation | Median latency (up to 10M rows) |
|---|---|
| Equality lookup | ~0.1 ms |
| Range query | ~0.5 ms |
| JSON field equality | ~0.1 ms |
stack is a Claude Code plugin with 16 hand-picked skills, indexed on Flowy in the Security category. Install it with the command on its page. It includes meta-issue-creation, meta-pr-creation, stash-auth. Its skills do not fire on their own yet. Request auto-invocation to have Flowy route them as you prompt. Free and open source.
Is this plugin yours?
Claim it with GitHubSubmit a pluginPromote it