Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 46 additions & 60 deletions content/stack/cipherstash/encryption/indexes.mdx
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
---
title: Setting up indexes
description: Create PostgreSQL indexes for encrypted columns. Index syntax differs between self-hosted PostgreSQL and managed databases like Supabase.
description: Create PostgreSQL functional indexes for encrypted columns — one recipe per query type, working on self-hosted and managed Postgres alike.
---

# Setting up indexes

Encrypted columns need PostgreSQL indexes for fast queries. Without an index, the database performs a sequential scan: correct but slow at scale.
Encrypted columns need PostgreSQL indexes for fast queries. Without one, the database falls back to a sequential scan: correct, but slow at scale.

Index syntax differs between deployment types. Self-hosted PostgreSQL with full EQL installed supports custom operator classes and can use B-tree indexes directly on `eql_v2_encrypted` columns. Managed databases like Supabase cannot install operator families (they require superuser), so indexes must use extraction functions instead.
EQL indexes an encrypted column with a **functional index** — an ordinary PostgreSQL index built over an EQL extraction function (`eql_v2.hmac_256`, `eql_v2.bloom_filter`, and so on) rather than over the raw column. There is one recipe per query type: equality, pattern matching, and JSONB containment indexes work on every PostgreSQL deployment with no special privileges; range and ordering indexes additionally need a custom operator class, which most providers allow (see the note below).

## Deployment matrix
Bare-form queries engage these indexes automatically. `WHERE email = $1`, `WHERE name LIKE $1`, and `WHERE age < $1` each match their functional index with no query rewriting: EQL's operators inline to the same extraction function the index is built on, so the planner connects the two.

| Query type | Self-hosted (full EQL) | Supabase |
## At a glance

| Query type | Index recipe | Availability |
|---|---|---|
| Equality | `USING btree (col)` with opclass, or `USING hash (eql_v2.hmac_256(col))` | `USING hash (eql_v2.hmac_256(col))` only |
| Range / ORDER BY | `USING btree (col)` with opclass | None (OPE-index work in progress) |
| Pattern match | `USING gin (eql_v2.bloom_filter(col))` | Same |
| JSONB containment | `USING gin (eql_v2.ste_vec(col))` | Same |
| Equality | `USING hash (eql_v2.hmac_256(col))` | All providers |
| Pattern match (`LIKE` / `ILIKE`) | `USING gin (eql_v2.bloom_filter(col))` | All providers |
| JSONB containment | `USING gin (eql_v2.jsonb_array(col))` | All providers |
| Range / `ORDER BY` | `USING btree (eql_v2.ore_block_u64_8_256(col))` | Most providers |

<Callout type="info">
Range filters (`>`, `>=`, `<`, `<=`) work on Supabase without a range index (they use a sequential scan). `ORDER BY` on encrypted columns is not supported on Supabase at all. Sort application-side after decrypting results. Operator family support for Supabase is in development.
The range index carries one caveat: it relies on a B-tree operator class for the ORE term type, and creating an operator class needs elevated privileges. It works on any provider that allows that — self-hosted PostgreSQL and AWS RDS included — but not on every managed platform. Supabase is the notable exception. Where the range index cannot be created, range *filters* (`>`, `>=`, `<`, `<=`) still return correct results (they fall back to a sequential scan), but `ORDER BY` on an encrypted column is unavailable — sort application-side after decrypting.
</Callout>

---
Expand All @@ -28,21 +30,17 @@ Index syntax differs between deployment types. Self-hosted PostgreSQL with full

Equality indexes speed up `WHERE col = $1` queries and `IN` lists.

**Self-hosted (B-tree with operator class):**

```sql
CREATE INDEX ON users USING btree (email);
CREATE INDEX ON users USING hash (eql_v2.hmac_256(email));
```

This works because the full EQL install registers a B-tree operator class for `eql_v2_encrypted` that compares HMAC terms.

**Self-hosted or Supabase (hash on extraction function):**
A bare equality query engages the index — you do not need to mention `eql_v2.hmac_256` in the query itself:

```sql
CREATE INDEX ON users USING hash (eql_v2.hmac_256(email));
SELECT * FROM users WHERE email = $1;
```

This form works on both deployment types. Use it when you want one index that works everywhere, or when you are on Supabase.
The `=` operator on an encrypted column inlines to a comparison of the HMAC term, which is exactly what the index stores, so the planner matches the two.

