CipherStashDocs

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 --supabase

The --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: null

You 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-is

Updating 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 MethodRequired IndexQuery Type
eq, neq, in.equality()'equality'
like, ilike.freeTextSearch()'freeTextSearch'
gt, gte, lt, lte.orderAndRange()'orderAndRange'
isNoneNo encryption (NULL/boolean check)

Exported types

@cipherstash/stack/supabase also exports the following types:

  • EncryptedSupabaseConfig
  • EncryptedSupabaseInstance
  • EncryptedQueryBuilder
  • PendingOrCondition
  • SupabaseClientLike

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:

  1. Encrypts mutation data — calls encryptModel / bulkEncryptModels and converts to PG composites
  2. Adds ::jsonb casts — parses your select string and adds ::jsonb to encrypted columns
  3. Batch-encrypts filter values — collects all filter values for encrypted columns and encrypts them in a single encryptQuery() call (one round-trip to ZeroKMS)
  4. Executes the real Supabase query — chains all operations on the underlying Supabase client
  5. Decrypts results — calls decryptModel / bulkDecryptModels on 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 }]

On this page