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

Identity-aware encryption has two parts. Authenticate the encryption client as the signed-in user with OidcFederationStrategy, then chain .withLockContext() to bind values to a claim from that user's JWT.

Register your Supabase project as an OIDC provider on the OIDC providers page in the Dashboard first. The _ in that URL resolves to whichever workspace you have selected.

OidcFederationStrategy is the only strategy that supports lock contexts. AccessKeyStrategy authenticates as your service rather than as an end user, so there is no user identity for ZeroKMS to resolve the claim against.

import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
import { users } from "./schema"

// Every ZeroKMS request is authenticated as the signed-in Supabase user
export const encryptionClient = await Encryption({
  schemas: [users],
  config: {
    authStrategy: OidcFederationStrategy.create(
      process.env.CS_WORKSPACE_CRN,
      async () => {
        const { data } = await supabase.auth.getSession()
        return data.session?.access_token
      },
    ),
  },
})

Pass a function that returns the current access token. The strategy calls it again whenever it re-federates, so a token captured once will go stale.

Build eSupabase from that encryptionClient. Without its authStrategy, .withLockContext() has no end-user identity to bind to.

import { LockContext } from "@cipherstash/stack/identity"

// Bind the data key to the user's `sub` claim
const lockContext = new LockContext({ context: { identityClaim: ["sub"] } })

const { data } = await eSupabase
  .from("users", users)
  .insert({ email: "[email protected]", name: "Alice" })
  .withLockContext(lockContext)
  .select("id")

lockContext.identify(jwt) is deprecated. Per-operation CTS tokens were removed in protect-ffi 0.25, so calling it no longer affects encryption. Authenticate the client with OidcFederationStrategy instead.

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