# Encrypting Supabase data with CipherStash Stack

*Published on 2026-05-24T00:00:00.000Z*

*By CJ Brewer*

Drop-in field-level encryption for Supabase that keeps your queries working. Equality, LIKE, ranges and ordering — all over ciphertext, with the Supabase JS client you already use.

## Content

Supabase encrypts your data at rest on the disk and in transit on the wire — which protects against a stolen disk or a sniffed connection, and nothing else. Once a query reaches Postgres, every value is in plaintext: visible to the database server, to any backup, to anyone with the right credentials, and to anything that logs SQL.

For most apps that's fine. For an app holding healthcare records, financial data, identity documents, or anything that lands you in a compliance conversation, it isn't — and you'd reach for **field-level encryption**: encrypt sensitive values before they leave your application, store ciphertext in the database, decrypt on the way back. Until recently that meant giving up search: you cannot `WHERE email = $1` on a column you cannot read.

This post is a working guide to doing it without giving up search. By the end you will have a Supabase table with an encrypted `email` column, an encrypted `name` column, and real queries — equality, `ilike`, and ranges — running over ciphertext using the Supabase JS client you already use.

## What "encrypted *and* searchable" actually means

Supabase already gives you two layers of encryption out of the box:

- **At rest** — Postgres' data files are encrypted on disk with AES-256.
- **In transit** — every connection runs over TLS.

Both protect against attacks on the *infrastructure*. Neither protects against an attack that reaches *inside* Postgres, *into* application memory, or anywhere in the request path. Encryption-in-transit also stops at the wire — the moment a TLS-protected payload reaches the application, it is plaintext in memory, in logs, in any caching layer, and from there it's plaintext on its way to Postgres too. Stolen credentials, an over-privileged role, a leaked backup, a misconfigured replica, an SQL log shipped to a third-party observability tool — all see real data.

The fourth thing — the one this post is about — is **encryption in use**: data encrypted by the application before it ever leaves your process, queries evaluated by Postgres without decrypting, indexes built over encrypted structures the planner can use, and **values only decrypted at the precise moment the application needs to use them — and only when the request carries the credential authorised to decrypt that value**. An attacker with full read access to the Postgres files, the application's memory, or its outbound HTTP traffic sees ciphertext, opaque index structures, and nothing else.