See queries: [Equality queries](/stack/cipherstash/encryption/queries#equality)

Expand All @@ -56,88 +54,76 @@ Match indexes speed up `WHERE col LIKE $1` and `ILIKE` queries. They use a GIN i
CREATE INDEX ON users USING gin (eql_v2.bloom_filter(name));
```

This form is identical for self-hosted and Supabase.
A bare `LIKE` / `ILIKE` query engages it:

```sql
SELECT * FROM users WHERE name LIKE $1;
```

See queries: [Match queries](/stack/cipherstash/encryption/queries#match-free-text)

---

## Range and order

Range indexes support `>`, `>=`, `<`, `<=`, `BETWEEN`, and `ORDER BY` on encrypted columns.

**Self-hosted (B-tree with operator class):**
Range indexes support `>`, `>=`, `<`, `<=`, `BETWEEN`, and `ORDER BY` on encrypted columns. They use a B-tree index on the Block-ORE term.

```sql
CREATE INDEX ON users USING btree (age);
CREATE INDEX ON users USING btree (eql_v2.ore_block_u64_8_256(age));
```

Requires the EQL operator family (`CREATE OPERATOR FAMILY`) to be installed. The full EQL install includes this. The `--exclude-operator-family` install flag omits it.
A bare range *filter* engages the index:

**Supabase:**

Functional range indexes for Supabase are not yet available. Range _filters_ work without an index (sequential scan). `ORDER BY` on encrypted columns is not supported on Supabase.

See queries: [Range queries](/stack/cipherstash/encryption/queries#range-and-ordering)

---

## JSONB
```sql
SELECT * FROM users WHERE age < $1;
```

JSONB indexes support path existence and containment queries on encrypted JSON columns.
For sorting, order by the extraction function. A bare `ORDER BY age` does not match the index expression and falls back to an in-memory sort:

```sql
CREATE INDEX ON documents USING gin (eql_v2.ste_vec(metadata));
SELECT * FROM users ORDER BY eql_v2.ore_block_u64_8_256(age) LIMIT 10;
```

This form is identical for self-hosted and Supabase.
<Callout type="warn">
The range index needs a B-tree operator class on the ORE term type, so it can be created on any provider that allows custom operator classes — self-hosted PostgreSQL, AWS RDS, and most managed Postgres. Supabase is the notable exception: there, range filters still return correct results via sequential scan but cannot be indexed, and `ORDER BY` on an encrypted column is unavailable — sort application-side after decrypting.
</Callout>

See queries: [JSONB queries](/stack/cipherstash/encryption/queries#jsonb-queries)
See queries: [Range queries](/stack/cipherstash/encryption/queries#range-and-ordering)

---

## Supabase query forms

This is the most common source of silent performance problems with encrypted columns on Supabase.

A functional index on `eql_v2.hmac_256(email)` is only engaged when the query uses the same extraction function. A bare `WHERE email = $1` query does not use the index, even if the index exists. The database falls back to a sequential scan: your query returns correct results, but it scans every row.

**Wrong (does not use functional index):**

```sql
SELECT * FROM users WHERE email = $1::eql_v2_encrypted;
```
## JSONB

**Right (engages the functional index):**
JSONB indexes support containment queries (`@>`) on encrypted JSON columns.

```sql
SELECT * FROM users WHERE eql_v2.hmac_256(email) = eql_v2.hmac_256($1::eql_v2_encrypted);
CREATE INDEX ON documents USING gin (eql_v2.jsonb_array(metadata));
```

<Callout type="warn">
SDK wrappers (Drizzle adapter, Supabase wrapper) generate the correct query form automatically. This only matters when you write raw SQL queries against Supabase encrypted columns. If you are using the Drizzle adapter or Supabase wrapper, no action is needed.
</Callout>
This recipe works on all deployments.

The same principle applies to `eql_v2.bloom_filter` and `eql_v2.ste_vec` indexes: the extraction function must appear in both the index definition and the query predicate.
See queries: [JSONB queries](/stack/cipherstash/encryption/queries#jsonb-queries)

---

## Complete example

```sql filename="migrations/add_encrypted_indexes.sql"
-- Equality index (Supabase-compatible form)
-- Equality
CREATE INDEX users_email_eq_idx ON users USING hash (eql_v2.hmac_256(email));

-- Match index
-- Pattern match
CREATE INDEX users_name_match_idx ON users USING gin (eql_v2.bloom_filter(name));

-- JSONB index
CREATE INDEX documents_metadata_ste_idx ON documents USING gin (eql_v2.ste_vec(metadata));
-- JSONB containment
CREATE INDEX documents_metadata_idx ON documents USING gin (eql_v2.jsonb_array(metadata));

-- Range index (self-hosted onlyrequires operator family)
CREATE INDEX users_age_range_idx ON users USING btree (age);
-- Range / ORDER BY (most providersneeds custom operator class support)
CREATE INDEX users_age_range_idx ON users USING btree (eql_v2.ore_block_u64_8_256(age));
```

Run `ANALYZE <table>` after creating an index and loading data, so the planner has accurate statistics.

---

## Related
Expand Down
29 changes: 9 additions & 20 deletions content/stack/cipherstash/encryption/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,13 @@ const { data } = await eSupabase
.eq("email", "alice@example.com")
```

**Raw SQL (self-hosted with EQL operator classes):**
**Raw SQL:**

```sql
SELECT * FROM users WHERE email = $1::eql_v2_encrypted;
```

**Raw SQL (Supabase / functional index form):**

```sql
SELECT * FROM users WHERE eql_v2.hmac_256(email) = eql_v2.hmac_256($1::eql_v2_encrypted);
```

<Callout type="warn">
On Supabase, bare `WHERE email = $1` does not use the functional index. Wrap both sides with `eql_v2.hmac_256()` to engage the hash index. The SDK wrappers (Drizzle, Supabase wrapper) handle this automatically. See [Index setup: Supabase callout](/stack/cipherstash/encryption/indexes#supabase-query-forms).
</Callout>
A bare `=` engages the `eql_v2.hmac_256(email)` functional index on every deployment — self-hosted and Supabase alike. The operator inlines to the HMAC comparison the index stores, so there is no need to wrap the query in `eql_v2.hmac_256()`.

**Underlying index:** [Equality index setup](/stack/cipherstash/encryption/indexes#equality)

Expand Down Expand Up @@ -173,15 +165,12 @@ const result = await pgClient.query(
)
```

**SDK, ORDER BY (self-hosted only):**
**SDK, ORDER BY (most providers):**

```typescript filename="src/queries.ts"
// Self-hosted PostgreSQL with EQL operator families installed:
const result = await pgClient.query(
"SELECT * FROM users ORDER BY age ASC",
)

// Without operator family support (Supabase, or --exclude-operator-family):
// Order by the extraction function — a bare `ORDER BY age` does not match
// the functional range index expression. The range index needs custom
// operator class support — available on most providers, not Supabase.
const result = await pgClient.query(
"SELECT * FROM users ORDER BY eql_v2.ore_block_u64_8_256(age) ASC",
)
Expand All @@ -196,7 +185,7 @@ const results = await db
.from(usersTable)
.where(await encryptionOps.gte(usersTable.age, 18))

// Sort (requires operator family support; not available on Supabase)
// Sort (requires a range index; most providers, not Supabase)
const results = await db
.select()
.from(usersTable)
Expand All @@ -217,7 +206,7 @@ const { data } = await eSupabase
```

<Callout type="warn">
`ORDER BY` on encrypted columns requires EQL operator families, which need superuser access to install. Supabase does not grant superuser. Range _filters_ (`>`, `>=`, `<`, `<=`) work on both self-hosted and Supabase. Sorting on encrypted columns is not currently supported on Supabase. Sort application-side after decrypting results. Operator family support for Supabase is being developed in collaboration with the Supabase and CipherStash teams.
`ORDER BY` on encrypted columns needs the range (Block-ORE) index, which relies on a B-tree operator class — creatable on any provider that allows custom operator classes (self-hosted, AWS RDS, and most managed Postgres). Supabase is the notable exception. Range _filters_ (`>`, `>=`, `<`, `<=`) return correct results everywhere — they fall back to a sequential scan where the index is unavailable. Where the range index cannot be created, `ORDER BY` on an encrypted column is not supported — sort application-side after decrypting.
</Callout>

**Underlying index:** [Range index setup](/stack/cipherstash/encryption/indexes#range-and-order)
Expand Down Expand Up @@ -245,7 +234,7 @@ const term = await client.encryptQuery("$.user.role", {
})

const result = await pgClient.query(
"SELECT * FROM documents WHERE cs_ste_vec_v2(metadata) @> $1",
"SELECT * FROM documents WHERE eql_v2.jsonb_array(metadata) @> eql_v2.jsonb_array($1::eql_v2_encrypted)",
[term.data],
)
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ const result = await pgClient.query(
Use `.orderAndRange()` for sorting and range operations:

<Callout type="info">
If your PostgreSQL database does not support EQL Operator families, use the `eql_v2.ore_block_u64_8_256()` function for `ORDER BY`. Databases with Operator family support can use `ORDER BY` directly on the encrypted column name.
Order by the `eql_v2.ore_block_u64_8_256()` extraction function, not the bare column — a bare `ORDER BY` does not match the functional range index. The range index needs custom operator class support, available on most providers (self-hosted, AWS RDS, …) but not Supabase; where it cannot be created, sort application-side after decrypting.
</Callout>

```typescript filename="search.ts"
Expand Down Expand Up @@ -253,7 +253,7 @@ const term = await client.encryptQuery([{
}])

const result = await pgClient.query(
"SELECT * FROM documents WHERE cs_ste_vec_v2(metadata_encrypted) @> $1",
"SELECT * FROM documents WHERE eql_v2.jsonb_array(metadata_encrypted) @> eql_v2.jsonb_array($1::eql_v2_encrypted)",
[term.data[0]]
)
```
Expand Down