How to Secure Encrypted Data in Supabase
Getting searchable field-level encryption running on Supabase takes a single command, npx stash init --supabase, which installs the encrypted-query types, sets up authentication, and scaffolds a starter client. Under that command, an application encrypts sensitive fields before they reach the database, stores randomized ciphertext alongside encrypted search terms, and lets PostgreSQL filter, sort, and join over those values without ever seeing the plaintext or the key. This second part of the series walks through that setup end to end against a table of user records holding email addresses and SSN fields, covering environment setup, the choice of encryption schemes per field, and wiring search capabilities in without giving up the security guarantees that the first part of this series established.
Key Takeaways:
- Setup is one command,
npx stash init --supabase, and theencryptedSupabasewrapper keeps the standard Supabase JavaScript client API.- The EQL column type itself declares what can be queried, so choosing a scheme per column is the core design decision, not a global setting.
- Equality fields use a keyed HMAC term, range fields use an order-preserving term, and free-text fields use a bloom-filtered n-gram term; you index each with ordinary Postgres indexes.
- Every query capability reveals something controlled: equality exposes frequency, ordering exposes relative order, and free-text exposes token structure.
- Vendor benchmarks put exact-match and range queries within 1.2 to 1.4 times plaintext, but inserts slow down, and free-text fields are the most expensive to write.
Setting up the environment
Supabase runs PostgreSQL as its core database, which matters because the entire approach sits on Postgres types, operators, and indexes rather than on a proprietary engine. According to the Supabase announcement, CipherStash ships Encrypt Query Language (EQL), an open-source set of Postgres domains and functions that add encrypted column types to a Supabase project. A companion wrapper called encryptedSupabase wraps the regular Supabase JavaScript client and performs encryption on the way in and decryption on the way out. The two together give you encrypted search capabilities in Supabase without changing the database schema.

