From a27449b6516d80a53a3e7b62fa95aa837627a489 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 14:42:40 +1000 Subject: [PATCH 1/4] docs(concepts): add the RLS and TDE comparison page First real page in the Comparisons section. Explains how CipherStash relates to Postgres Row-Level Security (access control) and Transparent Data Encryption (at-rest, on disk): three different layers closing different threats, and how they compose. Written against the shipped v3 story; no em-dashes. --- content/docs/concepts/compare/meta.json | 2 +- content/docs/concepts/compare/rls-and-tde.mdx | 102 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 content/docs/concepts/compare/rls-and-tde.mdx diff --git a/content/docs/concepts/compare/meta.json b/content/docs/concepts/compare/meta.json index 76e9696..0590ff4 100644 --- a/content/docs/concepts/compare/meta.json +++ b/content/docs/concepts/compare/meta.json @@ -1,5 +1,5 @@ { "title": "Comparisons", "icon": "Scale", - "pages": ["..."] + "pages": ["index", "rls-and-tde", "..."] } diff --git a/content/docs/concepts/compare/rls-and-tde.mdx b/content/docs/concepts/compare/rls-and-tde.mdx new file mode 100644 index 0000000..89624e6 --- /dev/null +++ b/content/docs/concepts/compare/rls-and-tde.mdx @@ -0,0 +1,102 @@ +--- +title: RLS and TDE +description: "How CipherStash relates to Postgres Row-Level Security and Transparent Data Encryption: different threats, and how they combine." +type: concept +components: [encryption] +audience: [cto, ciso] +reviewBy: "2027-01-15" +--- + +Row-Level Security (RLS) and Transparent Data Encryption (TDE) are the two controls most teams already have when they start thinking about protecting sensitive data in Postgres. Both are useful. Neither does what CipherStash does, and CipherStash does not replace either. This page draws the lines: what each control protects, what it leaves exposed, and how the three fit together. + +The short version: RLS decides **which rows** a caller may see. TDE encrypts the database **files on disk**. CipherStash encrypts the **values themselves**, before they reach the database, and keeps them queryable. They sit at three different layers, so the useful question is not "which one" but "which threats each closes". + +## At a glance + +| | Row-Level Security | Transparent Data Encryption | CipherStash | +| --- | --- | --- | --- | +| Layer | Access control, in the database | At-rest encryption, under the database | Application-level encryption, above the database | +| What it protects | Which rows a role can read or write | Data files, and usually backups | The contents of individual columns | +| Data visible to a DBA / superuser | Yes, plaintext | Yes, plaintext | No, ciphertext only | +| Data in logs, replicas, backups | Plaintext | Plaintext in replicas and logs; encrypted backup files | Ciphertext everywhere | +| Still queryable | Yes | Yes | Yes, via EQL | +| Threat it closes | Over-broad row access | Stolen disk or backup media | Database compromise, insider access, leaked replicas and logs | + +The rows in this table are the whole point: the three controls do not overlap much, so you generally want more than one. + +## What TDE protects, and what it does not + +Transparent Data Encryption encrypts the database's data files on disk. The database decrypts pages as it reads them, so from the perspective of any SQL query the data is plaintext. That transparency is the feature and the limit at the same time. + +TDE closes exactly one threat: someone walks off with the **physical media**, a decommissioned drive, or a raw backup file, and tries to read it outside the running database. Against that, it works well. + +It does nothing about the threats that account for most real exposure, because every one of them goes through the running database, where data is already decrypted: + +- A DBA, a compromised application, or a SQL-injection payload reads the tables directly. +- A logical replica, a read replica, or a change-data-capture stream carries plaintext downstream. +- Query logs, `EXPLAIN` output, error reports, and debug snapshots capture plaintext. + + +TDE is "encryption at rest" in the narrowest sense: at rest on disk, decrypted the moment the database touches it. It is worth having, and it is not a confidentiality control against anyone who can talk to the database. + + +## What RLS protects, and what it does not + +Row-Level Security is **authorization**, not encryption. An RLS policy attaches a predicate to a table so that a given role only sees, or only modifies, the rows that match. It is the right tool for multi-tenant isolation and per-user visibility: a policy like `tenant_id = current_setting('app.tenant')` keeps one tenant's rows away from another's. + +What RLS does not do is protect the **contents** of the rows a caller is allowed to see. Inside those rows, every column is plaintext. And RLS only applies while queries flow through the policy engine, so it is bypassed by: + +- A superuser, or any role with `BYPASSRLS`. +- Direct file access, backups, and replicas, which carry the raw plaintext with no policy attached. +- Any path that reads the table outside the policy's assumptions, including many admin and migration tools. + +RLS answers "may this caller see this row". It has nothing to say about "is the data readable if the database itself is compromised". + +## What CipherStash adds + +CipherStash encrypts each sensitive value in your application, before it is sent to Postgres, and stores ciphertext plus searchable index terms. The database never holds the plaintext, so the surfaces that defeat TDE and RLS see ciphertext instead: + +- A DBA, a leaked backup, a replica, or a query log contains ciphertext, not readable data. +- Decryption happens only in your application, after key-bound decryption through [ZeroKMS](/security). + +The part that makes this practical is that the ciphertext stays **queryable**. Through [EQL](/reference/eql), encrypted columns still answer equality, range, ordering, and free-text containment, using standard SQL and standard Postgres indexes. You do not trade query capability for confidentiality the way "just encrypt the column with `pgcrypto`" forces you to. + +What CipherStash deliberately does not do is decide *which* rows a caller sees. That is an access-control question, and RLS already answers it well. + +## Use them together + +Because the three controls close different threats, the strong design is to combine them rather than choose one. On a managed platform like Supabase this is the common pattern: + +- **RLS** decides which rows come back for the authenticated caller. +- **CipherStash** decides whether the contents of those rows can be read at all. +- **TDE**, provided by the platform, covers the stolen-media case underneath. + +RLS and CipherStash compose especially cleanly: RLS policies run against the plaintext columns you authorize on (typically `user_id` or `tenant_id`, which are not secret), while the sensitive columns stay encrypted. One control narrows the rows; the other protects their contents. See the [Supabase integration](/integrations/supabase) for this exact combination in practice. + +## Choosing + +| If your concern is... | Reach for | +| --- | --- | +| One tenant or user seeing another's rows | RLS | +| A stolen drive or backup file | TDE (usually on by default in managed Postgres) | +| The database itself being untrusted: insiders, leaked replicas and logs, breach | CipherStash | +| All of the above | All three, composed | + +If you only adopt one new control, make it the one that matches your actual threat model. For most teams handling regulated or high-value data, the gap that TDE and RLS leave open, the database seeing plaintext, is the one that matters, and it is the gap CipherStash closes. + +## Where to next + + + + The model behind application-level searchable encryption. + + + How encrypted values stay queryable, and what each index term reveals. + + + RLS and encryption composed on a managed Postgres platform. + + + The full threat model and the trust boundary CipherStash draws. + + From 5b6f31deb881364b147b7d3540b69f1c47dd1435 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 15:20:16 +1000 Subject: [PATCH 2/4] docs(concepts): lead the RLS comparison with the service-principal gap Reframe the RLS section around its real limitation: policies enforce on the connecting database identity, but apps connect as a shared service principal (especially through ORMs like Drizzle/Prisma, and on RDS/Aurora), so per-user RLS is not in effect for most real connection patterns. Supabase via supabase-js is the exception. CipherStash's guarantee is independent of how you connect and can bind to the end user. --- content/docs/concepts/compare/rls-and-tde.mdx | 89 +++++++++++-------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/content/docs/concepts/compare/rls-and-tde.mdx b/content/docs/concepts/compare/rls-and-tde.mdx index 89624e6..c5245ea 100644 --- a/content/docs/concepts/compare/rls-and-tde.mdx +++ b/content/docs/concepts/compare/rls-and-tde.mdx @@ -1,32 +1,43 @@ --- title: RLS and TDE -description: "How CipherStash relates to Postgres Row-Level Security and Transparent Data Encryption: different threats, and how they combine." +description: "How CipherStash relates to Postgres Row-Level Security and Transparent Data Encryption: different threats, why RLS is often not even in effect, and how they combine." type: concept components: [encryption] audience: [cto, ciso] reviewBy: "2027-01-15" --- -Row-Level Security (RLS) and Transparent Data Encryption (TDE) are the two controls most teams already have when they start thinking about protecting sensitive data in Postgres. Both are useful. Neither does what CipherStash does, and CipherStash does not replace either. This page draws the lines: what each control protects, what it leaves exposed, and how the three fit together. +Row-Level Security (RLS) and Transparent Data Encryption (TDE) are the two controls most teams already have when they start protecting sensitive data in Postgres. Both are useful. Neither does what CipherStash does, and CipherStash does not replace either. This page draws the lines: what each control protects, what it leaves exposed, and how the three fit together. -The short version: RLS decides **which rows** a caller may see. TDE encrypts the database **files on disk**. CipherStash encrypts the **values themselves**, before they reach the database, and keeps them queryable. They sit at three different layers, so the useful question is not "which one" but "which threats each closes". +Start with the one fact that surprises people most: **RLS enforces rules against the identity of the database connection, and your application almost never connects as the end user.** That single detail decides where RLS can protect you, and in the most common ways applications talk to Postgres, the answer is that it cannot. The rest of this page builds on that. -## At a glance +## Row-Level Security enforces on the connection, not the user -| | Row-Level Security | Transparent Data Encryption | CipherStash | -| --- | --- | --- | --- | -| Layer | Access control, in the database | At-rest encryption, under the database | Application-level encryption, above the database | -| What it protects | Which rows a role can read or write | Data files, and usually backups | The contents of individual columns | -| Data visible to a DBA / superuser | Yes, plaintext | Yes, plaintext | No, ciphertext only | -| Data in logs, replicas, backups | Plaintext | Plaintext in replicas and logs; encrypted backup files | Ciphertext everywhere | -| Still queryable | Yes | Yes | Yes, via EQL | -| Threat it closes | Over-broad row access | Stolen disk or backup media | Database compromise, insider access, leaked replicas and logs | +Row-Level Security is authorization, not encryption. A policy attaches a predicate to a table so that a role only sees, or only modifies, the rows that match. It is the standard tool for multi-tenant isolation and per-user visibility, and where it is wired up it works well. + +The catch is what "role" means. RLS policies are evaluated against the identity of the **database session**: the Postgres role you connected as, plus whatever you have set in that session. They know nothing about the *end user* of your application unless you put that user's identity into the session yourself, on every request. + +Almost no application does. The standard pattern is a **service principal**: the app opens a pool of connections as one shared database role and reuses them across every request and every user. Under that model `current_user` is identical for all of them, so a policy keyed on the end user has nothing to distinguish them by. RLS is not switched off; it simply has no per-user signal to act on. + +To make RLS enforce per-user rules, you have to get the end user's identity into the session on each request, by one of: + +- a distinct database role per end user, which does not survive connection pooling and does not scale; or +- a per-transaction session variable (`SET LOCAL app.user_id = ...`) that your policies read with `current_setting(...)`. This is real plumbing that every query path has to honor, and a single direct connection or a forgotten `SET` silently bypasses it. + +### Where it works, and where it doesn't + +The one place per-user RLS is wired up for you is a data API that carries the end user's token into the database session. **Supabase** does this through **supabase-js** and the PostgREST data API behind it: the user's JWT sets the session role and claims, so a policy can call `auth.uid()` and mean it. + +Connect to that same database a different way and the context is gone: + +- **Drizzle, Prisma, or any ORM or driver** connects over a direct Postgres connection as the **service principal**. There is no end-user JWT and no per-user session context, so RLS is back to seeing one shared role. +- **AWS RDS, Aurora, Cloud SQL, and self-managed Postgres** have no equivalent of the Supabase data API. Every connection is a service principal unless you build and enforce the session-context propagation yourself. -The rows in this table are the whole point: the three controls do not overlap much, so you generally want more than one. +So for the most common way applications actually reach Postgres, an ORM over a pooled service-principal connection, per-user RLS is not available by default. It is powerful where it is wired up, and absent almost everywhere else. That gap is worth naming before you rely on it. -## What TDE protects, and what it does not +## Transparent Data Encryption protects the disk, not the query -Transparent Data Encryption encrypts the database's data files on disk. The database decrypts pages as it reads them, so from the perspective of any SQL query the data is plaintext. That transparency is the feature and the limit at the same time. +Transparent Data Encryption encrypts the database's data files on disk. The database decrypts pages as it reads them, so from the perspective of any SQL query the data is plaintext. That transparency is the feature and the limit at once. TDE closes exactly one threat: someone walks off with the **physical media**, a decommissioned drive, or a raw backup file, and tries to read it outside the running database. Against that, it works well. @@ -40,49 +51,51 @@ It does nothing about the threats that account for most real exposure, because e TDE is "encryption at rest" in the narrowest sense: at rest on disk, decrypted the moment the database touches it. It is worth having, and it is not a confidentiality control against anyone who can talk to the database. -## What RLS protects, and what it does not - -Row-Level Security is **authorization**, not encryption. An RLS policy attaches a predicate to a table so that a given role only sees, or only modifies, the rows that match. It is the right tool for multi-tenant isolation and per-user visibility: a policy like `tenant_id = current_setting('app.tenant')` keeps one tenant's rows away from another's. - -What RLS does not do is protect the **contents** of the rows a caller is allowed to see. Inside those rows, every column is plaintext. And RLS only applies while queries flow through the policy engine, so it is bypassed by: - -- A superuser, or any role with `BYPASSRLS`. -- Direct file access, backups, and replicas, which carry the raw plaintext with no policy attached. -- Any path that reads the table outside the policy's assumptions, including many admin and migration tools. - -RLS answers "may this caller see this row". It has nothing to say about "is the data readable if the database itself is compromised". - ## What CipherStash adds -CipherStash encrypts each sensitive value in your application, before it is sent to Postgres, and stores ciphertext plus searchable index terms. The database never holds the plaintext, so the surfaces that defeat TDE and RLS see ciphertext instead: +CipherStash encrypts each sensitive value in your application, before it is sent to Postgres, and stores ciphertext plus searchable index terms. Two properties follow, and both address the gaps above. + +**It does not depend on how you connect.** The database only ever holds ciphertext, so the protection is the same whether you use supabase-js, Drizzle, Prisma, a raw driver, RDS, Aurora, or self-managed Postgres. There is no service-principal caveat, because confidentiality is not being enforced by the database at all. It is enforced by the keys, which live in your application. -- A DBA, a leaked backup, a replica, or a query log contains ciphertext, not readable data. -- Decryption happens only in your application, after key-bound decryption through [ZeroKMS](/security). +**It can bind to the end user that RLS could not see.** Because decryption happens in your application under a key resolved through [ZeroKMS](/security), it can be scoped to the authenticated end user with identity-aware encryption: a value encrypted for one user does not decrypt for another, regardless of which database role the connection used. That is the per-user guarantee the service-principal model denies RLS, delivered at the layer where the end user's identity actually exists. -The part that makes this practical is that the ciphertext stays **queryable**. Through [EQL](/reference/eql), encrypted columns still answer equality, range, ordering, and free-text containment, using standard SQL and standard Postgres indexes. You do not trade query capability for confidentiality the way "just encrypt the column with `pgcrypto`" forces you to. +And the ciphertext stays **queryable**. Through [EQL](/reference/eql), encrypted columns still answer equality, range, ordering, and free-text containment using standard SQL and standard Postgres indexes, so you do not trade query capability for confidentiality. -What CipherStash deliberately does not do is decide *which* rows a caller sees. That is an access-control question, and RLS already answers it well. +What CipherStash deliberately does not do is decide *which* rows a caller sees. That is an access-control question. Where RLS is wired up it answers it well; where it is not, that logic belongs in your application. ## Use them together -Because the three controls close different threats, the strong design is to combine them rather than choose one. On a managed platform like Supabase this is the common pattern: +The three controls close different threats, so the strong design combines them rather than choosing one: -- **RLS** decides which rows come back for the authenticated caller. -- **CipherStash** decides whether the contents of those rows can be read at all. +- **RLS**, where it is in effect, decides which rows come back for the authenticated caller. +- **CipherStash** decides whether the contents of those rows can be read at all, and can bind that to the end user. - **TDE**, provided by the platform, covers the stolen-media case underneath. -RLS and CipherStash compose especially cleanly: RLS policies run against the plaintext columns you authorize on (typically `user_id` or `tenant_id`, which are not secret), while the sensitive columns stay encrypted. One control narrows the rows; the other protects their contents. See the [Supabase integration](/integrations/supabase) for this exact combination in practice. +On Supabase through supabase-js, RLS and CipherStash compose cleanly: RLS runs against the plaintext columns you authorize on (`user_id`, `tenant_id`, which are not secret) while the sensitive columns stay encrypted. One control narrows the rows, the other protects their contents. Reach that same database through an ORM as the service principal and RLS steps back, so more of the "which rows" logic lives in your application, while CipherStash's confidentiality guarantee is unchanged. See the [Supabase integration](/integrations/supabase) for the wired-up case. + +## At a glance + +| | Row-Level Security | Transparent Data Encryption | CipherStash | +| --- | --- | --- | --- | +| Layer | Access control, in the database | At-rest encryption, under the database | Application-level encryption, above the database | +| Enforced against | The connecting database identity | The storage engine | Keys held in your application | +| Per-end-user? | Only if you propagate end-user identity into the session (Supabase data API does; ORMs and RDS do not) | No | Yes, independent of how you connect | +| Data visible to a DBA / superuser | Yes, plaintext | Yes, plaintext | No, ciphertext only | +| Data in logs, replicas, backups | Plaintext | Plaintext in replicas and logs; encrypted backup files | Ciphertext everywhere | +| Still queryable | Yes | Yes | Yes, via EQL | +| Threat it closes | Over-broad row access, where it is in effect | Stolen disk or backup media | Database compromise, insider access, leaked replicas and logs | ## Choosing | If your concern is... | Reach for | | --- | --- | -| One tenant or user seeing another's rows | RLS | +| One tenant or user seeing another's rows, and you go through a data API that carries the user identity | RLS | | A stolen drive or backup file | TDE (usually on by default in managed Postgres) | | The database itself being untrusted: insiders, leaked replicas and logs, breach | CipherStash | +| Per-user confidentiality when your app connects as a service principal (most ORMs, RDS, Aurora) | CipherStash | | All of the above | All three, composed | -If you only adopt one new control, make it the one that matches your actual threat model. For most teams handling regulated or high-value data, the gap that TDE and RLS leave open, the database seeing plaintext, is the one that matters, and it is the gap CipherStash closes. +If you only adopt one new control, match it to your actual threat model and your actual connection model. For most teams handling regulated or high-value data through an ORM, the gap that TDE and RLS leave open, the database seeing plaintext and RLS never seeing the end user, is the one that matters. That is the gap CipherStash closes. ## Where to next From fd5c4b968a601970ffd97f08d4054537b71f5e35 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 15:27:25 +1000 Subject: [PATCH 3/4] docs(concepts): add a service-principal vs per-user diagram to the RLS page Two-lane Mermaid diagram: three users sharing one pooled service-principal connection (DB sees one role) vs a user identity carried through a data API (DB sees the actual user). --- content/docs/concepts/compare/rls-and-tde.mdx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/content/docs/concepts/compare/rls-and-tde.mdx b/content/docs/concepts/compare/rls-and-tde.mdx index c5245ea..c9a473d 100644 --- a/content/docs/concepts/compare/rls-and-tde.mdx +++ b/content/docs/concepts/compare/rls-and-tde.mdx @@ -26,6 +26,33 @@ To make RLS enforce per-user rules, you have to get the end user's identity into ### Where it works, and where it doesn't +```mermaid +flowchart LR + subgraph sp["Service principal: ORM, RDS, Aurora"] + direction TB + u1["User A"] + u2["User B"] + u3["User C"] + app["Application"] + spdb["Postgres
RLS sees: app_service
one role for every user"] + u1 --> app + u2 --> app + u3 --> app + app -- "one pooled connection
as a shared role" --> spdb + end + + subgraph pu["Per-user: Supabase via supabase-js"] + direction TB + eu["End user"] + api["supabase-js / PostgREST
carries the user's JWT"] + pudb["Postgres
RLS sees: auth.uid
the actual end user"] + eu --> api + api -- "sets the session identity" --> pudb + end +``` + +On the left, three end users share one pooled connection, so the database only ever sees the service-principal role and RLS cannot tell them apart. On the right, the data API carries each user's identity into the session, so RLS can act on it. + The one place per-user RLS is wired up for you is a data API that carries the end user's token into the database session. **Supabase** does this through **supabase-js** and the PostgREST data API behind it: the user's JWT sets the session role and claims, so a policy can call `auth.uid()` and mean it. Connect to that same database a different way and the context is gone: From a6f202e7fa74de2f6df26321ce74421d3ef08e62 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 15:50:20 +1000 Subject: [PATCH 4/4] docs: brand Mermaid diagrams with the CipherStash palette Switch the Mermaid component from the stock default/dark themes to the customisable base theme with CipherStash themeVariables (warm near-black / off-white grounds, lime accent for subgraph titles), for both light and dark. Applied globally, so every diagram on the site is branded from one place. --- src/components/mermaid.tsx | 51 +++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/components/mermaid.tsx b/src/components/mermaid.tsx index 33d59d3..156ba2a 100644 --- a/src/components/mermaid.tsx +++ b/src/components/mermaid.tsx @@ -2,6 +2,52 @@ import { useEffect, useId, useState } from "react"; +// CipherStash Mermaid theming. Mermaid's `base` theme is the one built to be +// customised via `themeVariables`; these two palettes track the docs' own +// tokens (warm near-black / off-white grounds, the lime brand accent for +// subgraph titles) so diagrams read as part of the site rather than stock +// Mermaid. Applied globally, so every diagram is branded from one place. +const MERMAID_THEME = { + light: { + fontFamily: "inherit", + fontSize: "14px", + background: "transparent", + primaryColor: "#ffffff", + mainBkg: "#ffffff", + primaryBorderColor: "#d7d3c7", + nodeBorder: "#d7d3c7", + primaryTextColor: "#1a1813", + nodeTextColor: "#1a1813", + textColor: "#1a1813", + lineColor: "#8a857a", + secondaryColor: "#f5f3ee", + tertiaryColor: "#f5f3ee", + clusterBkg: "#f5f3ee", + clusterBorder: "#e2ded2", + titleColor: "#5f8410", + edgeLabelBackground: "#faf9f4", + }, + dark: { + fontFamily: "inherit", + fontSize: "14px", + background: "transparent", + primaryColor: "#17160f", + mainBkg: "#17160f", + primaryBorderColor: "#33322c", + nodeBorder: "#33322c", + primaryTextColor: "#eae8dd", + nodeTextColor: "#eae8dd", + textColor: "#eae8dd", + lineColor: "#9a9486", + secondaryColor: "#100f09", + tertiaryColor: "#100f09", + clusterBkg: "#100f09", + clusterBorder: "#2b2a24", + titleColor: "#b9e84a", + edgeLabelBackground: "#0a0a0a", + }, +} as const; + /** * Renders a Mermaid diagram authored as a ```mermaid code fence. * @@ -32,7 +78,10 @@ export function Mermaid({ chart }: { chart: string }) { mermaid.initialize({ startOnLoad: false, - theme: isDark ? "dark" : "default", + // `base` + themeVariables is Mermaid's customisation path; the palette + // is the CipherStash one defined above. + theme: "base", + themeVariables: isDark ? MERMAID_THEME.dark : MERMAID_THEME.light, // Diagram labels should match the surrounding prose, not Mermaid's // default sans stack. fontFamily: "inherit",