diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx
index d953048136f..41e3f4dd48b 100644
--- a/apps/docs/content/guides/database/postgres/row-level-security.mdx
+++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx
@@ -16,7 +16,7 @@ A table in an exposed schema without RLS is readable and writable by any role wi
Use the guide in three parts:
- [Understand Row Level Security](#understand-row-level-security) explains how grants and policies combine to control access.
-- [Secure a table with RLS](#secure-a-table-with-rls) is the procedure to follow for every table in an exposed schema.
+- [Secure a table with RLS](#secure-a-table-with-rls) is the procedure to follow for every table in an exposed schema: grants, policies, and `supabase test db`.
- [RLS reference](#rls-reference) documents the helper functions and patterns you use inside a policy expression.
Read the first section when you're deciding how to model access. Go directly to the second section when you're ready to secure a table.
@@ -63,7 +63,7 @@ Adding policies doesn't take those grants back. A table protected only by polici
Not every project grants these automatically. See [Default privileges](/docs/guides/api/securing-your-api#default-privileges). Grant each role only the operations it needs.
-A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. To set them, see [Lock down the table](#lock-down-the-table).
+A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. To set them, see [Enable RLS and set the grants](#enable-rls-and-set-the-grants).
### Authenticated and unauthenticated roles
@@ -102,9 +102,16 @@ Views bypass RLS by default because they are usually created with the `postgres`
## Secure a table with RLS
-Follow these steps for every table in an exposed schema.
+Follow these steps for every table in an exposed schema:
-### Lock down the table
+1. [Enable RLS and set the grants](#enable-rls-and-set-the-grants) to match the app.
+2. [Write a policy per operation](#write-a-policy-for-each-operation).
+3. [Create a `.sql` file under `supabase/tests/`](#policy-tests) that asserts allow and deny for `select`, `insert`, `update`, and `delete`, for `anon` and `authenticated`.
+4. [Run `supabase test db`](#run-the-test-suite) and fix what it reports.
+
+Until the suite passes, you don't know whether the policies do what you intended.
+
+### Enable RLS and set the grants
Run these statements in the [SQL Editor](/dashboard/project/_/sql/new) for a one-off change, or in a [migration](/docs/guides/deployment/database-migrations) to keep the change reproducible across environments. Grants and RLS belong in the same migration.
@@ -131,17 +138,105 @@ Enable RLS, then set the grants to match what each role does in your app:
grant select, insert, update, delete on table public.reports to authenticated;
```
-Data that clients read but never write, such as a feed a backend job populates, gets no write grant at all:
+4. Write the test file for the table, and run the suite.
+
+ ```bash
+ supabase test new reports_rls.test
+ supabase test db
+ ```
+
+ Give every table you secure one. The examples below show what goes in the file.
+
+Data that clients read but never write, such as a feed a backend job populates, gets select only. The policy and the test file are part of the same change:
```sql
-revoke all on table public.weather_readings from anon, authenticated;
-grant select on table public.weather_readings to anon, authenticated;
+alter table public.announcements enable row level security;
+revoke all on table public.announcements from anon, authenticated;
+grant select on table public.announcements to anon, authenticated;
+
+create policy "Anyone can read announcements"
+on public.announcements for select
+to anon, authenticated
+using ( true );
+```
+
+```sql supabase/tests/announcements_rls.test.sql
+-- File: supabase/tests/announcements_rls.test.sql
+-- Create: supabase test new announcements_rls.test
+-- Run: supabase test db
+-- Repeat for every public-read table you secured.
+begin;
+select plan(10);
+
+insert into announcements (id, body)
+values ('33333333-3333-3333-3333-333333333333', 'published');
+
+-- A read has to return the row. lives_ok passes on an empty result.
+select ok(
+ not has_table_privilege('anon', 'public.announcements', 'insert,update,delete'),
+ 'anon holds no write grant on the feed'
+);
+select ok(
+ not has_table_privilege('authenticated', 'public.announcements', 'insert,update,delete'),
+ 'authenticated holds no write grant on the feed'
+);
+
+set local role anon;
+select results_eq(
+ $$select body from announcements where id = '33333333-3333-3333-3333-333333333333'$$,
+ array['published'],
+ 'anon reads the feed'
+);
+select throws_ok(
+ $$insert into announcements select * from announcements$$,
+ '42501',
+ null,
+ 'anon cannot insert into the feed'
+);
+select throws_ok(
+ $$update announcements set id = id$$,
+ '42501',
+ null,
+ 'anon cannot update the feed'
+);
+select throws_ok(
+ $$delete from announcements$$,
+ '42501',
+ null,
+ 'anon cannot delete from the feed'
+);
+
+set local role authenticated;
+select results_eq(
+ $$select body from announcements where id = '33333333-3333-3333-3333-333333333333'$$,
+ array['published'],
+ 'authenticated reads the feed'
+);
+select throws_ok(
+ $$insert into announcements select * from announcements$$,
+ '42501',
+ null,
+ 'authenticated cannot insert into the feed'
+);
+select throws_ok(
+ $$update announcements set id = id$$,
+ '42501',
+ null,
+ 'authenticated cannot update the feed'
+);
+select throws_ok(
+ $$delete from announcements$$,
+ '42501',
+ null,
+ 'authenticated cannot delete from the feed'
+);
+
+select * from finish();
+rollback;
```
If new tables still receive automatic grants, see [Revoke default privileges](/docs/guides/api/securing-your-api#revoke-default-privileges). To enable RLS automatically on every new table, see [Event triggers](/docs/guides/database/postgres/event-triggers).
-Write the tests for this table in the same change. See [Test your policies](#test-your-policies).
-
### Write a policy for each operation
Write a separate policy for `select`, `insert`, `update`, and `delete`. Postgres does not accept multiple operations in one `for` clause, and a `for all` policy hides which operation each rule was meant to cover.
@@ -216,6 +311,153 @@ to authenticated
using ( (select auth.uid()) = user_id );
```
+#### Policy tests
+
+When you adapt those four policies to a table, add this file for that table. The path is `supabase/tests/
_rls.test.sql`. For a table users share, also assert that a member who is not the owner can perform the operations its policies allow, and that a non-member cannot.
+
+```sql supabase/tests/profiles_rls.test.sql
+-- File: supabase/tests/profiles_rls.test.sql
+-- Create: supabase test new profiles_rls.test
+-- Run: supabase test db
+-- One file per table you enabled RLS on. Name that table in the assertions.
+-- Do not create a table only the tests use.
+begin;
+select plan(14);
+
+insert into auth.users (id, email)
+values
+ ('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
+ ('22222222-2222-2222-2222-222222222222', 'other@example.com');
+
+-- anon holds no grant, so the request stops before any policy runs.
+set local role anon;
+select throws_ok(
+ $$select * from profiles$$,
+ '42501',
+ null,
+ 'anon cannot read profiles'
+);
+select throws_ok(
+ $$insert into profiles (id, user_id, avatar_url)
+ values (
+ gen_random_uuid(),
+ '11111111-1111-1111-1111-111111111111',
+ 'anon.png'
+ )$$,
+ '42501',
+ null,
+ 'anon cannot create a profile'
+);
+select throws_ok(
+ $$update profiles set avatar_url = 'anon.png'$$,
+ '42501',
+ null,
+ 'anon cannot update profiles'
+);
+select throws_ok(
+ $$delete from profiles$$,
+ '42501',
+ null,
+ 'anon cannot delete profiles'
+);
+
+-- The owner writes their own row. returning proves the row changed.
+set local role authenticated;
+set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
+select results_eq(
+ $$insert into profiles (id, user_id, avatar_url)
+ values (
+ gen_random_uuid(),
+ '11111111-1111-1111-1111-111111111111',
+ 'owner.png'
+ )
+ returning avatar_url$$,
+ array['owner.png'],
+ 'the owner creates their own profile'
+);
+select results_eq(
+ $$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
+ array['owner.png'],
+ 'the owner reads their own profile'
+);
+select results_eq(
+ $$update profiles set avatar_url = 'updated.png'
+ where user_id = '11111111-1111-1111-1111-111111111111'
+ returning avatar_url$$,
+ array['updated.png'],
+ 'the owner updates their own profile'
+);
+
+-- A signed-in stranger holds the grant, so the policy is what stops them.
+set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
+select throws_ok(
+ $$insert into profiles (id, user_id, avatar_url)
+ values (
+ gen_random_uuid(),
+ '11111111-1111-1111-1111-111111111111',
+ 'stolen.png'
+ )$$,
+ '42501',
+ null,
+ 'another user cannot create a profile for the owner'
+);
+select is_empty(
+ $$select * from profiles$$,
+ 'another user reads no profiles'
+);
+select is_empty(
+ $$update profiles set avatar_url = 'stolen.png' returning avatar_url$$,
+ 'another user updates no profiles'
+);
+
+-- Matching no rows is not proof on its own. Pair every denied write with a
+-- check that the row it targeted is intact. Scope it to that row: a suite
+-- that asserts on everything a role can see breaks once the table holds more.
+set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
+select results_eq(
+ $$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
+ array['updated.png'],
+ 'the denied update left the owner row intact'
+);
+
+set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
+select is_empty(
+ $$delete from profiles returning avatar_url$$,
+ 'another user deletes no profiles'
+);
+
+set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
+select results_eq(
+ $$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
+ array['updated.png'],
+ 'the denied delete left the owner row intact'
+);
+
+-- Owner delete last so earlier cases still have a row to assert against.
+set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
+select results_eq(
+ $$delete from profiles where user_id = '11111111-1111-1111-1111-111111111111'
+ returning avatar_url$$,
+ array['updated.png'],
+ 'the owner deletes their own profile'
+);
+
+select * from finish();
+rollback;
+```
+
+Match the assertion to how the request is denied. Only two of the three raise an error:
+
+| Denied by | Postgres | Assert with |
+| -------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
+| A missing grant | raises `42501` | `throws_ok` |
+| A `with check` violation | raises `42501` | `throws_ok` |
+| A `using` clause filtering the row out | raises nothing, matches zero rows | `is_empty` over the statement with `returning`, then a read proving the target row is intact |
+
+Never prove an allowed write with `lives_ok`. It passes when the write matched zero rows. Add `returning` and assert the returned value.
+
+Switch role and identity with `set local role` and `set local request.jwt.claim.sub`.
+
### Specify roles in your policies
Always name the role a policy applies to, using the `to` clause. Instead of this:
@@ -235,7 +477,11 @@ using ( (select auth.uid()) = user_id );
This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step.
-These three rules keep policies correct as a table grows. For the measured impact and for tuning beyond them, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance).
+### Run the test suite
+
+The files above live under `supabase/tests/` and run through [pgTAP](/docs/guides/database/extensions/pgtap). Create them with `supabase test new _rls.test`, and run them with `supabase test db`.
+
+[`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main) adds `tests.create_supabase_user()`, `tests.authenticate_as()`, and `tests.rls_enabled()`. See [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended) and [Testing your database](/docs/guides/database/testing).
### Add indexes
@@ -296,6 +542,8 @@ You can only use this technique if the results of the query or function do not c
+Indexing the filter columns and wrapping helper calls keeps policies fast as a table grows. For the measured impact, and for tuning beyond these two rules, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance).
+
### Expose a view safely
In Postgres 15 and above, make a view obey the RLS policies of its underlying tables when invoked by `anon` and `authenticated` by setting `security_invoker = true`.
@@ -308,99 +556,6 @@ as select
In older versions of Postgres, protect your views by revoking access from the `anon` and `authenticated` roles, or by putting them in an unexposed schema.
-### Test your policies
-
-We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended.
-
-A wrong policy fails quietly. Too permissive, and a query returns rows it shouldn't. Too strict, and it returns nothing and raises no error. Neither case surfaces as an error, so tests are how you find out.
-
-Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`.
-
-#### Anatomy of a policy test
-
-Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything.
-
-**Identity.** Switch role and identity between cases with `set local role` and `set local request.jwt.claim.sub`, so each assertion runs as the user it describes. Without the switch, every case runs as the same role and proves nothing about access.
-
-**Denials.** A denied request doesn't always raise an error, so match the assertion to the way the denial happens:
-
-- A missing grant raises `42501`. Assert it with `throws_ok`.
-- A `with check` violation raises `42501`. Assert it with `throws_ok`.
-- A `using` clause that filters the target row out raises nothing. The update or delete matches zero rows instead. Assert that no row changed.
-
-**Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing.
-
-#### Write and run the tests
-
-1. Create a test file:
-
- ```bash
- supabase test new profiles_rls
- ```
-
-2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies, for `anon` as well as `authenticated`.
-
- [`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main) removes most of the setup below. It adds `tests.create_supabase_user()`, `tests.authenticate_as()`, and `tests.rls_enabled()`, so you don't hand-roll user seeding or role switching. See [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended) for schema-wide assertions and a worked multi-tenant example.
-
-3. Run the suite:
-
- ```bash
- supabase test db
- ```
-
-This example shows each of those techniques against a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row. Extend it to the remaining operations:
-
-```sql supabase/tests/profiles_rls.test.sql
-begin;
-select plan(4);
-
-insert into auth.users (id, email)
-values
- ('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
- ('22222222-2222-2222-2222-222222222222', 'other@example.com');
-
--- anon holds no grant, so the request stops before any policy runs.
-set local role anon;
-select throws_ok(
- $$select * from profiles$$,
- '42501',
- null,
- 'anon cannot read profiles'
-);
-
--- The owner writes their own row. returning proves the row changed.
-set local role authenticated;
-set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
-select results_eq(
- $$insert into profiles (id, user_id, avatar_url)
- values (
- gen_random_uuid(),
- '11111111-1111-1111-1111-111111111111',
- 'owner.png'
- )
- returning avatar_url$$,
- array['owner.png'],
- 'the owner creates their own profile'
-);
-
--- A signed-in stranger holds the grant, so the policy is what stops them. The
--- using clause filters the row out, so these match nothing and raise nothing.
-set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
-select is_empty(
- $$select * from profiles$$,
- 'another user reads no profiles'
-);
-select is_empty(
- $$update profiles set avatar_url = 'stolen.png' returning avatar_url$$,
- 'another user updates no profiles'
-);
-
-select * from finish();
-rollback;
-```
-
-For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing).
-
## RLS reference
These are the functions and patterns available inside a policy expression.
@@ -522,6 +677,8 @@ to authenticated
using ( (select private.has_good_role()) );
```
+Add member and non-member cases to that table's file under `supabase/tests/`. A member who is not the owner must be allowed; a non-member must not.
+
Set `search_path = ''` on every `security definer` function and schema-qualify the names inside it. Without a pinned `search_path`, a caller can point an unqualified name at their own object and run it with the function owner's privileges.
@@ -530,6 +687,66 @@ A `security definer` function in an exposed schema is callable over the Data API
+### Avoid recursive policies
+
+Two tables whose policies read each other never resolve. Postgres raises `42P17`, `infinite recursion detected in policy for relation`, and the query fails for every role the policies apply to.
+
+Sharing features produce this shape. A policy on `lists` checks `list_members` to find who the list is shared with, and a policy on `list_members` checks `lists` to find who owns it:
+
+```sql
+-- Reject: each policy reads the table the other one protects.
+create policy "members read lists" on lists for select
+to authenticated
+using (
+ exists (
+ select 1 from list_members m
+ where m.list_id = lists.id and m.user_id = (select auth.uid())
+ )
+);
+
+create policy "members read membership" on list_members for select
+to authenticated
+using (
+ exists (
+ select 1 from lists l
+ where l.id = list_members.list_id and l.owner_id = (select auth.uid())
+ )
+);
+```
+
+Break the cycle with a [security definer function](#use-security-definer-functions). It reads the membership table as its owner, so the second policy never runs and the cycle is broken:
+
+```sql
+create schema if not exists private;
+
+create function private.user_list_ids()
+returns setof uuid
+language sql
+security definer
+set search_path = ''
+stable
+as $$
+ select list_id from public.list_members
+ where user_id = (select auth.uid())
+$$;
+
+revoke execute on function private.user_list_ids() from public;
+grant usage on schema private to authenticated;
+grant execute on function private.user_list_ids() to authenticated;
+
+create policy "members read lists" on lists for select
+to authenticated
+using ( id in (select private.user_list_ids()) );
+
+create policy "members read membership" on list_members for select
+to authenticated
+using ( list_id in (select private.user_list_ids()) );
+```
+
+The function filters on `(select auth.uid())`, so it returns only the caller's lists. A member who doesn't own the list still reads it, and a non-member reads nothing.
+
+This works because the function runs as its owner, and a `security definer` function only skips RLS when its owner can. On Supabase the owner is `postgres`, which has `bypassrls`. A function owned by a role without `bypassrls`, or reading a table set to `force row level security`, evaluates the membership policy again and stays recursive.
+
### Bypassing Row Level Security
Use a [secret key](/docs/guides/getting-started/api-keys) for administrative tasks that need to bypass RLS. A secret key authorizes access through the `service_role` Postgres role, which has the `bypassrls` attribute. Never use a secret key in the browser or expose it to customers.