The keys never enter the database. Each encrypted value gets its own data key, derived on demand by [ZeroKMS](https://cipherstash.com/docs/stack/cipherstash/kms/) and never stored. ZeroKMS is backed by AWS KMS by default; for the strongest separation you can supply your own root key in an AWS KMS account you control. Either way, CipherStash never sees your data, and unwrapped data keys never leave your application.

## The drop-in: `encryptedSupabase`

The friction with most field-level encryption libraries is that they want to own your query layer. `@cipherstash/stack` ships a Supabase wrapper that doesn't — `encryptedSupabase()` returns a client with the same API as the regular Supabase JS client, so this:

```ts
const { data, error } = await supabase
  .from('users')
  .select('id, email, name')
  .eq('email', 'alice@example.com')
```

becomes this:

```ts
const { data, error } = await eSupabase
  .from('users', users)
  .select('id, email, name')
  .eq('email', 'alice@example.com')
```

— with an extra `users` schema argument and no `select('*')`. The values for `.eq()`, `.ilike()`, `.gt()` and so on are encrypted automatically before the query reaches Postgres; results are decrypted automatically on the way back. The Supabase planner uses the encrypted indexes the same way it'd use any other index.

## A working walkthrough

The fastest path through this whole setup is one command:

```bash
npx stash init
```

That installs the encrypted-query types and functions into your Supabase database, sets up auth, and generates a starter client. The walkthrough below is what `stash init` produces, decomposed — useful both as a mental model and as a reference if you'd rather wire it up by hand.

### 1. Install EQL into your Supabase database

```bash
npx stash db install --supabase
```

The `--supabase` flag installs a Supabase-compatible version of [EQL](https://github.com/cipherstash/encrypt-query-language) — the open-source set of Postgres types and functions that backs the encrypted columns — and grants the right permissions on the `eql_v2` schema to `anon`, `authenticated`, and `service_role` so your Supabase keys work as usual.

Then expose the schema in the Supabase dashboard: **API settings → Exposed schemas → add `eql_v2`**.

### 2. Create the table

```sql
CREATE TABLE users (
  id         SERIAL PRIMARY KEY,
  email      eql_v2_encrypted NOT NULL,        -- encrypted, searchable
  name       eql_v2_encrypted NOT NULL,        -- encrypted, searchable
  age        eql_v2_encrypted,                 -- encrypted, range-queryable
  role       VARCHAR(50),                       -- plaintext, RLS-readable
  created_at TIMESTAMPTZ DEFAULT NOW()
);
```

The `eql_v2_encrypted` columns hold ciphertext plus encrypted search structures — HMACs for equality, bloom-filtered n-grams for free-text search, Order-Revealing-Encryption blocks for ranges, and Structured Encrypted Vectors for JSONB containment (`@>`) and JSONPath queries. The full list of supported query shapes is in the [encrypted queries reference](https://cipherstash.com/docs/stack/cipherstash/encryption/queries). The plain `role` column stays in the clear because Row-Level Security policies need to read it.

Add an index per query pattern you actually use:

```sql
-- equality lookups (=, IN, GROUP BY)
CREATE INDEX idx_users_email_eq    ON users USING HASH (eql_v2.hmac_256(email));
CREATE INDEX idx_users_name_eq     ON users USING HASH (eql_v2.hmac_256(name));

-- free-text search (ilike, like)
CREATE INDEX idx_users_email_match ON users USING GIN  (eql_v2.bloom_filter(email));
CREATE INDEX idx_users_name_match  ON users USING GIN  (eql_v2.bloom_filter(name));

-- ranges + ordering (gt, gte, lt, lte, ORDER BY)
CREATE INDEX idx_users_age_range   ON users USING BTREE (eql_v2.ore_block_u64_8_256(age));
```

### 3. Describe what's encrypted in TypeScript

```ts
// app/encryption/schema.ts
import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema'

export const users = encryptedTable('users', {
  email: encryptedColumn('email').equality().freeTextSearch(),
  name:  encryptedColumn('name').equality().freeTextSearch(),
  age:   encryptedColumn('age').dataType('number').equality().orderAndRange(),
})
```

The TypeScript schema is the source of truth — the Stack client reads it at runtime to know which columns to encrypt and which query shapes are valid for each one.

### 4. Sign in to CipherStash

If you don't have an account yet, [sign up](https://cipherstash.com/signup). Then:

```bash
npx stash auth login
```

That creates a local profile at `~/.cipherstash/` for development. For Supabase-specific setup, add your Supabase project as an OIDC provider in the [CipherStash dashboard](https://dashboard.cipherstash.com/sign-in) — that's how the dashboard verifies the Supabase Auth JWTs you'll pass to `LockContext` later in this post. For production, generate API credentials in the same dashboard and set the env vars referenced in the next step.

### 5. Initialise the encrypted Supabase client

```ts
// app/db.ts
import { createClient } from '@supabase/supabase-js'
import { Encryption } from '@cipherstash/stack'
import { encryptedSupabase } from '@cipherstash/stack/supabase'
import { users } from './encryption/schema'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_ANON_KEY!,
)

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

export const eSupabase = encryptedSupabase({
  encryptionClient,
  supabaseClient: supabase,
})
```

`eSupabase` is API-compatible with the regular Supabase client. Anywhere your code currently chains `.from().select().eq()` it can chain the same calls on `eSupabase` instead — just pass the schema to `.from()`, and list columns explicitly in `.select()`. A fully working version of every snippet below is in [`cipherstash/stack/examples/basic`](https://github.com/cipherstash/stack/tree/main/examples/basic) — runnable against a real Supabase project.

### 6. Insert encrypted data

```ts
await eSupabase
  .from('users', users)
  .insert({
    email: 'alice@example.com',  // encrypted automatically
    name:  'Alice Smith',        // encrypted automatically
    age:   30,                   // encrypted automatically
    role:  'admin',              // passed through as-is
  })
```

Bulk inserts work the same way:

```ts
await eSupabase
  .from('users', users)
  .insert([
    { email: 'alice@example.com', name: 'Alice', age: 30, role: 'admin' },
    { email: 'bob@example.com',   name: 'Bob',   age: 25, role: 'user'  },
  ])
```

### 7. Query encrypted columns

Equality:

```ts
const { data } = await eSupabase
  .from('users', users)
  .select('id, email, name')
  .eq('email', 'alice@example.com')
// data: [{ id: 1, email: 'alice@example.com', name: 'Alice Smith' }]
```

Approximate substring match — pattern style is `.ilike()` for API familiarity, but the actual match is run against the bloom-filtered n-grams of the encrypted value (see the note below):

```ts
const { data } = await eSupabase
  .from('users', users)
  .select('id, email, name')
  .ilike('email', '%example.com%')
```

Ranges and ordering:

```ts
const { data } = await eSupabase
  .from('users', users)
  .select('id, name, age')
  .gte('age', 18)
  .lte('age', 35)
  .order('age', { ascending: true })
```

Combining encrypted and plaintext filters in the same query is fine:

```ts
const { data } = await eSupabase
  .from('users', users)
  .select('id, email, name, role')
  .eq('email', 'alice@example.com')   // encrypted — value encrypted before query
  .eq('role',  'admin')               // plaintext — passed through to Supabase
```

The Supabase planner uses the encrypted indexes for the encrypted predicates and the plaintext index (or sequential scan, depending on selectivity) for the rest. From your code's point of view it's the same Supabase query you'd have written without encryption.

A note on text search: `.ilike()` and `.like()` over encrypted columns are **approximations**, not real SQL `LIKE`. The match is run against a bloom-filtered set of 3-character n-grams of the indexed text — closer in spirit to a Postgres `tsearch` than to SQL `LIKE`. The `%` wildcards in the pattern are not parsed; matching is case-insensitive regardless of which method you call; multi-word inputs work; positional patterns (`foo%`, leading wildcards, etc.) are not honored — `'foo'` matches wherever it appears. The Stack API will likely rename this to `.match()` in a future release to reflect what it's actually doing.

## Identity-bound encryption with Supabase Auth

Supabase Auth gives every request a JWT. You can tie encryption to that JWT so a leaked database snapshot cannot be decrypted without an authenticated user — each value's per-user lock context is what unlocks the key. Chain `.withLockContext()`:

```ts
import { LockContext } from '@cipherstash/stack/identity'

const lc = new LockContext()
const identified = await lc.identify(req.headers.get('authorization')!)
if (identified.failure) throw new Error(identified.failure.message)
const lockContext = identified.data

const { data } = await eSupabase
  .from('users', users)
  .select('id, email, name')
  .eq('email', 'alice@example.com')
  .withLockContext(lockContext)
  .audit({ metadata: { action: 'user-lookup' } })
```

The same chain works on inserts, updates and deletes. Combined with Supabase RLS, you get a layered defence: RLS gates *which* rows a user can address, the lock context gates *whether* the data they address can be decrypted at all, and every decrypt produces an audit record in your CipherStash workspace.

## What an attacker sees

A `pg_dump` of the table is ciphertext JSONB plus opaque encrypted-index structures. No plaintext column values, no document hashes you could rainbow-table, no shared deterministic ciphertext between users. A stolen snapshot, an over-permissioned admin, a leaked PostgREST log — they all see the same thing: ciphertext with no key.

The key itself never enters Supabase. Per-value keys are derived from a root key held in your own AWS KMS account, brokered by ZeroKMS.

## When *not* to use this

- **Free-text search across paragraph-scale fields.** Bloom-filter n-grams trade a small false-positive rate for index size; that's a great tradeoff for emails, names and identifiers, but not for searching `War and Peace`. Reach for full-text search over non-sensitive plaintext in that case.
- **Columns you'll never `WHERE` on.** If the column never appears in a predicate you can still use `eql_v2_encrypted` — just skip the index types and don't add an `eql_v2.hmac_256(...)` / `bloom_filter(...)` / `ore_*(...)` index for it. You keep the strong key management and the application-side encryption story; you just don't pay for the search structures you wouldn't use.

## Migrating from the older Protect.js library

If you're already running our earlier `@cipherstash/protectjs` library against Supabase, the high-level model is the same — schema, encrypted client, encrypted inserts. Stack supersedes Protect.js with first-class Supabase integration (`encryptedSupabase`), identity-bound encryption (`LockContext`), and the unified `npx stash` CLI. We recommend new projects start on Stack; existing Protect.js apps can migrate column-by-column without re-encrypting historical data — [reach out](https://cipherstash.com/contact) and we'll help you scope it.

## Get started

- **`npx stash init`** — the fastest path: auth, EQL install, and a starter encrypted Supabase client in one command.
- **Docs:** [Stack + Supabase reference](https://cipherstash.com/docs/stack/cipherstash/encryption/supabase)
- **Stack on GitHub:** [cipherstash/stack](https://github.com/cipherstash/stack)
- **EQL on GitHub:** [cipherstash/encrypt-query-language](https://github.com/cipherstash/encrypt-query-language)
- **`@cipherstash/stack` on npm:** [@cipherstash/stack](https://www.npmjs.com/package/@cipherstash/stack)

## Related blog posts

- [Introducing @cipherstash/stack](https://cipherstash.com/blog/introducing-cipherstash-stack.md) — Building blocks for Data Level Access Control in TypeScript
- [Searchable encryption in Postgres: a working guide with CipherStash Stack and EQL](https://cipherstash.com/blog/searchable-encryption-in-postgres.md) — Encrypt your data, keep the queries. A practical, indexable approach to searchable encryption in Postgres — 410,000× faster than fully homomorphic encryption — with a working code example using @cipherstash/stack and EQL.
- [Encryption in use with PostgreSQL](https://cipherstash.com/blog/encryption-in-use-with-postgresql.md) — Don't just rely on encryption at rest and in transit to protect your sensitive data. Use searchable encryption to enable encryption in use to harden data privacy in Postgres.

