diff --git a/IA.md b/IA.md
index 1294823..c1ee7d9 100644
--- a/IA.md
+++ b/IA.md
@@ -252,7 +252,6 @@ so it lives as facets and links, never as a tree section or a hub.
| CTS | Identity service in the Platform | `platform` | `/security/cts`, `/reference/auth/*` |
| `@cipherstash/auth` | Stack package (identity-aware encryption) | `[encryption, platform]` | `/reference/stack/auth` |
| Proxy stack-auth | How auth works inside the Proxy | `[proxy, platform]` | `/reference/proxy/*`, `/security/proxy` |
-| Next.js adapter | Framework integration | `[encryption, platform]` | `/integrations/nextjs` |
| Clerk / Auth0 / Okta | Auth-provider integrations | `[platform]` | `/integrations/*` (`category: auth-provider`) |
None of these is filed under an "auth" section, because there isn't one. Each is
@@ -302,21 +301,20 @@ Two notes:
## Integrations — CIP-3328 (Supabase), CIP-3330 (auth), CIP-3336 (rest)
- [x] Section scaffold 🚧 (index + supabase stub with facet exemplar)
-- [ ] `/integrations` index — category grid w/ setup badges
-- [ ] `/integrations/supabase` — flagship tutorial (CIP-3328)
-- [ ] `/integrations/supabase/database`
-- [ ] `/integrations/supabase/auth`
-- [ ] `/integrations/supabase/dashboard-experience` — Table Editor, expose eql schema
+- [x] `/integrations` index — category grid w/ setup badges
+- [x] `/integrations/supabase` — flagship tutorial (CIP-3328)
+- [x] `/integrations/supabase/database`
+- [x] `/integrations/supabase/auth`
+- [x] `/integrations/supabase/dashboard-experience` — Table Editor, expose eql schema
- [ ] ⛔ `/integrations/supabase/edge-functions` — pending Deno/FFI answer
- [ ] ⛔ `/integrations/supabase/realtime` — pending product verification
-- [ ] `/integrations/drizzle` — merge the two divergent Drizzle pages
-- [ ] `/integrations/prisma-next`
+- [x] `/integrations/drizzle`
+- [x] `/integrations/prisma-next` — EQL v2 today; revisit when v3 support lands
- [ ] `/integrations/aws/rds-aurora` — Proxy path
- [ ] `/integrations/aws/dynamodb`
- [ ] `/integrations/clerk`
- [ ] `/integrations/auth0` — end-to-end example (Clerk parity)
- [ ] `/integrations/okta` — end-to-end example (Clerk parity)
-- [ ] `/integrations/nextjs`
- [ ] `/integrations/typescript` — thin router to Stack SDK reference
- [ ] `/integrations/serverless` — Vercel/Lambda, bundling, CS_CONFIG_PATH
- [ ] `/integrations/docker`
@@ -420,7 +418,7 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`).
- [ ] `/reference/stack` — client + configuration (port encryption/* pages)
- [ ] `/reference/stack/schema`
- [ ] `/reference/stack/encrypt-decrypt` (+ bulk, models)
-- [ ] `/reference/stack/supabase` — THE canonical `encryptedSupabase` page, ONE signature (CIP-3328)
+- [x] `/reference/stack/supabase` — THE canonical `encryptedSupabase` page, ONE signature (CIP-3328)
- [ ] `/reference/stack/drizzle-operators`
- [ ] `/reference/stack/errors` — port error-handling; miette catalog later (CIP-3338)
- [ ] `/reference/stack/upgrading-from-protect` (retitled package-rename guide)
@@ -453,5 +451,9 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`).
documents the release as decided, ahead of the eql_v3 branch: payload `v: 3`,
OPE SEM specifier, Docker tag `:17-3.0.0`, `version()` output, schema files.
Each must land upstream or be walked back in the docs before merge
+- [ ] ⛔ Stack SDK Supabase-wrapper v3 alignment (CIP-3355, blocks CIP-3335) — the
+ Supabase section documents the 0.18 wrapper API with v3 wire semantics; the
+ wrapper itself is still v2 (composite type, `like` wire op, v2 payloads) and
+ the SDK's v3 branches don't touch `src/supabase/` yet
- [ ] Flip `ENABLE_V2_REDIRECTS=1`, delete `content/stack` + `/stack` routes + legacy loader (CIP-3335)
- [ ] Consistency sweep + Supabase listing v3 revision (CIP-3335)
diff --git a/content/docs/concepts/identity-aware-encryption.mdx b/content/docs/concepts/identity-aware-encryption.mdx
new file mode 100644
index 0000000..3f11530
--- /dev/null
+++ b/content/docs/concepts/identity-aware-encryption.mdx
@@ -0,0 +1,9 @@
+---
+title: Identity-aware encryption
+description: "Lock contexts and CTS: binding encrypted values to a user identity so only that identity can decrypt them."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/encryption/identity).
diff --git a/content/docs/guides/development/schema-design.mdx b/content/docs/guides/development/schema-design.mdx
new file mode 100644
index 0000000..b11397f
--- /dev/null
+++ b/content/docs/guides/development/schema-design.mdx
@@ -0,0 +1,9 @@
+---
+title: Schema design
+description: "Choosing the right encrypted type and capability for each column."
+type: guide
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3327](https://linear.app/cipherstash/issue/CIP-3327)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, [EQL core concepts](/reference/eql/core-concepts) covers the capability model, and the per-type pages ([numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json)) cover choosing variants.
diff --git a/content/docs/guides/migration/encrypt-existing-data.mdx b/content/docs/guides/migration/encrypt-existing-data.mdx
new file mode 100644
index 0000000..df00457
--- /dev/null
+++ b/content/docs/guides/migration/encrypt-existing-data.mdx
@@ -0,0 +1,9 @@
+---
+title: Encrypt existing data
+description: "Backfilling encryption onto live tables, column by column."
+type: guide
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3329](https://linear.app/cipherstash/issue/CIP-3329)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/proxy/encrypt-tool).
diff --git a/content/docs/integrations/drizzle.mdx b/content/docs/integrations/drizzle.mdx
new file mode 100644
index 0000000..e0dd150
--- /dev/null
+++ b/content/docs/integrations/drizzle.mdx
@@ -0,0 +1,179 @@
+---
+title: Drizzle
+description: "Encrypted columns with Drizzle ORM: pick a concrete EQL column type, and capability-checked operators encrypt your query operands for you."
+type: tutorial
+components: [encryption, eql]
+audience: [developer]
+integration:
+ category: orm
+ setup: code-only
+ pairsWith: [supabase, prisma-next]
+verifiedAgainst:
+ stack: "1.0.0"
+ eql: "3.0.0"
+---
+
+Drizzle keeps its shape. You declare each encrypted column with a concrete EQL type, and a set of query operators encrypt their operands before Drizzle builds the SQL. Your `select`, `where`, and `orderBy` read as they always did, and the database only ever compares ciphertext.
+
+## Install
+
+```bash cta cta-type="install" example-id="install-drizzle"
+npm install @cipherstash/stack drizzle-orm
+```
+
+Then install EQL into your database:
+
+```bash
+npx stash eql install --eql-version 3
+```
+
+
+`--eql-version 3` installs directly against the database and cannot generate a Drizzle migration: `--drizzle` is rejected alongside it. EQL is therefore not part of your migration history, so a database rebuilt from migrations will not have it. Re-run the install after a rebuild.
+
+
+## Declare the table
+
+The column type *is* the capability. There are no flags to configure. `TextEq` answers equality and nothing else, `IntegerOrd` answers ranges and ordering, `TextMatch` answers free-text containment, and a bare `Text` or `Bigint` is storage-only.
+
+```typescript filename="src/schema.ts"
+import { pgTable, integer } from "drizzle-orm/pg-core"
+import { types } from "@cipherstash/stack/eql/v3/drizzle"
+
+export const users = pgTable("users", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ email: types.TextEq("email"), // equality
+ age: types.IntegerOrd("age"), // ranges, ordering, and equality
+ bio: types.TextMatch("bio"), // free-text containment
+ balance: types.Bigint("balance"), // storage only
+})
+```
+
+Each factory maps to the matching EQL domain, so `types.TextEq("email")` types the column as `public.eql_v3_text_eq`. The variant model behind those names is in [core concepts](/reference/eql/core-concepts).
+
+| Factory | Capability | EQL domain |
+| --- | --- | --- |
+| `types.Text(name)` | Store and decrypt only | `public.eql_v3_text` |
+| `types.TextEq(name)` | `eq`, `ne`, `inArray`, `notInArray` | `public.eql_v3_text_eq` |
+| `types.TextOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_text_ord` |
+| `types.TextMatch(name)` | `contains` | `public.eql_v3_text_match` |
+| `types.TextSearch(name)` | All of the above, on text | `public.eql_v3_text_search` |
+| `types.IntegerOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_integer_ord` |
+
+The same bare / `Eq` / `Ord` pattern exists for `Smallint`, `Bigint`, `Real`, `Double`, `Numeric`, `Date`, and `Timestamp`. `Boolean` is storage-only by design, because a two-value column has too little cardinality for any searchable index to be safe.
+
+Declare only the capability you query on. Each one stores an extra index term alongside the ciphertext, with a bounded, published leakage profile: see [searchable encryption](/concepts/searchable-encryption).
+
+## Wire up the client
+
+Derive the encryption schema from the Drizzle table, build a typed client, and create the operators:
+
+```typescript filename="src/db.ts"
+import { drizzle } from "drizzle-orm/postgres-js"
+import {
+ createEncryptionOperatorsV3,
+ extractEncryptionSchemaV3,
+} from "@cipherstash/stack/eql/v3/drizzle"
+import { EncryptionV3 } from "@cipherstash/stack/v3"
+import { users } from "./schema"
+
+export const usersSchema = extractEncryptionSchemaV3(users)
+
+export const client = await EncryptionV3({ schemas: [usersSchema] })
+export const ops = createEncryptionOperatorsV3(client)
+
+export const db = drizzle({ client: sqlClient })
+```
+
+`extractEncryptionSchemaV3` reads the encryption config straight off the column types, so there is no second schema to keep in sync with the first.
+
+## Query
+
+Every where-clause operator is **async**, because it encrypts its operand before Drizzle sees it. `await` each one. `ops.asc` and `ops.desc` are synchronous.
+
+```typescript filename="src/queries.ts"
+import { db, ops } from "./db"
+import { users } from "./schema"
+
+// Equality — email is TextEq
+const exact = await db.select().from(users)
+ .where(await ops.eq(users.email, "alice@example.com"))
+
+// Ranges and ordering — age is IntegerOrd
+const adults = await db.select().from(users)
+ .where(await ops.gte(users.age, 18))
+ .orderBy(ops.asc(users.age))
+
+const midBand = await db.select().from(users)
+ .where(await ops.between(users.age, 25, 40))
+
+// Set membership, built on equality
+const listed = await db.select().from(users)
+ .where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"]))
+
+// Free-text token containment — bio is TextMatch
+const coffee = await db.select().from(users)
+ .where(await ops.contains(users.bio, "coffee"))
+```
+
+The full operator set is `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `inArray`, `notInArray`, `contains`, `exists`, `notExists`, `and`, `or`, `not`, `isNull`, `isNotNull`, `asc`, and `desc`. Null checks need no encryption, because a SQL `NULL` is never encrypted.
+
+Applying an operator the column's type cannot answer throws `EncryptionOperatorError` rather than silently returning wrong rows. Ordering a `TextEq` column, for instance, has no ordering term to compare.
+
+
+There is no `like` or `ilike`. SQL pattern matching is meaningless on ciphertext, so free-text search is `ops.contains`, which compares encrypted token sets. It matches whole indexed tokens rather than arbitrary substrings.
+
+
+## Insert and read
+
+Rows are encrypted **before** they reach Drizzle, so Drizzle never sees plaintext. Encrypt a batch in one call, which is also one ZeroKMS round trip:
+
+```typescript filename="src/insert.ts"
+import { client, db, usersSchema } from "./db"
+import { users } from "./schema"
+
+const rows = await client.bulkEncryptModels(
+ [
+ { email: "alice@example.com", age: 30, bio: "climbing and coffee", balance: 100_000n },
+ { email: "bob@example.com", age: 41, bio: "cycling and coffee", balance: 250_000n },
+ ],
+ usersSchema,
+)
+
+if (rows.failure) {
+ throw new Error(rows.failure.message)
+}
+
+await db.insert(users).values(rows.data)
+```
+
+`Bigint` columns take a native JS `bigint`. Decrypt what comes back with `client.bulkDecryptModels(rows)`, which is likewise one round trip for the batch.
+
+## Indexes
+
+At real row counts, add a functional index for each capability you query. The encrypted operators inline into these extractor functions, so an ordinary `CREATE INDEX` is all it takes:
+
+```sql filename="indexes.sql"
+CREATE INDEX users_email_eq ON users USING hash (eql_v3.eq_term(email));
+CREATE INDEX users_age_ord ON users USING btree (eql_v3.ord_term(age));
+CREATE INDEX users_bio_match ON users USING gin (eql_v3.match_term(bio));
+ANALYZE users;
+```
+
+Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes).
+
+## Where to next
+
+
+
+ Drizzle over a Supabase Postgres connection.
+
+
+ The variant model behind each column type.
+
+
+ What each capability reveals, and how to choose.
+
+
+ The functional-index recipes behind every query on this page.
+
+
diff --git a/content/docs/integrations/index.mdx b/content/docs/integrations/index.mdx
index 6148388..6791c28 100644
--- a/content/docs/integrations/index.mdx
+++ b/content/docs/integrations/index.mdx
@@ -1,9 +1,65 @@
---
title: Integrations
-description: "Set up CipherStash with your platform, ORM, framework, auth provider, and runtime."
-type: tutorial
+description: "Set up CipherStash with your Postgres platform, your ORM, and your identity provider."
+type: concept
+components: [encryption, eql, platform]
+audience: [developer]
---
-This section is being built as part of the docs V2 overhaul ([CIP-3307](https://linear.app/cipherstash/issue/CIP-3307)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+CipherStash encrypts fields in your application and keeps them queryable in Postgres. Which integration you reach for depends on how your application talks to the database, not on what you are encrypting.
-Until it lands, current documentation lives in the [existing docs](/stack).
+Everything here writes the same ciphertext into the same [EQL](/reference/eql) columns, so these are not one-way doors. You can move a table from one path to another without re-encrypting it, and mix them in one codebase: an ORM for application queries, raw SQL for migrations.
+
+## Where your data lives
+
+
+
+ Wrap the Supabase client so `.eq()`, `.gt()`, and `.order()` keep working on encrypted columns. Row Level Security and Supabase Auth apply unchanged.
+
+
+ Neon, RDS, Aurora, Cloud SQL, or a container. EQL installs into any Postgres you can connect to, with no extension and no superuser.
+
+
+
+## How you query it
+
+
+
+ Concrete encrypted column types and capability-checked operators. Targets EQL v3.
+
+
+ Encrypted columns declared in `schema.prisma`. Targets EQL v2. Not classic Prisma.
+
+
+ Encrypt the value, insert it, cast the query operand. The SDK's whole surface is `encrypt` and `encryptQuery`.
+
+
+ Encrypt before a write, decrypt after a read. Every ORM supports that, and nothing is missing.
+
+
+
+
+Not using Postgres? The SDK still encrypts values that never touch a database, and non-Postgres stores like [DynamoDB](/stack/cipherstash/encryption/dynamodb), with no EQL involved. EQL is the Postgres surface, not the encryption itself.
+
+
+## Who is allowed to decrypt
+
+Reach for identity binding when a value should be decryptable only by the user it belongs to, rather than by anything holding the application's credentials. It needs an OIDC provider, registered once in the [Dashboard](https://dashboard.cipherstash.com/workspaces/_/oidc-providers).
+
+| Provider | Supported |
+| --- | --- |
+| Supabase Auth | Yes. See [Supabase Auth](/integrations/supabase/auth) |
+| Clerk | Yes |
+| Auth0 | Yes |
+| Okta | Yes |
+| Any OIDC issuer | Yes, if it publishes a discovery endpoint |
+
+See [provable access control](/solutions/provable-access) for what this buys you, and what it does not.
+
+## You cannot change the application
+
+[CipherStash Proxy](/reference/proxy) sits in front of Postgres and speaks EQL for you. Point your connection string at it. Because it speaks the Postgres wire protocol, the language your application is written in stops mattering.
+
+## Still deciding
+
+[Choose your stack](/get-started/choose-your-stack) walks the four decisions in order: SDK or Proxy, your Postgres, your ORM, and your identity provider.
diff --git a/content/docs/integrations/meta.json b/content/docs/integrations/meta.json
index 13995d5..074c8ed 100644
--- a/content/docs/integrations/meta.json
+++ b/content/docs/integrations/meta.json
@@ -1,5 +1,5 @@
{
"title": "Integrations",
"icon": "Blocks",
- "pages": ["..."]
+ "pages": ["index", "supabase", "..."]
}
diff --git a/content/docs/integrations/prisma-next.mdx b/content/docs/integrations/prisma-next.mdx
new file mode 100644
index 0000000..a5333fa
--- /dev/null
+++ b/content/docs/integrations/prisma-next.mdx
@@ -0,0 +1,146 @@
+---
+title: Prisma Next
+description: "Encrypted columns with Prisma Next: declare them in schema.prisma, and the migration system installs EQL for you."
+type: tutorial
+components: [encryption, eql]
+audience: [developer]
+integration:
+ category: orm
+ setup: code-only
+ pairsWith: [supabase, drizzle]
+verifiedAgainst:
+ prismaNext: "0.3.2"
+ eql: "2"
+---
+
+`@cipherstash/prisma-next` adds searchable, field-level encryption to [Prisma Next](https://www.npmjs.com/package/prisma-next). You declare encrypted columns in `schema.prisma`, and the framework's migration system installs the EQL bundle in the same sweep that creates your tables. There is no separate "install EQL" step.
+
+
+This integration targets **Prisma Next**, not the classic `@prisma/client` ORM. If you are on classic Prisma, there is no extension: use the [Stack SDK](/reference/stack) directly, encrypting before a write and decrypting after a read. Every ORM supports that.
+
+It also targets **EQL v2**, while the rest of these docs target [EQL v3](/reference/eql). Encrypted columns are `eql_v2_encrypted`, and free-text search is `cipherstashIlike` rather than v3's token containment.
+
+
+## Install
+
+```bash cta cta-type="install" example-id="install-prisma-next"
+npm install @cipherstash/stack @cipherstash/prisma-next
+```
+
+## Declare encrypted columns
+
+Six column types cover the encryptable surface: string, double, bigint, date, boolean, and JSON.
+
+```prisma filename="prisma/schema.prisma"
+model User {
+ id String @id
+ email cipherstash.EncryptedString()
+ salary cipherstash.EncryptedDouble()
+ birthday cipherstash.EncryptedDate()
+ preferences cipherstash.EncryptedJson()
+}
+```
+
+Register the extension pack so the framework knows how to plan migrations and encode values:
+
+```typescript filename="prisma-next.config.ts"
+import cipherstash from "@cipherstash/prisma-next/control"
+
+export default defineConfig({
+ // family, target, adapter, contract
+ extensionPacks: [cipherstash],
+})
+```
+
+## Wire up the client
+
+`cipherstashFromStack` builds the encryption client from your emitted contract, so there is no second schema to maintain alongside `schema.prisma`:
+
+```typescript filename="src/db.ts"
+import "dotenv/config"
+import { cipherstashFromStack } from "@cipherstash/prisma-next/stack"
+import postgres from "@prisma-next/postgres/runtime"
+import type { Contract } from "./prisma/contract.d"
+import contractJson from "./prisma/contract.json" with { type: "json" }
+
+const cipherstash = await cipherstashFromStack({ contractJson })
+
+export const db = postgres({
+ contractJson,
+ extensions: cipherstash.extensions,
+ middleware: cipherstash.middleware,
+})
+```
+
+## Migrate
+
+`migration apply` installs the EQL bundle alongside your schema:
+
+```bash
+npx stash auth login # once per developer
+npx prisma-next contract emit
+npx prisma-next migration plan --name initial
+npx prisma-next migration apply # installs EQL and creates your tables
+```
+
+This is the one place Prisma Next has an advantage over the other integrations: EQL lands inside your migration history, so a rebuilt database has it.
+
+## Read and write
+
+Values are wrapped in an envelope class on the way in, and decrypted explicitly on the way out. Plaintext is redacted from every implicit serialisation path (`toJSON`, `toString`, `util.inspect`), so a stray `console.log` cannot leak it.
+
+```typescript filename="src/queries.ts"
+import { EncryptedString, decryptAll } from "@cipherstash/prisma-next/runtime"
+import { db } from "./db"
+
+await db.orm.User.create({
+ id: "user-0",
+ email: EncryptedString.from("alice@example.com"),
+})
+
+const rows = await db.orm.User
+ .where((u) => u.email.cipherstashIlike("%@example.com"))
+ .all()
+
+await decryptAll(rows)
+console.log(await rows[0]?.email.decrypt())
+```
+
+`decryptAll` decrypts a whole result set in one round trip. A single field decrypts with `await row.email.decrypt()`.
+
+## Query operators
+
+Each operator is a method on the encrypted field, and the search config the column was declared with decides which ones it can answer:
+
+| Operator | Answers |
+| --- | --- |
+| `cipherstashEq`, `cipherstashNe` | Equality |
+| `cipherstashInArray`, `cipherstashNotInArray` | Set membership |
+| `cipherstashGt`, `cipherstashGte`, `cipherstashLt`, `cipherstashLte` | Ranges |
+| `cipherstashBetween`, `cipherstashNotBetween` | Bounded ranges |
+| `cipherstashIlike`, `cipherstashNotIlike` | Free-text match |
+| `cipherstashAsc`, `cipherstashDesc` | Ordering |
+| `cipherstashJsonbGet`, `cipherstashJsonbPathExists`, `cipherstashJsonbPathQueryFirst` | Encrypted JSON path access |
+
+Writes are coalesced: one SDK round trip per `(table, column)` group per query, rather than one per value.
+
+## Authenticating
+
+Local development uses your `stash` profile after `npx stash auth login`. In production, supply the `CS_*` environment variables instead. See [going to production](/stack/deploy/going-to-production).
+
+## Where to next
+
+
+
+ The EQL v3 ORM integration, with concrete encrypted column types.
+
+
+ Prisma Next over a Supabase Postgres connection.
+
+
+ Encrypt and decrypt directly, for classic Prisma or any other ORM.
+
+
+ What each capability reveals, and how to choose.
+
+
diff --git a/content/docs/integrations/supabase/auth.mdx b/content/docs/integrations/supabase/auth.mdx
new file mode 100644
index 0000000..c887539
--- /dev/null
+++ b/content/docs/integrations/supabase/auth.mdx
@@ -0,0 +1,137 @@
+---
+title: Supabase Auth
+description: "Federate the Supabase Auth session into CipherStash so encryption authenticates as the signed-in user — then, optionally, lock decryption to that user's identity."
+type: guide
+components: [encryption, platform]
+audience: [developer]
+verifiedAgainst:
+ stack: "1.0.0"
+ auth: "0.41.0"
+---
+
+Supabase Auth already knows who your user is. This page connects that identity to CipherStash in two layers you can adopt independently:
+
+1. **Federation** — exchange the user's Supabase session for a CipherStash token, so every encryption and decryption request authenticates *as that user* instead of as a shared service credential. This is the foundation.
+2. **Identity-bound encryption (lock context)** — an optional layer on top of federation that binds a value to the user's identity claim, so only that user can decrypt it — enforced by ZeroKMS, not by your application code.
+
+Federation is useful on its own. Lock context requires it. Start with federation; add lock context where per-user secrecy matters.
+
+## Register Supabase as an OIDC provider
+
+CipherStash needs to trust your Supabase project as an OIDC issuer before it will accept a Supabase session token. Add the provider once, in the CipherStash dashboard at [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) (the `_` resolves to whichever workspace you select). A Supabase project's issuer is:
+
+```
+https://.supabase.co/auth/v1
+```
+
+> **Good to know**: the dashboard's [Supabase integration](/integrations/supabase/fundamentals#connecting-your-project-to-cipherstash) can register this for you in one click while it's connected to your project over OAuth. Manual OIDC configuration is covered in the [auth reference](/reference/auth).
+
+## Federate the Supabase session
+
+Authenticate the `Encryption` client with an `OidcFederationStrategy`. It takes your workspace CRN and a `getJwt` callback that returns the user's **current** Supabase access token — the strategy calls it on first use and again on every re-federation, so return a fresh token each time rather than capturing one:
+
+```typescript title="lib/db.ts"
+import { createClient } from "@supabase/supabase-js"
+import { OidcFederationStrategy } from "@cipherstash/stack"
+import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase"
+
+const supabaseClient = createClient(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+
+// Return the current Supabase session token — re-invoked on every re-federation.
+const getJwt = async () => {
+ const { data: { session } } = await supabaseClient.auth.getSession()
+ if (!session) throw new Error("No active Supabase session")
+ return session.access_token
+}
+
+export const db = await encryptedSupabaseV3(supabaseClient, {
+ config: {
+ authStrategy: OidcFederationStrategy.create(process.env.CS_WORKSPACE_CRN!, getJwt),
+ },
+})
+```
+
+That's the whole integration. Every `db.from(...)` query now encrypts and decrypts under the signed-in user's identity, and your Supabase Auth session and RLS policies apply to the underlying request exactly as before.
+
+
+Run the federation strategy **server-side only**: a Next.js Route Handler or Server Action, or an Edge Function. Never in the browser, where it would expose the session token. For the Edge runtime, see [Edge Functions](/integrations/supabase/edge-functions).
+
+The federation endpoint issues no refresh token. When the CipherStash token expires, the strategy re-federates by calling `getJwt` again, and because `getJwt` reads from `supabaseClient.auth.getSession()`, supabase-js's own token refresh keeps it valid.
+
+
+## Identity-bound encryption (lock context)
+
+Federation authenticates *as* the user. **Lock context** goes further: it bakes a claim from the user's JWT (by default `sub`, which for Supabase Auth is the user's UUID) into the data key, so a value can only be decrypted by presenting the same identity. Even your own backend — holding valid workspace credentials — cannot decrypt another user's locked values.
+
+
+Lock context requires the client to be authenticated with `OidcFederationStrategy` (above). It cannot be used with `AccessKeyStrategy`, which authenticates a *service*, not a user — there is no user `sub` claim to bind to. Lock context also requires a Business or Enterprise workspace plan.
+
+
+Attach a lock context to any [`encryptedSupabaseV3`](/reference/stack/supabase) query with `.withLockContext()`. The query builder takes a `LockContext` **instance**. `sub` is the Supabase user's UUID:
+
+```typescript
+import { LockContext } from "@cipherstash/stack/identity"
+import { db } from "./lib/db"
+
+const lockContext = new LockContext({ context: { identityClaim: ["sub"] } })
+
+// Insert — the value is bound to the signed-in user's `sub` claim
+await db.from("patients")
+ .insert({ email: "alice@example.com", name: "Alice Chen" })
+ .withLockContext(lockContext)
+
+// Read — the same user must be authenticated
+const { data } = await db.from("patients")
+ .select("id, email, name")
+ .eq("email", "alice@example.com")
+ .withLockContext(lockContext)
+```
+
+
+The query builder's `.withLockContext()` takes a `LockContext` instance. The core `Encryption` client's operations take a plain object instead, as `.withLockContext({ identityClaim: ["sub"] })`. The two surfaces differ.
+
+
+ZeroKMS resolves the claim's *value* from the token that authenticated the request — the federated Supabase identity — so no separate identify step or per-operation token is needed. Reading a locked value without a matching context, or as a different user, fails with an encryption error regardless of what RLS allows.
+
+
+Earlier releases used `new LockContext().identify(jwt)` to fetch a per-operation token. `identify()` and `getLockContext()` are **deprecated** — per-operation CTS tokens were removed in protect-ffi 0.25. The `LockContext` constructor itself is not deprecated. Authenticate the client with `OidcFederationStrategy` and pass the context straight to `.withLockContext()`, as above.
+
+
+## Where it fits
+
+- **Per-user data** (a user's own medical history, messages, documents): lock to `sub`. The row is useless to anyone but its owner — including operators with database access and your own service role.
+- **Shared / tenant data** (a support queue, org-wide records): don't lock it, or you'll be unable to decrypt it outside a user session. Federation alone still authenticates access as the user; RLS scopes which rows they see.
+- **Server-side jobs** that must read locked data need the owning user's session — by design. If a background job must read a field, that field shouldn't be identity-locked.
+
+## Errors
+
+Both federation and lock context surface failures as values, not throws.
+
+| Scenario | What you see |
+| --- | --- |
+| Provider not registered with the workspace | Federation fails — register the Supabase OIDC issuer first |
+| Expired or invalid Supabase session | `getJwt` throws / returns no session; federation cannot proceed |
+| CipherStash token expired mid-request | Strategy re-federates automatically via `getJwt` |
+| Decrypting another user's locked value | `encryptionError` on the [query response](/reference/stack/supabase#responses-and-errors) |
+
+The strategy's own factory and token errors follow the `@cipherstash/auth` `Result` shape (`failure.type`); see the [auth reference](/reference/auth).
+
+## Where to next
+
+
+
+ The concept: federation, lock contexts, and what identity binding proves.
+
+
+ Run the federation strategy server-side in Supabase Edge Functions.
+
+
+ `.withLockContext()` and `.audit()` on the query builder.
+
+
+ OIDC providers, federation strategies, and access keys.
+
+
diff --git a/content/docs/integrations/supabase/edge-functions.mdx b/content/docs/integrations/supabase/edge-functions.mdx
new file mode 100644
index 0000000..c4e086f
--- /dev/null
+++ b/content/docs/integrations/supabase/edge-functions.mdx
@@ -0,0 +1,123 @@
+---
+title: Edge Functions
+description: "Run CipherStash encryption inside Supabase Edge Functions using the @cipherstash/stack WebAssembly build, with the CipherStash token cached across invocations in an HTTP-only cookie."
+type: guide
+components: [encryption, platform]
+audience: [developer]
+verifiedAgainst:
+ stack: "0.18.0"
+ auth: "0.41.0"
+---
+
+Encryption and decryption must run somewhere your keys and credentials are safe — never in the browser. Supabase Edge Functions are that place: server-side compute next to your database. CipherStash ships a **WebAssembly build** of the SDK for exactly this runtime, so the same encrypt/decrypt code runs in Deno with no native bindings.
+
+## Why the wasm build
+
+Supabase Edge Functions run on Deno, which loads npm packages through the `node` compatibility layer. The SDK's default entry resolves to native NAPI bindings that don't exist in that runtime, so the edge uses a dedicated entry: `@cipherstash/stack/wasm-inline`. It embeds the WebAssembly module as base64 inside the JS bundle — no separate `.wasm` fetch, no bundler plugins, no `static_files` config. The same applies to the auth package (`@cipherstash/auth/wasm-inline`).
+
+## Set up the function
+
+Map the `wasm-inline` entries in your function's `deno.json`:
+
+```jsonc title="supabase/functions/encrypt/deno.json"
+{
+ "imports": {
+ "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@^0.18/wasm-inline",
+ "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline",
+ "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies"
+ }
+}
+```
+
+Provide the `CS_*` credentials when you serve the function — locally via an env file:
+
+```bash
+supabase functions serve --env-file ./supabase/functions/.env.local
+```
+
+```sh title="supabase/functions/.env.local"
+CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:
+CS_CLIENT_ID=...
+CS_CLIENT_KEY=...
+CS_CLIENT_ACCESS_KEY=...
+```
+
+For deployed functions, set the same values as [Edge Function secrets](https://supabase.com/dashboard/project/_/settings/functions).
+
+## Encrypt and decrypt in a function
+
+Each invocation builds a fresh client, but the CipherStash service token is cached in an HTTP-only cookie via `cookieStore`, so only the first request per session pays the full round-trip to CipherStash:
+
+```typescript title="supabase/functions/encrypt/index.ts"
+import { Encryption, AccessKeyStrategy, encryptedTable, encryptedColumn } from "@cipherstash/stack/wasm-inline"
+import { cookieStore } from "@cipherstash/auth/cookies"
+
+const patients = encryptedTable("patients", {
+ email: encryptedColumn("email").equality(),
+})
+
+Deno.serve(async (req) => {
+ const responseHeaders = new Headers({ "content-type": "application/json" })
+
+ // Cache the CipherStash token across invocations in an HTTP-only cookie
+ const authStrategy = AccessKeyStrategy.create(
+ Deno.env.get("CS_WORKSPACE_CRN")!,
+ Deno.env.get("CS_CLIENT_ACCESS_KEY")!,
+ { store: cookieStore({ request: req, responseHeaders }) },
+ )
+ if (authStrategy.failure) {
+ return Response.json({ error: authStrategy.failure.type }, { status: 500, headers: responseHeaders })
+ }
+
+ const client = await Encryption({
+ schemas: [patients],
+ config: {
+ authStrategy: authStrategy.data,
+ clientId: Deno.env.get("CS_CLIENT_ID")!,
+ clientKey: Deno.env.get("CS_CLIENT_KEY")!,
+ },
+ })
+
+ const encrypted = await client.encrypt("alice@example.com", { column: patients.email, table: patients })
+ const decrypted = await client.decrypt(encrypted.data)
+
+ return Response.json({ ok: decrypted.data === "alice@example.com" }, { headers: responseHeaders })
+})
+```
+
+Note the `.failure` / `.data` result shape: on the wasm entry, `AccessKeyStrategy.create` returns a `Result` you unwrap (unlike the Node build, where it returns the strategy directly).
+
+## Per-user identity on the edge
+
+To encrypt as the signed-in Supabase user rather than a service credential, swap `AccessKeyStrategy` for `OidcFederationStrategy` — its `getJwt` callback returns the current Supabase session token. The mechanics and the identity-lock layer are covered in [Supabase Auth](/integrations/supabase/auth); the only edge-specific difference is the `wasm-inline` import and the `Result` unwrap:
+
+```typescript
+import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline"
+import { cookieStore } from "@cipherstash/auth/cookies"
+
+const authStrategy = OidcFederationStrategy.create(
+ Deno.env.get("CS_WORKSPACE_CRN")!,
+ () => getSupabaseSessionToken(req), // return the current session JWT
+ { store: cookieStore({ request: req, responseHeaders }) },
+)
+```
+
+## Caveats
+
+- **Cold start.** The first request pays the full path: WebAssembly compile, auth round-trip to CipherStash, and ZeroKMS dataset-key initialization. Subsequent requests reuse the cookie-cached token and only pay for the encrypt/decrypt work.
+- **Bundle size.** The inline wasm build is larger than a native binding (the module ships as base64 in the JS). That's the trade for zero runtime config — acceptable for an edge function that boots once per worker.
+- **Server-side only.** These credentials and the session token must never reach the browser. Keep this code in the Edge Function.
+
+## Where to next
+
+
+
+ Federation and identity-locked encryption — the full identity story.
+
+
+ The standard (Node) setup with the encryptedSupabaseV3 wrapper.
+
+
+ The core Encryption client API used here.
+
+
diff --git a/content/docs/integrations/supabase/fundamentals.mdx b/content/docs/integrations/supabase/fundamentals.mdx
new file mode 100644
index 0000000..962f55a
--- /dev/null
+++ b/content/docs/integrations/supabase/fundamentals.mdx
@@ -0,0 +1,187 @@
+---
+title: Fundamentals
+description: "Install and operate EQL on Supabase Postgres — migrations vs the SQL Editor, role grants, column types, functional indexes, RLS — and work with encrypted columns across the Supabase dashboard."
+type: guide
+components: [eql]
+audience: [developer]
+verifiedAgainst:
+ eql: "3.0.0"
+---
+
+EQL installs into Supabase Postgres as plain SQL — no extension packaging, no superuser, no separate Supabase build. This page covers the database side in full — how to install so it survives `supabase db reset`, the grants Supabase's roles need, choosing column types, indexing, and how encryption composes with Row Level Security — then walks through what encrypted columns look like across the Supabase dashboard.
+
+If you just want the fastest path from empty project to encrypted table, start with the [Quickstart](/integrations/supabase/quickstart); this page is the reference behind it.
+
+## Installing EQL
+
+The `stash` CLI installs EQL. It reads `DATABASE_URL`, detects that it points at Supabase, and installs directly against the database:
+
+```bash example-id="supabase-eql-install"
+npx stash eql install --eql-version 3
+```
+
+Pass `--database-url ` to override the connection for a single run without writing it to disk. The install is idempotent, so `--force` reinstalls over an existing schema, and `--dry-run` shows what would happen. Check what you ended up with:
+
+```bash
+npx stash eql status
+```
+
+`--eql-version` accepts `2` or `3` and **defaults to `2`**. Pass the flag until v3 becomes the default. The full flag set is in the [CLI reference](/reference/cli/eql).
+
+
+EQL v3 installs **directly against the database only**. `--migration`, `--drizzle`, `--latest`, and `--migrations-dir` are rejected when combined with `--eql-version 3`; they exist for the v2 install paths.
+
+The practical consequence is local resets. `supabase db reset` rebuilds your database from your migrations directory, so it drops a v3 install. Re-run `stash eql install` after each reset.
+
+
+## Grants
+
+Supabase's API roles need access to the `eql_v3` schema, because your table columns are typed with its domains and your queries resolve its operators and functions:
+
+```sql
+GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role;
+```
+
+Run this once after install (and after upgrades that add functions). Without `USAGE`, PostgREST queries against tables with encrypted columns fail with a permission error on the schema.
+
+You do **not** need to add `eql_v3` to the *Exposed schemas* list in the [API settings](https://supabase.com/dashboard/project/_/settings/api) for normal queries — your tables live in `public`. Expose it only if you want to call EQL functions directly over the REST API.
+
+## Choosing column types
+
+The column's domain type fixes what it can do, and the wrapper reads that type straight from the database, so there is no second declaration to keep in sync. The full type catalogue lives in the EQL reference — [numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json), [booleans](/reference/eql/booleans) — but the shape is always the same:
+
+| You query it with | Type the column as | EQL term |
+| --- | --- | --- |
+| Nothing (store/decrypt only) | `public.eql_v3_` | none |
+| `.eq()` / `.in()` | `public.eql_v3__eq` | `hm` |
+| Ranges, `.order()` | `public.eql_v3__ord` | `op` |
+| Free-text match | `public.eql_v3_text_match` | `bf` |
+| All three (text) | `public.eql_v3_text_search` | `hm`, `op`, `bf` |
+| Encrypted JSON | `public.eql_v3_json` | `sv` |
+
+Declare only the capabilities you use: every extra capability stores extra searchable material with defined leakage — see [Searchable encryption](/concepts/searchable-encryption).
+
+## Indexes
+
+Encrypted columns index through **ordinary functional indexes** over EQL's term-extractor functions. This is why EQL works on Supabase unchanged: the default ordering term indexes under Postgres's own `bytea_ops`, so no custom operator class is needed, just `CREATE INDEX`, which any migration can run:
+
+```sql
+CREATE INDEX patients_email_eq ON patients USING hash (eql_v3.eq_term(email));
+CREATE INDEX patients_dob_ord ON patients USING btree (eql_v3.ord_term(date_of_birth));
+CREATE INDEX patients_name_match ON patients USING gin (eql_v3.match_term(name));
+ANALYZE patients;
+```
+
+Add them once a table has real row counts (thousands, not dozens). Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes) — all of it applies to Supabase verbatim.
+
+
+Creating a custom operator class needs superuser, which Supabase does not grant. The EQL installer detects this and **disables the ORE-backed domains** (`_ord_ore`, `text_search_ore`), which then raise `feature_not_supported` on the first value written. Use `_ord` and `text_search`, whose CLLW OPE ordering term indexes under the default btree operator class. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers).
+
+
+## Row Level Security
+
+Encrypted columns and RLS compose — they solve different problems:
+
+- **RLS** decides *which rows* a role may query. It's enforced in the database, on whatever the database stores.
+- **Encryption** decides *whether a value can be read at all*. Keys stay outside the database, so anything that bypasses RLS — a leaked `service_role` key, an over-broad policy, a stolen backup, direct disk access — yields ciphertext, not plaintext.
+
+Keep writing policies exactly as before; the `encryptedSupabaseV3` wrapper goes through your own supabase-js client, so the caller's JWT and your policies apply to every query. Policies can freely reference *non-encrypted* columns (`user_id`, `tenant_id`, timestamps). Policies **cannot usefully compare encrypted values to plaintext** — the database never sees plaintext, so keep RLS predicates on plaintext columns and encrypted-value filtering in your queries.
+
+One practical pattern: leave your row-ownership predicate on a plaintext `user_id uuid` column, and encrypt the *contents* of the row. RLS scopes the rows to `auth.uid()`; CipherStash makes their contents unreadable to everything except your app. To go one step further and make a row's contents decryptable *only* by its owner — even to your own service role — see [Supabase Auth](/integrations/supabase/auth).
+
+## Connections and pooling
+
+The `encryptedSupabaseV3` wrapper talks to Supabase over PostgREST (your existing `SUPABASE_URL`), so connection pooling concerns don't change — nothing about encryption touches the connection layer. It does open one direct Postgres connection at startup, over `DATABASE_URL`, to introspect your encrypted columns. If you also connect to the database directly (migrations, [CipherStash Proxy](/reference/proxy), or an ORM like [Drizzle](/integrations/drizzle)), use the same connection strings you'd use without encryption; EQL is passive SQL and has no session requirements. The direct-connection path is covered in [Data layer](/integrations/supabase/orms).
+
+## Working in the dashboard
+
+Encrypted columns live inside your normal Supabase dashboard — no separate tool. Here's what to expect in each part of it.
+
+### Table Editor
+
+Encrypted columns show **ciphertext payloads**, not plaintext. A `patients` row looks like:
+
+| id | email | name | plan |
+| --- | --- | --- | --- |
+| 1 | `{"v": 3, "i": {"t": "patients", "c": "email"}, "c": "mBbKmsMM%bK#…", "hm": "9c8ec1d2…"}` | `{"v": 3, "i": …, "c": "x7Qq…", "bf": [42, 1290, …]}` | `standard` |
+
+This is the point, not a limitation: what the Table Editor shows is exactly what a backup, a replication stream, a leaked `service_role` key, or a curious operator sees. The payload structure (`v`, `i`, `c`, and the index terms) is documented in [EQL core concepts](/reference/eql/core-concepts) — none of it reveals the plaintext.
+
+Practical consequences:
+
+- **Reading data** happens through your app (or any client using the [Stack SDK](/reference/stack)); the dashboard can't decrypt, because the keys never go near it.
+- **Hand-editing an encrypted cell** in the Table Editor will produce a value that fails the column's `CHECK` constraint or fails to decrypt — treat encrypted columns as read-only in the dashboard.
+- **Filtering and sorting** on encrypted columns in the Table Editor UI operates on the raw payloads, so it won't return meaningful results. Query through your app instead.
+
+### Creating encrypted columns
+
+Use the **SQL Editor** for schema work on encrypted columns:
+
+```sql
+ALTER TABLE patients ADD COLUMN tax_id public.eql_v3_text_eq;
+```
+
+The `eql_v3_*` domain types are user-defined types, and the Table Editor's column-type picker doesn't reliably surface custom domains — SQL is the dependable path, and it's what your migrations should contain anyway.
+
+### SQL Editor
+
+Everything in the [EQL reference](/reference/eql) can be run from the SQL Editor — installing EQL, creating indexes, `EXPLAIN`-ing query plans. Two checks worth knowing:
+
+```sql
+-- Is EQL installed, and which version?
+SELECT eql_v3.version();
+
+-- Which columns are encrypted?
+SELECT table_name, column_name, udt_name
+FROM information_schema.columns
+WHERE udt_schema = 'eql_v3';
+```
+
+### API settings
+
+Normal operation needs **no** API-settings changes: your tables are in the `public` schema, and the wrapper's queries are ordinary PostgREST calls. The `eql_v3` schema needs role *grants* (see [Grants](#grants) above), but does **not** need to be in the *Exposed schemas* list unless you want to call EQL functions directly over REST.
+
+## Connecting your project to CipherStash
+
+Connecting is optional — you can set the `CS_*` credentials by hand ([Quickstart](/integrations/supabase/quickstart#install-the-sdk-and-connect-your-workspace)). The CipherStash dashboard's first-class Supabase integration at [dashboard.cipherstash.com](https://dashboard.cipherstash.com) automates it:
+
+
+
+### Connect via OAuth
+
+Authorize CipherStash against your Supabase organization. The integration requests read-only scopes (`projects:read`, `database:read`) — it never sees your database secrets.
+
+
+### Pick a project and run the readiness checks
+
+The setup hub verifies EQL is installed (`eql_v3.version()`), checks for encrypted columns, and flags anything missing.
+
+
+### Configure identity (optional)
+
+One click registers your project's Supabase Auth issuer (`https://.supabase.co/auth/v1`) with your workspace, enabling [identity-aware encryption](/integrations/supabase/auth).
+
+
+### Copy your environment
+
+The hub emits the `CS_*` credentials block for your app's environment, ready to pair with your existing `SUPABASE_URL` and keys.
+
+
+
+## Where to next
+
+
+
+ The end-to-end walkthrough this page backs.
+
+
+ Full indexing recipes and EXPLAIN verification.
+
+
+ Federate the session and lock decryption to the authenticated user.
+
+
+ Adopt encryption column-by-column on a live table.
+
+
diff --git a/content/docs/integrations/supabase/index.mdx b/content/docs/integrations/supabase/index.mdx
index 5ce53db..6d20c8e 100644
--- a/content/docs/integrations/supabase/index.mdx
+++ b/content/docs/integrations/supabase/index.mdx
@@ -1,20 +1,125 @@
---
-title: Supabase
-description: "Searchable, application-level encryption for your Supabase project — encrypt in your app, query in Postgres."
-type: tutorial
+title: Overview
+description: "Searchable, application-level encryption for your Supabase project: encrypt sensitive fields in your app before they reach Postgres, and keep them queryable with the Supabase.js calls you already use."
+type: concept
components: [encryption, eql, platform]
-audience: [developer]
+audience: [developer, ciso]
integration:
category: platform
setup: dashboard-required
- pairsWith: [drizzle, prisma-next, clerk, nextjs]
+ pairsWith: [drizzle, prisma-next, clerk]
+verifiedAgainst:
+ eql: "3.0.0"
+ stack: "0.18.0"
---
-CipherStash adds application-level encryption to your Supabase project:
-sensitive fields are encrypted in your application before they reach Postgres,
-and stay queryable with the same Supabase.js calls you already use.
+import { faqPrivacy, faqScaling, faqKms, faqRlsVsEncryption, faqFreeTier } from "@/components/faq/shared";
-This page is being rebuilt as part of the docs V2 overhaul
-([CIP-3328](https://linear.app/cipherstash/issue/CIP-3328)). Until it lands,
-the current Supabase integration guide lives at
-[CipherStash + Supabase](/stack/cipherstash/supabase).
+CipherStash adds application-level encryption to your Supabase project. Sensitive fields (names, emails, health records, anything you choose) are encrypted inside your application before they ever reach Postgres, so plaintext never touches your database, its backups, or its replication streams. CipherStash never sees your data or your keys.
+
+Unlike ordinary field encryption, the ciphertext stays **fully queryable**. You install [Encrypt Query Language (EQL)](/reference/eql) on your Supabase database, then declare each protected column with the encrypted type that matches the operations you need, then query it with the same `.eq()`, `.order()`, and text-search calls you use today.
+
+
+
+ Empty project to an encrypted, searchable table in about fifteen minutes.
+
+
+ Install and operate EQL on Supabase Postgres, and work with encrypted columns in the dashboard.
+
+
+
+## How it works
+
+CipherStash encrypted search is built on the [Encrypted Query Language (EQL)](/reference/eql), which can be installed into any Supabase database. Like regular Postgres, EQL provides a set of common data types and functions, but is designed for working with encrypted data.
+
+Use EQL `base` types to encrypt table columns containing dates, numbers, text, or JSON. Type modifiers let you specify how you want to query data stored in a column of that type:
+
+- Use `date_eq` to filter encrypted dates with `WHERE`
+- Use `integer_ord` to filter and sort encrypted integers with `WHERE` and `ORDER BY`
+- Use `text_search` for encrypted text search: containment matching (like `LIKE '%term%'`), `WHERE` clauses, and `ORDER BY`
+
+Most types also support `JOIN`, `GROUP BY`, and `UNION` queries. See the [EQL reference](/reference/eql) for a full list.
+
+
+ `text_search` match is *containment* (`LIKE '%term%'`-style), not full SQL `LIKE`: there are no wildcards or anchoring, and it can occasionally return a false positive. Use `text_eq` (`=`) for exact matching.
+
+
+Declare EQL columns in SQL, either in the Supabase Dashboard's SQL Editor or in a migration:
+
+```sql filename="migration.sql"
+CREATE TABLE users (
+ id serial,
+ email public.eql_v3_text_search,
+ dob public.eql_v3_date_ord
+)
+```
+
+EQL's column types are Postgres domains, and the Dashboard's Table Editor does not reliably offer custom domains in its column-type picker, so SQL is the dependable path. It is also what your migrations should contain either way.
+
+## Built for the Supabase stack
+
+CipherStash layers onto the tools you already use. Each area has a page that goes deeper:
+
+- **[Row Level Security](/integrations/supabase/fundamentals#row-level-security):** RLS controls *who can query* your data; CipherStash controls *whether it can be read at all*. Run both, so a bypassed policy, a leaked `service_role` key, or a stolen backup yields only ciphertext.
+- **[Quickstart](/integrations/supabase/quickstart):** encrypt in your app, then store and query through the same Supabase.js client with the `.eq()` and `.order()` calls you use today.
+- **[Supabase Auth](/integrations/supabase/auth):** federate the signed-in session so encryption authenticates as the user, and optionally lock decryption to their identity.
+- **[Edge Functions](/integrations/supabase/edge-functions):** run encryption server-side through the WebAssembly build.
+- **[Data layer](/integrations/supabase/orms):** first-class support for Prisma, Drizzle, and Next.js alongside Supabase.
+- **[Key management and audit logging](/security/audit-logging):** per-value keys and rotation through CipherStash ZeroKMS, a decryption access trail out of the box, optional Bring Your Own Key, and FIPS and SOC 2 compliance.
+
+## FAQ
+
+
+ Yes. It integrates with Supabase Auth and runs alongside RLS. It
+ complements them; it does not replace them.
+ >
+ ),
+ },
+ faqRlsVsEncryption,
+ {
+ title: "Do I have to change how I write queries?",
+ answer: (
+ <>
+ No. Query encrypted columns with the same Supabase.js calls you use
+ today.
+ >
+ ),
+ },
+ {
+ title: "What does migration look like?",
+ answer: (
+ <>
+ Install EQL on your Supabase database, choose which columns to protect,
+ then set their encryption type. Encrypt values in your app before
+ saving them, and update columns one at a time.
+ >
+ ),
+ },
+ faqKms,
+ faqScaling,
+ faqFreeTier,
+ ]}
+/>
+
+## Where to next
+
+
+
+ The end-to-end walkthrough: install EQL, wrap your client, query encrypted data.
+
+
+ Federate the Supabase session and lock decryption to the authenticated user.
+
+
+ Run encryption server-side in Supabase Edge Functions.
+
+
+ The complete wrapper API: every filter, response shapes, and how it works.
+
+
diff --git a/content/docs/integrations/supabase/meta.json b/content/docs/integrations/supabase/meta.json
index b4690bf..02d5b3e 100644
--- a/content/docs/integrations/supabase/meta.json
+++ b/content/docs/integrations/supabase/meta.json
@@ -1,5 +1,12 @@
{
"title": "Supabase",
"icon": "Supabase",
- "pages": ["..."]
+ "pages": [
+ "index",
+ "quickstart",
+ "fundamentals",
+ "orms",
+ "auth",
+ "edge-functions"
+ ]
}
diff --git a/content/docs/integrations/supabase/orms.mdx b/content/docs/integrations/supabase/orms.mdx
new file mode 100644
index 0000000..0b9a14c
--- /dev/null
+++ b/content/docs/integrations/supabase/orms.mdx
@@ -0,0 +1,71 @@
+---
+title: Data layer
+description: "Two ways to reach your Supabase data with CipherStash: the encryptedSupabaseV3 wrapper over PostgREST, or a direct Postgres connection through an ORM like Drizzle or Prisma."
+type: guide
+components: [encryption, eql]
+audience: [developer]
+---
+
+There are two ways an application talks to a Supabase database, and CipherStash rides along on both. Which one you use decides where encryption plugs in — so start here before reaching for the ORM-specific guide.
+
+## Two paths to your data
+
+| | PostgREST path | Direct-connection path |
+| --- | --- | --- |
+| **Client** | `supabase-js` (+ `encryptedSupabaseV3`) | Drizzle, Prisma, or raw `pg` |
+| **Transport** | Supabase's REST API (PostgREST) | A Postgres connection (pooler or direct) |
+| **How encryption plugs in** | The [`encryptedSupabaseV3`](/reference/stack/supabase) wrapper encrypts/decrypts around each request | The [Stack SDK](/reference/stack) encrypts values in your app; EQL-typed columns store and search them |
+| **Best for** | Supabase-native apps, Edge Functions, RLS via the user's JWT | Existing ORM codebases, migrations, complex joins |
+
+Both write the **same ciphertext into the same EQL columns** — the [Fundamentals](/integrations/supabase/fundamentals) database setup is identical either way. The difference is only in how your app connects and where the encrypt/decrypt step lives. You can even mix them: an `encryptedSupabaseV3` client for app queries and Drizzle for migrations against the same tables.
+
+The [Quickstart](/integrations/supabase/quickstart) covers the PostgREST path end-to-end. This page covers the direct-connection path and the Supabase-specific glue it needs.
+
+## Using an ORM
+
+The ORM integrations are **database-agnostic** — Supabase is Postgres, so the encryption mechanics are the same as anywhere else. Follow the canonical guide for your ORM, then apply the Supabase connection notes below:
+
+
+
+ Encrypted column types, query operators, and EQL migrations with Drizzle.
+
+
+ Encrypted fields and searchable queries with Prisma.
+
+
+
+## Connecting to Supabase Postgres
+
+An ORM connects over a Postgres connection string, not the REST API. Supabase gives you two, and the choice matters for a serverless or edge deployment:
+
+- **Direct connection** (port `5432`) — one long-lived connection to the database. Use it for migrations and for long-running servers. Not suitable for serverless functions, which open a connection per invocation.
+- **Supavisor pooler** — a connection pooler in front of Postgres:
+ - **Transaction mode** (port `6543`) — a pooled connection per transaction. Use this for serverless / edge / Next.js Route Handlers, where connections are short-lived and numerous.
+ - **Session mode** (port `5432` via the pooler) — pooled but session-scoped, for clients that need session features.
+
+Nothing about encryption changes the connection layer: EQL is passive SQL and the Stack SDK encrypts in your app, so use the **same connection string you'd use without CipherStash**. Point your migration tooling at the direct connection and your app at the transaction pooler, exactly as Supabase recommends.
+
+> **Good to know**: run EQL migrations through the direct connection (or Supabase's migrations directory), not the transaction pooler — DDL and the EQL install script want a real session. See [Fundamentals → Installing EQL](/integrations/supabase/fundamentals#installing-eql).
+
+## Row Level Security on the direct path
+
+RLS composes differently depending on which connection role you use:
+
+- The **`encryptedSupabaseV3` / PostgREST path** carries the end user's JWT, so `auth.uid()` and your policies apply automatically.
+- The **direct-connection path** typically authenticates as a privileged role (a pooler connection string for `postgres` or `service_role`), which **bypasses RLS**. If you rely on RLS for row scoping, either connect as the `authenticated` role with the user's JWT (setting `request.jwt.claims`), or enforce that scoping in your ORM query.
+
+Either way, encryption is unaffected: values are ciphertext regardless of which role reads them. RLS decides *which rows* are returned; CipherStash decides *whether their contents can be read at all* — see [Fundamentals → Row Level Security](/integrations/supabase/fundamentals#row-level-security).
+
+## Where to next
+
+
+
+ The PostgREST path end-to-end with encryptedSupabaseV3.
+
+
+ Database setup, grants, indexes, and RLS — shared by both paths.
+
+
+ Encrypt at the connection layer instead of in the app — an option for some ORM setups.
+
+
diff --git a/content/docs/integrations/supabase/quickstart.mdx b/content/docs/integrations/supabase/quickstart.mdx
new file mode 100644
index 0000000..bf80b25
--- /dev/null
+++ b/content/docs/integrations/supabase/quickstart.mdx
@@ -0,0 +1,179 @@
+---
+title: Quickstart
+description: "Take an empty Supabase project to an encrypted, searchable table: install EQL, declare encrypted columns, wrap your Supabase client, and query with the calls you already use."
+type: tutorial
+components: [encryption, eql, platform]
+audience: [developer]
+integration:
+ category: platform
+ setup: dashboard-required
+ pairsWith: [drizzle, prisma-next, clerk]
+verifiedAgainst:
+ eql: "3.0.0"
+ stack: "1.0.0"
+---
+
+This tutorial takes an empty Supabase project to an encrypted, searchable table. It takes about fifteen minutes. For what CipherStash is and why you'd add it, start with the [overview](/integrations/supabase).
+
+## How it fits together
+
+Two pieces, one on each side of your Supabase connection:
+
+- **[EQL](/reference/eql)** installs into your Supabase Postgres as plain SQL. It provides encrypted column types, such as `public.eql_v3_text_eq` and `public.eql_v3_date_ord`, where the type name declares what queries the column supports.
+- **[`@cipherstash/stack`](/reference/stack)** runs in your application. Its [`encryptedSupabaseV3` wrapper](/reference/stack/supabase) encrypts values before they're written, encrypts filter terms before they're compared, and decrypts results. It wraps your existing supabase-js client, so Supabase Auth and Row Level Security apply unchanged.
+
+
+
+
+### Install EQL on your database
+
+Point `DATABASE_URL` at your Supabase project, then install EQL with the `stash` CLI:
+
+```bash cta cta-type="install" example-id="supabase-quickstart-eql-install"
+npx stash eql install --eql-version 3
+```
+
+It detects Supabase from the connection string, needs no superuser, and completes in seconds, creating the `eql_v3` schema for functions and operators, and the encrypted column types in `public`. Then grant your Supabase roles access:
+
+```sql
+GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role;
+GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role;
+```
+
+`--eql-version` defaults to `2`, so pass the flag until v3 becomes the default. [Fundamentals](/integrations/supabase/fundamentals#installing-eql) covers the rest, including why a v3 install does not survive `supabase db reset`.
+
+
+
+
+### Create a table with encrypted columns
+
+The column type declares what each column can do. Equality lookups, free-text matching, and range or ordering queries are different capabilities. Declare only what you query, because each capability stores an extra index term alongside the ciphertext:
+
+```sql
+CREATE TABLE patients (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email public.eql_v3_text_eq NOT NULL, -- exact lookup
+ name public.eql_v3_text_match, -- free-text match
+ date_of_birth public.eql_v3_date_ord, -- ranges and ORDER BY
+ plan text -- not sensitive: ordinary column
+);
+```
+
+Which type fits which column, and what each stores, is covered by the [EQL type pages](/reference/eql/core-concepts).
+
+
+Supabase runs the EQL installer as a non-superuser, so the ORE-backed domains (`_ord_ore`, `text_search_ore`) are disabled on Supabase and raise if you use them. Use `_ord` and `text_search`, which order under Postgres's default btree operator class. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers).
+
+
+
+
+
+### Install the SDK and connect your workspace
+
+```sh
+npm install @cipherstash/stack @supabase/supabase-js
+```
+
+Sign up at [dashboard.cipherstash.com](https://dashboard.cipherstash.com), create a workspace, and set its credentials alongside your Supabase keys. `DATABASE_URL` is your project's Postgres connection string, which the wrapper uses to introspect your encrypted columns:
+
+```sh
+CS_WORKSPACE_CRN=...
+CS_CLIENT_ID=...
+CS_CLIENT_KEY=...
+CS_CLIENT_ACCESS_KEY=...
+SUPABASE_URL=https://.supabase.co
+SUPABASE_ANON_KEY=...
+DATABASE_URL=postgresql://postgres:...@db..supabase.co:5432/postgres
+```
+
+> **Good to know**: the CipherStash dashboard's Supabase integration can generate this whole block for you. It connects to your project over OAuth, runs readiness checks, and emits the `CS_*` credentials. It's optional; the manual block above works everywhere. See [Fundamentals → Connecting your project](/integrations/supabase/fundamentals#connecting-your-project-to-cipherstash).
+
+
+
+
+### Wrap your Supabase client
+
+There is no schema to declare. `encryptedSupabaseV3` introspects the database over `DATABASE_URL`, finds the columns typed as EQL domains, and derives each one's encryption config from its type.
+
+```typescript title="lib/db.ts"
+import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase"
+
+export const db = await encryptedSupabaseV3(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+```
+
+
+
+
+### Write and query encrypted data
+
+```typescript
+import { db } from "./lib/db"
+
+// Insert — encrypted columns are encrypted before the request leaves your app
+await db.from("patients").insert({
+ email: "alice@example.com",
+ name: "Alice Chen",
+ date_of_birth: "1987-04-12",
+ plan: "standard",
+})
+
+// Exact lookup on an encrypted column
+const { data } = await db
+ .from("patients")
+ .select("id, email, name")
+ .eq("email", "alice@example.com")
+
+// Free-text match: encrypted token containment, not SQL LIKE
+await db.from("patients").select("id, name").contains("name", "Alice Chen")
+
+// Range and newest-first on an encrypted date
+await db
+ .from("patients")
+ .select("id, name, date_of_birth")
+ .gt("date_of_birth", "1980-01-01")
+ .order("date_of_birth", { ascending: false })
+ .limit(20)
+```
+
+
+`.like()` and `.ilike()` throw on encrypted columns. Free-text matching is `.contains()`, which compares encrypted token sets rather than SQL wildcards. See the [wrapper reference](/reference/stack/supabase#there-is-no-like).
+
+
+The full method surface, including filters, upserts, error shapes, and identity-locked queries, is in the [`encryptedSupabaseV3` reference](/reference/stack/supabase).
+
+
+
+
+### See what the database sees
+
+Open the Table Editor and look at the `patients` rows: every encrypted cell is a ciphertext payload, not plaintext. That's what a backup, a replication stream, or anyone bypassing your app sees, including a leaked `service_role` key. [Fundamentals → Working in the dashboard](/integrations/supabase/fundamentals#working-in-the-dashboard) walks through what encrypted data looks like across the Supabase dashboard.
+
+
+
+
+## Production checklist
+
+- **Indexes** — at real row counts, add functional indexes for the capabilities you query. Two lines of SQL per column: [EQL indexes](/reference/eql/indexes).
+- **RLS** — keep your Row Level Security policies; they compose. RLS controls *who can query* rows, encryption controls *whether their contents can be read at all*. A bypassed policy or leaked key yields only ciphertext.
+- **Identity-locked decryption** — tie decryption to the logged-in Supabase Auth user: [Supabase Auth integration](/integrations/supabase/auth).
+- **Adopt incrementally** — you can encrypt one column at a time; nothing requires a big-bang migration. See [encrypting existing data](/guides/migration/encrypt-existing-data).
+
+## Go deeper
+
+
+
+ Migrations vs SQL Editor, grants, indexes, RLS, and the dashboard experience in detail.
+
+
+ Federate the Supabase session and lock decryption to the authenticated user.
+
+
+ Using Drizzle, Prisma, or a framework against Supabase with CipherStash.
+
+
+ The complete wrapper API: every filter, response shapes, and how it works.
+
+
diff --git a/content/docs/reference/stack/supabase.mdx b/content/docs/reference/stack/supabase.mdx
new file mode 100644
index 0000000..a75419c
--- /dev/null
+++ b/content/docs/reference/stack/supabase.mdx
@@ -0,0 +1,251 @@
+---
+title: Supabase wrapper
+description: "The canonical encryptedSupabaseV3 reference: connect-time introspection, the full query-builder surface, and how each filter maps to an encrypted index term."
+type: reference
+components: [encryption, eql]
+audience: [developer]
+verifiedAgainst:
+ stack: "1.0.0"
+ eql: "3.0.0"
+---
+
+`encryptedSupabaseV3` wraps a `@supabase/supabase-js` client so that queries on encrypted columns work like queries on plaintext ones. Mutations are encrypted before they leave your app, filter values are encrypted into the matching index term, and results are decrypted on the way back. You keep writing `.eq()`, `.gt()`, `.order()`, and the wrapper handles the encryption on both sides of every call.
+
+This page is the single source of truth for the wrapper's API. The [Supabase integration](/integrations/supabase) shows it in context; the [EQL reference](/reference/eql) covers the database side it queries against.
+
+## Setup
+
+`encryptedSupabaseV3` **introspects your database when you call it**. It reads the column types, finds the ones typed as EQL v3 domains, and derives each column's encryption config from the domain. The database already records which columns are encrypted and what they can do, so you do not declare it a second time.
+
+That makes it async, and it makes `.from()` take a table name and nothing else.
+
+```typescript
+import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase"
+
+const supabase = await encryptedSupabaseV3(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+
+const { data, error } = await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com")
+```
+
+Introspection needs a direct Postgres connection, which is a different credential from the Supabase URL and anon key. It reads `DATABASE_URL` from the environment, or `options.databaseUrl` if you pass it.
+
+```typescript
+const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey, {
+ databaseUrl: process.env.PG_URL,
+})
+```
+
+Pass an existing client instead of a URL and key when you already build one, for example to attach a Supabase Auth session:
+
+```typescript
+const supabase = await encryptedSupabaseV3(supabaseClient, {
+ databaseUrl: process.env.PG_URL,
+})
+```
+
+### Options
+
+| Option | Type | Purpose |
+| --- | --- | --- |
+| `databaseUrl` | `string` | Postgres connection string used for introspection. Defaults to `process.env.DATABASE_URL`. |
+| `config` | `ClientConfig` | Passed through to the encryption client. `eqlVersion` is forced to `3`. |
+| `schemas` | `Record` | Optional declared tables. Adds compile-time types and startup verification. |
+
+### `.from(tableName)`
+
+Returns a query builder that mirrors the supabase-js builder. Columns that are not EQL domains pass through untouched, so mixed plaintext and encrypted tables are the normal case.
+
+
+A table containing an EQL column that this SDK cannot model throws when you name it in `.from()`. The introspector identifies EQL domains by their Postgres comment, so an EQL column from a newer bundle than your SDK is detected, reported, and refused rather than silently read back as raw ciphertext.
+
+
+## Declaring columns in the database
+
+The column's domain type is the schema. Declare the capability you query on and nothing more, because each capability stores an extra index term alongside the ciphertext.
+
+```sql
+CREATE TABLE users (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email public.eql_v3_text_search, -- equality, ordering, free-text match
+ age public.eql_v3_integer_ord, -- equality, ordering
+ name text -- plaintext, untouched
+);
+```
+
+The capability model is the same one the database enforces through [EQL's domain variants](/reference/eql/core-concepts):
+
+| Domain | Enables | EQL term |
+| --- | --- | --- |
+| `public.eql_v3_` | Store and decrypt only | none |
+| `public.eql_v3__eq` | `.eq()`, `.neq()`, `.in()` | `hm` |
+| `public.eql_v3__ord` | `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.order()`, plus equality | `op` |
+| `public.eql_v3_text_match` | `.contains()` | `bf` |
+| `public.eql_v3_text_search` | All of the above, on text | `hm`, `op`, `bf` |
+| `public.eql_v3_json` | Encrypted JSON documents | `sv` |
+
+## Optional schemas: types and startup verification
+
+Without `schemas`, rows are `Record` and any table name is accepted. Declare a table and `.from()` returns a typed builder for it, with row types, filter-value types, and capability-narrowed methods. Declared tables are also verified against the live database before the client is handed back, so a column that was dropped or retyped fails at startup rather than at the first query.
+
+```typescript
+import { encryptedSupabaseV3 } from "@cipherstash/stack/supabase"
+import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
+
+const users = encryptedTable("users", {
+ email: types.TextSearch("email"),
+ age: types.IntegerOrd("age"),
+})
+
+const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey, {
+ schemas: { users },
+})
+
+// `.gt` is offered on `age`, and rejected on `email`, at compile time
+await supabase.from("users").select("id, email").gt("age", 21)
+```
+
+Each key in `schemas` must equal its table's name. Declaring a table changes what TypeScript knows about it, never how its values are encrypted: a declared and an introspected column build byte-identically.
+
+## Writing data
+
+`insert`, `update`, and `upsert` encrypt every encrypted column before the request leaves your process. All values in a batch are encrypted in a single ZeroKMS round trip, so large inserts do not multiply key-service calls.
+
+```typescript
+await supabase.from("users").insert({
+ email: "alice@example.com", // encrypted
+ age: 30, // encrypted
+ name: "Alice Chen", // plaintext column, passes through
+})
+
+await supabase.from("users").update({ age: 31 }).eq("email", "alice@example.com")
+
+await supabase.from("users").delete().eq("email", "alice@example.com")
+```
+
+`insert` and `upsert` accept a single row or an array. `upsert` supports `onConflict` and `ignoreDuplicates` as in supabase-js.
+
+## Reading data
+
+Encrypted columns in the result are decrypted before `data` is returned to you. `select('*')` and a no-argument `select()` both work: the wrapper expands them from the column list it introspected.
+
+```typescript
+const { data, error } = await supabase
+ .from("users")
+ .select("*")
+ .eq("email", "alice@example.com")
+```
+
+## Filters
+
+Every filter value on an encrypted column is encrypted client-side into the term that capability queries, then sent in place of the plaintext. The database only ever compares ciphertext. Filters on plaintext columns pass through to supabase-js unchanged.
+
+| Method | Requires | What the database compares |
+| --- | --- | --- |
+| `.eq()` / `.neq()` | `_eq`, `_ord`, or `text_search` | Equality terms |
+| `.in(col, values)` | `_eq`, `_ord`, or `text_search` | Each element encrypted separately |
+| `.gt()` `.gte()` `.lt()` `.lte()` | `_ord` or `text_search` | Ordering terms |
+| `.contains(col, value)` | `text_match` or `text_search` | Bloom-filter token containment |
+| `.is(col, null)` | any column | Passed through. SQL `NULL` is never encrypted |
+| `.match({ col: value })` | per column | Each pair applied as `.eq()` |
+| `.or(filters)` | per column | Encrypted values inside the filter string are encrypted in place |
+| `.not(col, op, value)` | per operator | Value is encrypted, operator passes through |
+| `.filter(col, op, value)` | per operator | Escape hatch, same as `.not()` |
+
+```typescript
+// Range on an _ord column
+await supabase.from("users").select("id, age").gt("age", 21)
+
+// Encrypted and plaintext filters compose freely
+await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com") // encrypted comparison
+ .eq("name", "Alice Chen") // ordinary supabase-js filter
+```
+
+### There is no `.like()`
+
+`.like()` and `.ilike()` **throw** on an encrypted column. SQL pattern matching is meaningless on ciphertext, and EQL's free-text search is token containment rather than wildcard matching. They still work on plaintext columns in the same table.
+
+```typescript
+// ❌ throws: "like" is not supported on encrypted column "email"
+await supabase.from("users").select("*").like("email", "%@example.com")
+
+// ✅ encrypted token containment
+await supabase.from("users").select("*").contains("email", "alice@example.com")
+```
+
+`.contains()` tests whether the stored value's encrypted token set contains the search string's. Matching is case-insensitive, and false positives are possible while false negatives are not.
+
+## Ordering and pagination
+
+`.order()` on an `_ord` or `text_search` column sorts on the encrypted ordering terms, which is exactly what that capability exists for. See [ordering encrypted values](/reference/eql/sorting). `.limit()`, `.range()`, `.single()`, `.maybeSingle()`, and `.csv()` behave as in supabase-js.
+
+```typescript
+await supabase.from("users").select("id, email, age").order("age", { ascending: false }).limit(20)
+```
+
+Ordering a column without an ordering term does not error. It sorts on the raw stored payload, which is meaningless.
+
+## Identity and audit
+
+Two methods have no supabase-js equivalent:
+
+```typescript
+await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com")
+ .withLockContext(lockContext)
+ .audit({ metadata: { reason: "support-lookup" } })
+```
+
+`.withLockContext(lockContext)` scopes every encrypt and decrypt in the query to an identity-locked context. See the [Supabase Auth integration](/integrations/supabase/auth) for wiring it to Supabase Auth sessions. `.audit(config)` attaches metadata to the query's decryption events in the [audit log](/security/audit-logging).
+
+`.abortSignal()`, `.throwOnError()`, and `.returns()` pass through as in supabase-js.
+
+## Responses and errors
+
+Awaiting the builder resolves to the supabase-js response shape, extended with encryption context:
+
+```typescript
+type EncryptedSupabaseResponse = {
+ data: T | null
+ error: EncryptedSupabaseError | null
+ count: number | null
+ status: number
+ statusText: string
+}
+
+type EncryptedSupabaseError = {
+ message: string
+ details?: string
+ hint?: string
+ code?: string
+ encryptionError?: EncryptionError // set when encrypt/decrypt failed, not the query
+}
+```
+
+Check `error.encryptionError` to distinguish an encryption failure (bad workspace credentials, unreachable ZeroKMS, a payload that fails validation) from an ordinary PostgREST error.
+
+## How it works
+
+The builder is deferred: method calls record intent, and nothing executes until you `await`. At that point the wrapper, in order:
+
+1. Encrypts mutation payloads, all rows in one ZeroKMS call
+2. Encrypts every filter value on an encrypted column into its index term
+3. Adds `::jsonb` casts to encrypted columns in the `select` list
+4. Replays the recorded calls onto the real supabase-js builder
+5. Decrypts encrypted columns in the response
+
+Because the wrapped client is your own `createClient(...)` instance, Supabase Auth sessions and **Row Level Security policies apply unchanged**. RLS decides which rows come back; encryption decides whether what is in them can be read. Database-side setup (installing EQL, column types, grants, indexes) is covered in [Supabase fundamentals](/integrations/supabase/fundamentals).
+
+## The v2 wrapper
+
+`encryptedSupabase` is the EQL v2 dialect and is unchanged. It is synchronous, takes `{ encryptionClient, supabaseClient }`, requires `.from(tableName, schema)` with the schema as a second argument, rejects `select('*')`, and uses `.like()` / `.ilike()` for free-text search. Pick the dialect by function: v2 targets `eql_v2_encrypted` columns, v3 targets the `public.eql_v3_*` domains. New work should use `encryptedSupabaseV3`.
diff --git a/content/docs/security/audit-logging.mdx b/content/docs/security/audit-logging.mdx
new file mode 100644
index 0000000..ae1bba0
--- /dev/null
+++ b/content/docs/security/audit-logging.mdx
@@ -0,0 +1,9 @@
+---
+title: Audit logging
+description: "Decryption-event logging: what's recorded, and how to attach query metadata."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3331](https://linear.app/cipherstash/issue/CIP-3331)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/proxy/audit).
diff --git a/content/docs/security/cts.mdx b/content/docs/security/cts.mdx
new file mode 100644
index 0000000..23e541d
--- /dev/null
+++ b/content/docs/security/cts.mdx
@@ -0,0 +1,9 @@
+---
+title: CTS
+description: "The CipherStash Token Service: exchanging identity-provider JWTs for tokens that gate decryption."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/kms/cts).
diff --git a/content/stack/cipherstash/encryption/dynamodb.mdx b/content/stack/cipherstash/encryption/dynamodb.mdx
index 76e2f44..5b672bc 100644
--- a/content/stack/cipherstash/encryption/dynamodb.mdx
+++ b/content/stack/cipherstash/encryption/dynamodb.mdx
@@ -3,6 +3,8 @@ title: DynamoDB
description: Encrypt and decrypt DynamoDB items with the encryptedDynamoDB helper from @cipherstash/stack, including bulk operations and HMAC equality queries.
---
+import { faqPrivacy, faqKms, faqFreeTier } from "@/components/faq/shared";
+
CipherStash provides a DynamoDB integration through `@cipherstash/stack/dynamodb`. The `encryptedDynamoDB` helper encrypts items before writing to DynamoDB and decrypts them after reading — it does not wrap the AWS SDK, so you keep full control of your DynamoDB operations.
## Installation
@@ -469,3 +471,44 @@ const queryResult = await docClient.send(new QueryCommand({
const decrypted = await dynamo.bulkDecryptModels(queryResult.Items ?? [], users)
```
+
+## FAQ
+
+
+ Yes, for exact equality. Each encrypted attribute stores an{" "}
+ __hmac term you match against, so you can look up by an
+ encrypted partition key, sort key, or GSI. Range, comparison, and
+ substring queries on encrypted attributes are not supported.
+ >
+ ),
+ },
+ {
+ title: "Do I have to change how I write DynamoDB operations?",
+ answer: (
+ <>
+ No. encryptedDynamoDB encrypts items before writing and
+ decrypts them after reading; it does not wrap the AWS SDK, so you keep
+ full control of your PutItem, GetItem, and Query calls.
+ >
+ ),
+ },
+ {
+ title: "What does adopting it look like?",
+ answer: (
+ <>
+ Install @cipherstash/stack, define an encrypted schema for
+ the attributes you want to protect, initialize the client, and encrypt
+ values before writing them. You can adopt it one table at a time.
+ >
+ ),
+ },
+ faqKms,
+ faqFreeTier,
+ ]}
+/>
diff --git a/next.config.mjs b/next.config.mjs
index 3ffa9e2..990a7b3 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -333,16 +333,6 @@ const config = {
destination: "/stack/cipherstash/kms",
permanent: false,
},
- // === Pre-publish placeholder for the v2 docs restructure ===
- // The v2 branch will host the Supabase docs at /docs/integrations/supabase,
- // but that branch isn't published yet. Until it ships, temporarily (307)
- // point the future URL at the current Supabase overview page so external
- // links to it resolve. Remove this once v2 owns /integrations/supabase.
- {
- source: "/integrations/supabase",
- destination: "/stack/cipherstash/supabase",
- permanent: false,
- },
];
},
async rewrites() {
diff --git a/scripts/validate-content-api.ts b/scripts/validate-content-api.ts
index 30d0b61..63bfed6 100644
--- a/scripts/validate-content-api.ts
+++ b/scripts/validate-content-api.ts
@@ -46,12 +46,24 @@ type Rule = {
fix: string;
/** Only apply the rule to files under these prefixes. */
scope: string[];
+ /**
+ * Files that legitimately name the API, and are exempt from this rule. Use
+ * only when a page documents a component that really does still ship it.
+ */
+ exempt?: string[];
};
const IDENTITY_SCOPE = ["content/docs/", "content/stack/"];
/** The legacy tree documents EQL v2 throughout; only the v2 IA must be v3-only. */
const V3_SCOPE = ["content/docs/"];
+/**
+ * `@cipherstash/prisma-next` really does target EQL v2 — its migrations create
+ * `eql_v2_encrypted` columns and call `eql_v2.add_search_config`. Its page must
+ * name those to be correct. Delete this when prisma-next moves to v3.
+ */
+const PRISMA_NEXT_IS_V2 = ["content/docs/integrations/prisma-next.mdx"];
+
const RULES: Rule[] = [
{
id: "lockcontext-identify",
@@ -81,14 +93,16 @@ const RULES: Rule[] = [
id: "eql-v2-encrypted-type",
pattern: /\beql_v2_encrypted\b/,
message: "The `eql_v2_encrypted` column type was removed in EQL 3.0.0.",
- fix: "Type the column with an EQL v3 domain variant, e.g. `public.text_eq`. See /reference/eql/core-concepts.",
+ fix: "Type the column with an EQL v3 domain variant, e.g. `public.eql_v3_text_eq`. See /reference/eql/core-concepts.",
scope: V3_SCOPE,
+ exempt: PRISMA_NEXT_IS_V2,
},
{
id: "eql-v2-schema-functions",
pattern: /\beql_v2\.\w+\s*\(/,
message: "The `eql_v2` schema was removed in EQL 3.0.0.",
fix: "Use the `eql_v3` equivalents, or the encrypted operators directly with a typed operand.",
+ exempt: PRISMA_NEXT_IS_V2,
scope: V3_SCOPE,
},
{
@@ -151,6 +165,7 @@ for (const dir of ["content/docs", "content/stack"]) {
for (const rule of RULES) {
if (!inScope(relative, rule)) continue;
+ if (rule.exempt?.includes(relative)) continue;
if (rule.pattern.test(text)) {
findings.push({
file: relative,
diff --git a/src/components/faq/index.tsx b/src/components/faq/index.tsx
new file mode 100644
index 0000000..ffd9ac6
--- /dev/null
+++ b/src/components/faq/index.tsx
@@ -0,0 +1,63 @@
+import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
+import { isValidElement, type ReactNode } from "react";
+
+export type FaqEntry = {
+ title: string;
+ answer: ReactNode;
+};
+
+/** Flatten a ReactNode to plain text for the FAQPage JSON-LD `text` field. */
+function nodeToText(node: ReactNode): string {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return "";
+ }
+ if (typeof node === "string" || typeof node === "number") {
+ return String(node);
+ }
+ if (Array.isArray(node)) {
+ return node.map(nodeToText).join("");
+ }
+ if (isValidElement(node)) {
+ return nodeToText((node.props as { children?: ReactNode }).children);
+ }
+ return "";
+}
+
+/**
+ * Renders a list of FAQ entries as an accordion and emits FAQPage structured
+ * data (JSON-LD) so the questions can surface as rich results in search.
+ *
+ * Compose `items` from the shared, product-wide entries in `./shared` plus any
+ * integration-specific entries authored inline, keeping generic answers DRY.
+ */
+export function Faq({ items }: { items: FaqEntry[] }) {
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: items.map((item) => ({
+ "@type": "Question",
+ name: item.title,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: nodeToText(item.answer).replace(/\s+/g, " ").trim(),
+ },
+ })),
+ };
+
+ return (
+ <>
+
+
+ {items.map((item) => (
+
+ {item.answer}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/components/faq/shared.tsx b/src/components/faq/shared.tsx
new file mode 100644
index 0000000..5d5378a
--- /dev/null
+++ b/src/components/faq/shared.tsx
@@ -0,0 +1,70 @@
+import type { FaqEntry } from "./index";
+
+/**
+ * Product-wide FAQ answers that are not specific to any one integration.
+ * Import the entries you need into an integration overview page and mix them
+ * with integration-specific `FaqEntry` objects authored inline.
+ */
+
+export const faqPrivacy: FaqEntry = {
+ title: "Can CipherStash ever see my data, or my encryption keys?",
+ answer: (
+ <>
+ No, never. Encryption and decryption occur in your application, and keys
+ are derived in your environment. Plaintext and keys never leave your
+ control and never reach CipherStash.
+ >
+ ),
+};
+
+export const faqScaling: FaqEntry = {
+ title: "How well does it scale?",
+ answer: (
+ <>
+ Latency stays flat as data grows: exact-match lookups hold at ~0.1 ms and
+ range queries at ~0.5 ms, from 10k to 10M rows on the{" "}
+
+ cipherstash/benches
+ {" "}
+ suite.
+ >
+ ),
+};
+
+export const faqKms: FaqEntry = {
+ title: "Do I need to run a KMS or key vault?",
+ answer: (
+ <>
+ No. Key management is built in through ZeroKMS. If you want to control the
+ root key, Bring Your Own Key (BYOK) lets you root it in your own KMS.
+ >
+ ),
+};
+
+export const faqRlsVsEncryption: FaqEntry = {
+ title: "I already use Row Level Security. Do I need this?",
+ answer: (
+ <>
+ RLS and CipherStash do different things, and they work best together. RLS
+ controls which rows someone can see, but the data itself is still
+ unencrypted, so if RLS is bypassed (a leaked key, a wrong policy, a
+ compromised database) the plaintext is exposed. CipherStash encrypts the
+ data and stores the keys separately, so even if someone gets around RLS,
+ the data stays safe. Use RLS to control access, and CipherStash to keep
+ the data protected.
+ >
+ ),
+};
+
+export const faqFreeTier: FaqEntry = {
+ title: "Is there a free tier?",
+ answer: (
+ <>
+ Yes, a free developer tier, so you can build encryption in from day one.
+ >
+ ),
+};
diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx
index f05ae7c..720e12b 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -4,6 +4,7 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
import { TrackedCodeBlock } from "@/components/code-block";
import { EqlVersion } from "@/components/eql-version";
+import { Faq } from "@/components/faq";
import { ZeroKmsRegions } from "@/components/zerokms-regions";
export function getMDXComponents(components?: MDXComponents): MDXComponents {
@@ -16,6 +17,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
Steps,
Step,
EqlVersion,
+ Faq,
ZeroKmsRegions,
...components,
};