Data security
This page explains how to protect the rows of your database from anyone holding the publishable key: row-level security (RLS) policies, testing a policy as a given user, classifying personal columns that agents do not read, and the daily security posture analysis.
The principle
The publishable key is in your browser code: anyone can read it. What protects your data is therefore the policies the database applies to every request, not the application.
- A table is served by the API only if it has row-level security AND at least one policy. The grants (
select,insert,update,deletefor the anonymous and authenticated roles) follow automatically, and are withdrawn as soon as the table loses either (LIM-11). Supabase, by contrast, grants every table inpublic. - Roles carry the database name as a prefix. A
grant … to authenticatedor acreate policy … to authenticatedwritten as is gets refused (unknown role,42704). Write the policy withoutTO: it applies to every role, andauth.uid()tells the signed-in user apart.auth.role()andauth.jwt(), for their part, return Supabase's names. - **Write
(select auth.uid()), not a bareauth.uid().** The sub-query form is evaluated once per query instead of once per row. The platform refuses the bare form and returns its rewrite.
Proposed policies
For each table in the public schema, the platform generates a proposal, with no AI model, from the table's structure. Three regimes, tried in this order:
| Regime | When | What is proposed |
|---|---|---|
| Owner | A uuid column ties the row to a user: a foreign key to auth.users(id), otherwise a conventional column name. | Each user sees and changes their own rows. |
| Through the parent | No such column, but a foreign key leads to a table that belongs to someone (order lines, join tables). | The row belongs to its parent's owner. |
| Closed | Neither. | The table is closed to users and open only to the service key. |
The proposal carries the grants, four policies (one per command) for the authenticated role, one policy for the service key, and the index that serves the predicate. The anonymous role gets nothing. Opening a table for reading to everyone (USING (true)) is never proposed: that decision is yours.
The test before committing
Before committing a policy, the platform tests it under four identities, with real users of the database: an anonymous visitor, a row's owner, another user, and the service key. A row readable by someone who does not own it is a defect: the policy is then applied only after a person confirms it, and the report cites the row.
From the console
Open Politiques (Policies), from Données (Data). Tables are on the left, with their state; the chosen table shows its policy, its form and the proposal in the centre; on the right, the tools to test it as a given user.
- Pick a table and read the proposal, or write your own predicate.
- Apply: the four-identity report is shown.
- If a row is visible to a non-owner, confirm or correct.
Disabling row-level security on a table is a separate action, at the admin rank: it is confirmed in two steps, by retyping the table name, and the platform's worker carries it out (database.rls.configure). Re-enabling needs no confirmation. This action is closed to agents.
Through an agent, the command line or the API
| Action | MCP tool | Command line |
|---|---|---|
| Read the proposals | database_policy_list | bastide database policy list |
| Apply a table's proposal | database_policy_write_proposed | bastide database policy write_proposed |
| Apply your own predicate | database_policy_write | bastide database policy write |
| Enable or disable RLS | none | bastide database rls configure |
Apply an ownership predicate (rank maintainer, scope db:write):
{
"projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
"table": "commandes",
"policy": "proprietaire_id = (select auth.uid())",
"verifyAs": ["anon", "authenticated", "service_role"],
"idempotencyKey": "politique-commandes-2026-09-14"
}
If the test finds a row that is wrongly readable, the call is refused with confirmation_requise and a nonce; a person replays it with confirmationNonce. The response carries applied, rewrite (the optimised form, if yours was refused), verifications, report and indexCreated. A policy is replaced by a new call, which removes the previous one before setting its own.
Testing a policy as a user
Impersonation shows what a specific user of your application actually sees.
- Open a session for a user of
auth.users, given by their identifier (database_impersonation_create). It lasts thirty minutes, with no extension. The audit log keeps who opened it and for which user. - Read as that user (
database_impersonation_read): one statement, under the authenticated role, in a read-only transaction, at most one hundred rows.deniedsays whether the database refused the read. - Write as that user (
database_impersonation_write): a real write, which only goes out after a person confirms it in two steps. Policies apply as they would for the user.
In the console, the right-hand panel of Politiques does the same: pick the user, open the session, then read. The write button does not run the write directly: it opens a dialog that says what will happen before you approve.
{
"projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
"impersonationId": "usu_01JQ8ZK4M7NP2RSTVWXYZ34567",
"sql": "select id, total from commandes order by id"
}
Personal columns
You can classify columns as "personal data": address, phone number, date of birth. A classified column is read by no agent: any agent statement whose plan reads it, writes it or passes it to a project function is refused with colonne_personnelle_refusee, in the SQL editor as under impersonation. A person can still read it from the console.
- The list lives in the control plane: the database is not touched, and a column can be classified before it exists.
- It is replaced as a whole: whatever is no longer in it is declassified. Five hundred columns at most.
- Only a person sets it (rank
maintainer): declassifying a column would open it to agents, so thedatabase.personal_columns.configureoperation is closed to them. - A project created by an agent and not yet claimed receives no personal data (
projet_non_revendique).
In the console: Données, section « Sécurité » (security), personal columns card. On the command line: bastide database personal_columns configure. Over the API: PUT /v1/projects/{projectId}/database/personal-columns.
{
"projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
"columns": [
{ "schema": "public", "table": "clients", "column": "courriel" },
{ "schema": "public", "table": "clients", "column": "telephone" }
],
"idempotencyKey": "classement-clients-2026-10-13"
}
Security posture
Every day, the platform analyses your database and reports five kinds of defect, from most to least severe:
| Finding | Stake | Remedy |
|---|---|---|
| Table exposed without a policy | Anyone holding the publishable key reads or writes all its rows. | Apply the proposed policy. |
| Personal column readable with the publishable key | A classified column is read by the anonymous role. | Revoke the anonymous role's read grant. |
| Secret key seen from a browser | Its secret is in a page's code. | Issue a new key server side, then revoke this one. |
| Always-true policy | It opens the table to the API roles unconditionally. | Replace it with an ownership predicate. |
| Policy without an index | Every row re-evaluates the predicate. | Create the proposed index. |
Each finding carries its correction, and the posture gathers them into one migration that you apply (see Branches and migrations). What the analysis could not measure is reported as such (notMeasured), never as a healthy database.
- Console: Données, section « Sécurité »; the right-hand column of the « Tables » section shows a summary.
- Agent:
database_posture_read; a nullanalyzedAtmeans no analysis has run yet. - Correction as a pull request: when enabled, each new correction goes out on a
bastide/…branch of your GitHub repository, as a pull request; nothing is pushed to your branch. Off by default, it is set from the console or withdatabase.posture.configure, which is closed to agents.
Common errors
- A table returns zero rows through the API: it has no policy, or its policy grants nothing to that role. Check Politiques.
- **
42704**: you namedauthenticatedoranon; remove theTO. - The policy is refused for its form: use the rewrite returned in
rewrite. - **
confirmation_requise**: the test found a wrongly readable row; read the report before confirming. - **
usurpation_expiree**: the session passed thirty minutes; open a new one. - **
colonne_personnelle_refusee**: the column is classified; a person reads it from the console.