CipherStashDocs
Get started

Quickstart

Encrypt, store, query, and decrypt your first field in Postgres, using the stash CLI and @cipherstash/stack.

CipherStash encrypts your data at the field level. Every value gets its own encryption key, and the database never sees plaintext. A breach, a compromised agent, or a curious insider sees ciphertext.

By the end of this page you will have encrypted a field, stored it in Postgres, queried it without decrypting it, and read it back. It takes about ten minutes and works with any Postgres: Supabase, Neon, RDS, or a Docker container.

Before you start

  • Node.js 22 or later.
  • A Postgres database you can connect to.
  • A CipherStash account. The free plan is enough.

Initialize your project

npx stash init

This opens a browser for device-based authentication, so local development needs no environment variables and no shared secrets. init then:

  1. Connects to your workspace.
  2. Resolves your database connection and runs stash eql install, which installs EQL v3, the Postgres surface that makes encrypted columns queryable.
  3. Installs @cipherstash/stack if it isn't already present.
  4. Scaffolds an encryption module at src/encryption/index.ts.
  5. Writes .cipherstash/context.json with what it detected about your project.

Integration-specific initialization flags include --supabase, --drizzle, and --prisma.

EQL v3 is the default, so init installs it directly against the database. Confirm what you ended up with:

npx stash eql status

This guide, and the rest of the EQL reference, targets EQL v3.

Define what to encrypt

init scaffolds src/encryption/index.ts. It declares which columns are encrypted, and exports a ready-to-use client. Edit it to match your table:

import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack"

export const users = encryptedTable("users", {
  email: encryptedColumn("email").equality(),
})

export const encryptionClient = await Encryption({ schemas: [users] })

.equality() declares a capability: it is what makes WHERE email = ? work later. Add .orderAndRange() for ORDER BY and comparisons, or .freeTextSearch() for token containment. Declare only the capabilities you query on, because each one stores an extra index term alongside the ciphertext.

Prefer to have an agent do this? npx stash plan inspects your project and drafts .cipherstash/plan.md listing the columns worth encrypting. Review it, then npx stash impl applies it. This page does it by hand so you can see each moving part.

Create the encrypted column

An encrypted column is typed with an EQL domain. The domain you pick has to match the capability you declared: .equality() on a text column means public.eql_v3_text_eq.

CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email public.eql_v3_text_eq
);

The type is what fixes the column's searchable surface. There is no separate configuration table. See core concepts for the full variant model.

Encrypt a value and store it

import { encryptionClient, users } from "./encryption"

const encrypted = await encryptionClient.encrypt("[email protected]", {
  column: users.email,
  table: users,
})

if (encrypted.failure) {
  throw new Error(`Encryption failed: ${encrypted.failure.message}`)
}

await db.query("INSERT INTO users (email) VALUES ($1)", [encrypted.data])

Every operation returns a Result: either data or failure, never both. Encryption happens in your process, so the plaintext never leaves it.

Query without decrypting

Encrypt the search term, then use it in an ordinary WHERE clause.

import { encryptionClient, users } from "./encryption"

const term = await encryptionClient.encryptQuery("[email protected]", {
  column: users.email,
  table: users,
})

if (term.failure) {
  throw new Error(`Query encryption failed: ${term.failure.message}`)
}

const rows = await db.query(
  "SELECT id, email FROM users WHERE email = $1::public.eql_v3_text_eq",
  [term.data],
)

The cast matters. An encrypted operator only resolves against a typed operand, so $1::public.eql_v3_text_eq is what tells Postgres to compare encrypted terms rather than fall back to raw jsonb semantics. See typed operands.

Postgres compares ciphertext against ciphertext. It never sees either plaintext.

LIKE and ILIKE do not work on encrypted columns, in any variant. Free-text matching uses bloom-filter token containment (@>) on a text_match or text_search column instead. See text.

Decrypt

const decrypted = await encryptionClient.decrypt(row.email)

if (decrypted.failure) {
  throw new Error(`Decryption failed: ${decrypted.failure.message}`)
}

console.log(decrypted.data) // "[email protected]"

decrypt takes the encrypted payload and nothing else. The payload carries the key ID it needs.

What you just built

You encrypted a field, stored it, queried it without decrypting, and read it back. Underneath:

  • ZeroKMS returned a key seed, which your application processed with its client key to derive a unique data key for that value. The data key existed in your memory for one operation and was then discarded. See cryptography.
  • Your client key never left your infrastructure. Without it, nothing decrypts, including CipherStash.
  • Your keyset is the isolation boundary. Data encrypted under one keyset cannot be decrypted with another, which is how you separate tenants or environments.
  • The index term stored alongside the ciphertext is what let Postgres answer the query. Each capability you declare has a bounded, published leakage profile: an equality term lets an observer see which rows share a value, and nothing more. Declare only the capabilities you query on, so that what a column reveals stays within what your threat model already tolerates. See searchable encryption.

Next steps

  • Using an ORM or Supabase? The Drizzle and Supabase integrations wrap your existing client so queries look normal.
  • No app changes possible? CipherStash Proxy sits in front of Postgres and does this transparently.
  • Bind decryption to a user. Provable access control ties a value to the identity that encrypted it.
  • Ready to deploy? Deployment covers credential choices, environment promotion, and production rollout gates.

On this page