You need three things before you start: a Supabase project with its database connection string, a Node.js project, and a CipherStash account. The setup flow signs you in. Add your Supabase settings to the project environment file first:
SUPABASE_URL=https://project-ref.supabase.co
SUPABASE_ANON_KEY=...
DATABASE_URL=postgresql://postgres:[email protected]:5432/postgres
Then run the one-command setup:
npx stash init --supabase
The command opens a device login if you do not already have a developer profile, resolves the database configuration, installs pinned versions of the CipherStash packages and the CLI, installs EQL v3 with the grants that Supabase’s anon, authenticated, and service_role roles need, and scaffolds an encryption client. If the command generates a Supabase migration instead of applying EQL directly, run the apply command it prints, then confirm the installation with npx stash eql status. The CipherStash Supabase quickstart documents this flow in detail.
One Supabase-specific step is easy to miss. The EQL functions live in a versioned schema such as eql_v3, and you must expose that schema in the Supabase dashboard so your API keys can reach it: API settings, then Exposed schemas, then add eql_v3. Without this, the wrapper cannot resolve the encrypted operators even though the types are installed. The CipherStash working guide on encrypting Supabase data calls this out as part of the --supabase install path.
Choosing encryption schemes per column
The superset of capabilities comes from the column type. EQL types each column as a domain variant that declares what can be queried, and the core concepts reference is explicit that this is a per-column decision, not a global one. A column typed public.eql_v3_text_eq supports equality. A column typed public.eql_v3_text_search supports equality, ordering, and free-text match. A column typed public.eql_v3_integer_ord supports ranges and ordering. The capability you pick determines which index terms travel with each value and, critically, what a database observer can infer.
This is where the encryption schemes from the earlier part of this series become concrete. Deterministic encryption, implemented here as a keyed HMAC term, is what powers equality search: the same plaintext always produces the same term, so Postgres can index and match it. Order-preserving encryption powers ranges and ordering by letting the database compare values relatively. Free-text search uses a bloom-filtered set of character n-grams, which gives approximate substring matching. Each reveals something different, so the honest rule is to give a column only the capability it actually needs.
| Column capability | EQL domain variant | Index term | What a database observer can infer |
|---|---|---|---|
| Equality only | public.eql_v3_text_eq |
Keyed HMAC term | Which values are equal, and therefore their frequency |
| Range and ordering | public.eql_v3_integer_ord |
Order-preserving term | Equality, frequency, and relative order of values |
| Free-text match | public.eql_v3_text_match |
Bloom-filtered n-grams | Probabilistic token overlap between values |
| Storage and decryption only | public.eql_v3_text |
None | Row count, nullness, and approximate value size only |
For the running example, a users table with email, name, and SSN fields, the sensible choices differ per field. Email needs equality for lookups and free-text for partial matching, so it becomes text_search. Name needs free-text matching, so text_search fits there too. An SSN is matched exactly and never ranged over, so it becomes text_eq and carries no ordering or bloom term at all. A created-at timestamp you want to filter by date range becomes timestamp_ord. A role column that row-level security policies must read stays plaintext, because RLS predicates need readable values.
Creating encrypted columns in PostgreSQL
With the schemes chosen, you create the table in the Supabase SQL editor or as a migration. The column type is the encryption schema, so there is no separate application declaration to keep synchronized. Here is the users table for the example:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email public.eql_v3_text_search NOT NULL,
name public.eql_v3_text_search NOT NULL,
ssn public.eql_v3_text_eq NOT NULL,
created_at public.eql_v3_timestamp_ord,
role varchar(50)
);
Each encrypted value is stored as a single JSON payload containing randomized ciphertext plus the index terms that make it queryable. The payload carries an envelope with the EQL version, a binding to the table and column it was encrypted for, and the opaque non-deterministic ciphertext that is never used in comparisons. Alongside the envelope travel the terms: an HMAC for equality, an order-preserving term for ranges, and a bloom filter for free-text. A domain CHECK constraint validates the payload on insert, so a malformed or wrong-version value is rejected at write time rather than surfacing later at query time.
Two design rules matter here. First, the plaintext never reaches the database; EQL only validates, stores, and compares payloads and cannot produce or decrypt them. Second, unsupported operations fail loudly rather than returning wrong rows. If you try to run a greater-than comparison on an equality-only column, the query raises an exception saying the operator is not supported, which is far safer than silently returning garbage. The one exception to the loud-failure rule is the typed-operand requirement: if you bind a query parameter as a bare JSON type instead of the EQL domain, Postgres resolves the native JSON operator and the query returns nothing. The wrapper types its parameters automatically, but raw SQL must do it by hand.
Building indexes for encrypted search
Encrypted columns are indexed with ordinary Postgres indexes over term-extractor functions, never by indexing the column itself. The indexes reference gives the four recipes. Equality uses a hash or btree index on eql_v3.eq_term(col). Range and ordering use a btree on eql_v3.ord_term(col) for the order-preserving variant. Free-text match uses a GIN index on eql_v3.match_term(col). For the users table, you index each query pattern you actually use:
CREATE INDEX users_email_eq ON users USING hash (eql_v3.eq_term(email));
CREATE INDEX users_email_match ON users USING gin (eql_v3.match_term(email));
CREATE INDEX users_name_match ON users USING gin (eql_v3.match_term(name));
CREATE INDEX users_ssn_eq ON users USING hash (eql_v3.eq_term(ssn));
CREATE INDEX users_created_at_ord ON users USING btree (eql_v3.ord_term(created_at));
ANALYZE users;
Run ANALYZE after every index build. A CREATE INDEX over an expression gathers no statistics for that expression, so without ANALYZE the planner has no histogram for the term extractor and can misjudge the index it just built. Create indexes once the table holds a meaningful number of rows, typically more than a thousand, and drop indexes for capabilities you no longer query, because duplicate indexes compete for cache and slow writes.
For large tables, the single highest-use knob is maintenance_work_mem, which defaults to a value far too small for a multi-million-row build. Raise it before indexing, and prefer btree over hash for equality on large tables: a btree build sorts, bulk-loads with sequential writes, and can parallelize, while a hash build scatters rows to random buckets and cannot. On managed Postgres, which includes Supabase, these recipes run unchanged because they use functional indexes over immutable functions rather than custom operator classes that managed platforms block.
Wiring the encrypted Supabase client
The client wrapper is where encryption and decryption actually happen. You create an encrypted client from the same Supabase URL and anon key you already use, and it inspects the database at startup to recognize which columns are encrypted:
import { encryptedSupabase } from "@cipherstash/stack-supabase"
export const db = await encryptedSupabase(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
)
The returned client is API-compatible with the regular Supabase client. Anywhere your code currently chains .from().select().eq(), it can chain the same calls on the encrypted client instead. The one meaningful change is that you list columns explicitly in .select() rather than using the wildcard form, because the client needs to know which columns to encrypt and which query shapes are valid for each one. Plaintext tables and columns continue to behave exactly as before.
Key handling is what separates this from ordinary application-level encryption. Each encrypted value gets its own data key, derived on demand and never stored or transmitted. The authority key lives in ZeroKMS, encrypted at rest under an AWS KMS root key, while the client key stays in your application environment. A usable data key comes into existence only inside your application, when material from ZeroKMS is processed with material that never leaves your environment, and it is discarded after a single operation. Because the keys never enter the database, neither CipherStash nor Supabase can read the plaintext.
Inserting and querying encrypted data
You write plaintext application values as usual, and the wrapper encrypts them before they leave the app. A bulk insert of two users works like any Supabase insert:
await db.from("users").insert([
{ email: "[email protected]", name: "Alice Smith", ssn: "000-00-0001", role: "admin" },
{ email: "[email protected]", name: "Bob Jones", ssn: "000-00-0002", role: "user" }
])
The wrapper encrypts email, name, and ssn before the payload reaches Postgres, while role passes through as plaintext. When you query, the wrapper encrypts the filter value and decrypts the matching result, so the call looks identical to an unencrypted query:
const { data } = await db
.from("users")
.select("id, email, name, ssn")
.eq("email", "[email protected]")
.single()
Free-text matching uses the bloom-filtered n-gram term. The API keeps the familiar ilike() method name, but the actual match is an approximation run against indexed n-grams, not real SQL LIKE, and it is case-insensitive regardless of which method you call. Range and ordering queries work over the order-preserving term:
There is a functional limit worth knowing before you build: in EQL 3.0.4 the Supabase wrapper supports equality, range, and ordering, but encrypted free-text and JSON queries currently require an ORM adapter such as Drizzle or Prisma because PostgREST cannot express the typed operands those operations need. If you rely on free-text search through the plain PostgREST path, plan for that adapter.
Security considerations and what a breach exposes
The security payoff is visible in what a dump actually contains. A pg_dump of the users table returns ciphertext payloads plus opaque encrypted index structures, with no plaintext column values, no shared deterministic ciphertext between users, and no document hashes that could be rainbow-tabled. A stolen snapshot, an over-privileged admin, or a leaked PostgREST log all see the same thing: ciphertext with no key, because the key never enters the database.
Encryption complements Supabase row-level security rather than replacing it. RLS controls which rows a caller may address; encryption controls whether the values in those rows can be read. The layered design keeps RLS predicates on plaintext ownership columns such as role or a user ID, and encrypts the sensitive contents of the row. Keep that separation in mind when you design the schema, and keep the columns RLS needs readable.
The honest caveats matter for a production rollout. Searchable encryption is not zero-leakage: every capability reveals something controlled, and a fully compromised application tier holds the keys and can decrypt everything, so this control protects data against database and cloud insiders, not against an attacker who owns your application process. Free-text search over paragraph-scale fields is impractical because bloom-filtered n-grams trade a small false-positive rate for index size, so reserve it for emails, names, and short identifiers. And be aware that the CipherStash Proxy, offered as an escape hatch for direct database access from analytics jobs or other languages, is a separate surface that must itself be locked down, a point independent reviewers have pressed on.
Performance expectations
CipherStash publishes benchmark figures on an Apple M1 Max running PostgreSQL 17 with EQL v3, from 10,000 to 10 million rows. Exact match and order-preserving-encryption range queries run at about 0.12 milliseconds, within 1.2 to 1.4 times plaintext, and the ratio stays flat as the dataset grows. Encrypted inserts run at roughly 11,000 rows per second for equality and order-preserving columns, down to about 1,300 rows per second for a free-text search column that generates extra bloom and ordering terms per value. The full methodology and per-scenario plans are in the CipherStash benchmarks reference.
These are vendor-reported, single-machine, single-connection medians, so absolute numbers on your hardware and under concurrency will differ. The useful takeaways are directional: read latency on indexed encrypted columns tracks the index, not the row count, so point lookups stay fast at scale, while writes pay a per-value encryption and term-derivation cost that grows with how many capabilities a column carries. Free-text fields are the most expensive to write, which is another reason to give a column only the capabilities it needs. Batching and parallel writers scale insert throughput well past the single-threaded figures here.
What comes next
This part turned the concepts from the series overview into a working implementation: environment setup, per-field scheme selection, encrypted columns, indexes, the encrypted client, and real equality and range queries running over ciphertext. The next part in this series examines the security and performance trade-offs of searchable encryption in depth, comparing the encryption schemes, their leakage profiles, and their impact on query speed so you can choose approaches that fit your workload. You can also return to the series overview to see how all six parts fit together.
Related Reading
More in-depth coverage from this blog on closely related topics:
Sources and References
Sources cited while researching and writing this article:
Nadia Kowalski
Has read every privacy policy you've ever skipped. Fluent in GDPR, CCPA, SOC 2, and several other acronyms that make people's eyes glaze over. Processes regulatory updates faster than most organizations can schedule a meeting about them. Her idea of light reading is a 200-page compliance framework, and she remembers all of it.
