Supabase
Encrypt, search, and decrypt data with the encryptedSupabase wrapper from @cipherstash/stack, using familiar Supabase queries, filters, and inserts.
The encryptedSupabase wrapper makes encrypted queries look nearly identical to normal Supabase queries. It automatically handles encryption, decryption, ::jsonb casts, and search term formatting.
Install EQL in your Supabase database using the CipherStash CLI so encrypted columns can use the eql_v2_encrypted type.
npx stash db install --supabaseThe --supabase flag installs a Supabase-compatible version of EQL and grants the required permissions on the eql_v2 schema to anon, authenticated, and service_role.
Database schema
Encrypted columns must be stored as eql_v2_encrypted or JSONB if you don't want searchable capabilities.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email eql_v2_encrypted NOT NULL, -- encrypted column
name eql_v2_encrypted NOT NULL, -- encrypted column
age eql_v2_encrypted, -- encrypted column (numeric)
role VARCHAR(50), -- regular column (not encrypted)
created_at TIMESTAMPTZ DEFAULT NOW()
);Setup
import { createClient } from "@supabase/supabase-js"
import { Encryption } from "@cipherstash/stack"
import { encryptedSupabase } from "@cipherstash/stack/supabase"
import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
const users = encryptedTable("users", {
email: encryptedColumn("email")
.equality()
.freeTextSearch(),
name: encryptedColumn("name")
.equality()
.freeTextSearch(),
age: encryptedColumn("age")
.dataType("number")
.equality()
.orderAndRange(),
})
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!,
)
const encryptionClient = await Encryption({ schemas: [users] })
const eSupabase = encryptedSupabase({
encryptionClient,
supabaseClient: supabase,
})All queries go through eSupabase.from(tableName, schema):
const { data, error } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("email", "[email protected]")Inserting data
The wrapper encrypts fields before insertion:
// Single insert
const { data, error } = await eSupabase
.from("users", users)
.insert({
email: "[email protected]", // encrypted automatically
name: "Alice Smith", // encrypted automatically
age: 30, // encrypted automatically
role: "admin", // not in schema, passed through
})
.select("id")
// Bulk insert
const { data, error } = await eSupabase
.from("users", users)
.insert([
{ email: "[email protected]", name: "Alice", age: 30, role: "admin" },
{ email: "[email protected]", name: "Bob", age: 25, role: "user" },
])
.select("id")Selecting data
The wrapper decrypts results automatically:
// List query — returns decrypted array
const { data, error } = await eSupabase
.from("users", users)
.select("id, email, name, role")
// data: [{ id: 1, email: "[email protected]", name: "Alice Smith", role: "admin" }]
// Single result
const { data, error } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("id", 1)
.single()
// data: { id: 1, email: "[email protected]", name: "Alice Smith" }
// Maybe single (returns null if no match)
const { data, error } = await eSupabase
.from("users", users)
.select("id, email")
.eq("email", "[email protected]")
.maybeSingle()
// data: nullYou must list columns explicitly in select() — using select('*') will throw an error. The wrapper automatically adds ::jsonb casts to encrypted columns so PostgreSQL parses them correctly.
select() also accepts an optional second parameter: select(columns, { head?: boolean, count?: 'exact' | 'planned' | 'estimated' }).
Query filters
All filter values for encrypted columns are automatically encrypted before the query executes. Multiple filters are batch-encrypted in a single ZeroKMS call for efficiency.
Equality filters
// Exact match (requires .equality() on column)
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("email", "[email protected]")
// Not equal
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.neq("email", "[email protected]")
// IN array (requires .equality())
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.in("name", ["Alice Smith", "Bob Jones"])
// NULL check (no encryption needed)
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.is("email", null)Text search filters
// LIKE — case sensitive (requires .freeTextSearch())
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.like("name", "%alice%")
// ILIKE — case insensitive (requires .freeTextSearch())
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.ilike("email", "%example.com%")Range and comparison filters
// Greater than (requires .orderAndRange())
const { data } = await eSupabase
.from("users", users)
.select("id, name, age")
.gt("age", 21)
// Greater than or equal
const { data } = await eSupabase
.from("users", users)
.select("id, name, age")
.gte("age", 18)
// Less than
const { data } = await eSupabase
.from("users", users)
.select("id, name, age")
.lt("age", 65)
// Less than or equal
const { data } = await eSupabase
.from("users", users)
.select("id, name, age")
.lte("age", 100)Match (multi-column equality)
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.match({ email: "[email protected]", name: "Alice Smith" })OR conditions
// String format
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.or("[email protected],[email protected]")
// Structured format (more type-safe)
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.or([
{ column: "email", op: "eq", value: "[email protected]" },
{ column: "email", op: "eq", value: "[email protected]" },
])Both forms encrypt values for encrypted columns automatically.
NOT filter
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.not("email", "eq", "[email protected]")Raw filter
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.filter("email", "eq", "[email protected]")Combining encrypted and non-encrypted filters
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("email", "[email protected]") // encrypted
.eq("role", "admin") // passed through as-isUpdating and deleting
// Update
const { data } = await eSupabase
.from("users", users)
.update({ name: "Alice Johnson" }) // encrypted automatically
.eq("id", 1)
.select("id, name")
// Upsert
const { data } = await eSupabase
.from("users", users)
.upsert(
{ id: 1, email: "[email protected]", name: "Alice", role: "admin" },
{ onConflict: "id" },
)
.select("id, email, name")
// Delete
const { error } = await eSupabase
.from("users", users)
.delete()
.eq("id", 1)Transforms
These are passed through to Supabase directly:
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("name", "Alice")
.order("name", { ascending: true })
.limit(10)
.range(0, 9)The following transform methods are also supported:
.csv()
.abortSignal(signal)
.throwOnError()
.returns<U>()Lock context and audit
Chain .withLockContext() to tie encryption to a specific user's JWT:
import { LockContext } from "@cipherstash/stack/identity"
const lc = new LockContext()
const identified = await lc.identify(userJwt)
if (identified.failure) throw new Error(identified.failure.message)
const lockContext = identified.data
const { data } = await eSupabase
.from("users", users)
.insert({ email: "[email protected]", name: "Alice" })
.withLockContext(lockContext)
.select("id")Lock contexts work with all operations (insert, select, update, delete):
const { data } = await eSupabase
.from("users", users)
.select("id, email, name")
.eq("email", "[email protected]")
.withLockContext(lockContext)
.audit({ metadata: { action: "user-lookup", requestId: "abc-123" } })Error handling
Encryption errors are surfaced with an additional encryptionError field:
const { data, error } = await eSupabase
.from("users", users)
.select("id, email")
if (error) {
if (error.encryptionError) {
console.error("Encryption error:", error.encryptionError)
}
}Response type
type EncryptedSupabaseResponse<T> = {
data: T | null // Decrypted rows
error: EncryptedSupabaseError | null
count: number | null
status: number
statusText: string
}Errors can come from Supabase (API errors) or from encryption operations. Check error.encryptionError for encryption-specific failures.
The full EncryptedSupabaseError type:
type EncryptedSupabaseError = {
message: string
details?: string // Supabase error details
hint?: string // Supabase error hint
code?: string // Supabase/PostgreSQL error code
encryptionError?: EncryptionError // CipherStash encryption-specific error
}Filter to index mapping
| Filter Method | Required Index | Query Type |
|---|---|---|
eq, neq, in | .equality() | 'equality' |
like, ilike | .freeTextSearch() | 'freeTextSearch' |
gt, gte, lt, lte | .orderAndRange() | 'orderAndRange' |
is | None | No encryption (NULL/boolean check) |
Exported types
@cipherstash/stack/supabase also exports the following types:
EncryptedSupabaseConfigEncryptedSupabaseInstanceEncryptedQueryBuilderPendingOrConditionSupabaseClientLike
Exposing EQL schema for Supabase
If you installed EQL with npx stash db install --supabase, the role grants are already applied. You still need to expose the schema in the Supabase dashboard:
Go to API settings and add eql_v2 to Exposed schemas.
If you installed EQL manually (without --supabase), you also need to grant permissions. See CipherStash CLI — Supabase install for the required grants.
How it works
encryptedSupabase uses a deferred query builder pattern. All chained operations (.select(), .eq(), .insert()) are recorded synchronously. When you await the query, the builder:
- Encrypts mutation data — calls
encryptModel/bulkEncryptModelsand converts to PG composites - Adds
::jsonbcasts — parses your select string and adds::jsonbto encrypted columns - Batch-encrypts filter values — collects all filter values for encrypted columns and encrypts them in a single
encryptQuery()call (one round-trip to ZeroKMS) - Executes the real Supabase query — chains all operations on the underlying Supabase client
- Decrypts results — calls
decryptModel/bulkDecryptModelson the returned data
Complete example
import { createClient } from "@supabase/supabase-js"
import { Encryption } from "@cipherstash/stack"
import { encryptedSupabase } from "@cipherstash/stack/supabase"
import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
// Schema
const users = encryptedTable("users", {
email: encryptedColumn("email").equality().freeTextSearch(),
name: encryptedColumn("name").equality().freeTextSearch(),
age: encryptedColumn("age").dataType("number").equality().orderAndRange(),
})
// Clients
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)
const encryptionClient = await Encryption({ schemas: [users] })
const eSupabase = encryptedSupabase({ encryptionClient, supabaseClient: supabase })
// Insert
await eSupabase
.from("users", users)
.insert([
{ email: "[email protected]", name: "Alice", age: 30 },
{ email: "[email protected]", name: "Bob", age: 25 },
])
// Query with multiple filters
const { data } = await eSupabase
.from("users", users)
.select("id, email, name, age")
.gte("age", 18)
.lte("age", 35)
.ilike("name", "%ali%")
// data is fully decrypted:
// [{ id: 1, email: "[email protected]", name: "Alice", age: 30 }]