docs(database): fix RLS guide copy and two SQL examples (#49015)

Stacked on #49011. Base is `docs/rls-revision`, so review that one
first.

## Problem

An audit of the Row Level Security guide against
`apps/docs/CONTRIBUTING.md` and `WORD_LIST.md` turned up 4 lint warnings
and 3 things that are wrong rather than just untidy.

- Two SQL examples contradict the guide's own advice. The own-profile
`SELECT` policy has no `TO` clause. The `security definer` example has
no `set search_path`.
- `## Bypassing Row Level Security` says Service Keys bypass RLS, then a
note says Supabase adheres to the signed-in user's policy anyway. The
condition that separates the two is never stated.
- `#using-functions` is linked twice from the RBAC guide and has never
existed on the RLS page.

## Solution

Copy and correctness only. No section moves, no heading renames.

- Replace the italic emphasis on `never` with bold. CONTRIBUTING permits
**bold** for a term the reader must not miss, not italics for general
emphasis. The matching fix for `must` lives in #49011, which rewrites
that line anyway.
- Drop marketing language from the opener, the Supabase intro, and the
policies and performance leads. Removes the idiom "get the hang of them"
and the filler `just`.
- Replace `we` with second person in two places.
- Scope the own-profile `SELECT` example with `to authenticated`.
- Pin `search_path = ''` on the `security definer` example,
schema-qualify its body to match, and state the requirement in prose.
- State when a Service Key actually bypasses RLS.
- Repoint the two RBAC links to `#use-security-definer-functions` and
`#helper-functions`.

`supa-mdx-lint` on the RLS guide goes from 4 warnings to 0.

## Manual testing

1. Open the [Row Level Security
guide](https://docs-git-docs-rls-copy-fixes-supabase.vercel.app/docs/guides/database/postgres/row-level-security)
on the preview. The own-profile SELECT example shows `to authenticated`,
and the security definer example shows `set search_path = ''`.
2. Open the [RBAC
guide](https://docs-git-docs-rls-copy-fixes-supabase.vercel.app/docs/guides/api/custom-claims-and-role-based-access-control-rbac)
and select the "RLS helper functions" link near the end. It lands on the
Helper functions section instead of the top of the page.
3. From `apps/docs`, run `pnpm lint:mdx`. The RLS guide reports no
warnings.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Documentation**
- Updated access-control guidance with clearer links for
security-definer functions and RLS helper functions.
- Clarified that exposed tables require Row Level Security (RLS), while
table grants and row policies provide separate controls.
- Added least-privilege and grant-revocation examples, plus explanations
for authorization errors.
- Expanded testing guidance for CRUD policies, identity switching, and
denied operations.
- Improved recommendations for service keys, policy performance,
indexing, and secure function configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
authored and GitHub committed 2026-08-17 23:40:09 +00:00
1 parent d2ccbe5d46
commit 45bb7c30ce
2 files changed
+157 -17

No files matched your search

@@ -181,7 +181,7 @@ $$ language plpgsql stable security definer set search_path = '';
<Admonition type="note">
You can read more about using functions in RLS policies in the [RLS guide](/docs/guides/database/postgres/row-level-security#using-functions).
You can read more about using functions in RLS policies in the [RLS guide](/docs/guides/database/postgres/row-level-security#use-security-definer-functions).
</Admonition>
@@ -219,5 +219,5 @@ You now have a robust system in place to manage user roles and permissions withi
- [Auth Hooks](/docs/guides/auth/auth-hooks)
- [Row Level Security](/docs/guides/database/postgres/row-level-security)
- [RLS Functions](/docs/guides/database/postgres/row-level-security#using-functions)
- [RLS helper functions](/docs/guides/database/postgres/row-level-security#helper-functions)
- [Next.js Slack Clone Example](https://github.com/supabase/supabase/tree/master/examples/slack-clone/nextjs-slack-clone)
@@ -5,7 +5,7 @@ description: 'Secure your data using Postgres Row Level Security.'
subtitle: 'Secure your data using Postgres Row Level Security.'
---
When you need granular authorization rules, nothing beats Postgres's [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html).
Postgres [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) gives you granular authorization rules that run inside the database.
## Row Level Security in Supabase
@@ -31,15 +31,15 @@ Policies alone don't do this. See [Grants and policies](#grants-and-policies).
</Admonition>
RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database.
You write RLS rules in SQL, so a rule can express whatever access logic your app needs. Combine RLS with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database.
RLS is a Postgres primitive and can provide "[defense in depth](<https://en.wikipedia.org/wiki/Defense_in_depth_(computing)>)" to protect your data from malicious actors even when accessed through third-party tooling.
## Policies
[Policies](https://www.postgresql.org/docs/current/sql-createpolicy.html) are Postgres's rule engine. Policies are easy to understand once you get the hang of them. Each policy is attached to a table, and the policy is executed every time a table is accessed.
[Policies](https://www.postgresql.org/docs/current/sql-createpolicy.html) are Postgres's rule engine. Each policy is attached to a table, and the policy is executed every time a table is accessed.
You can just think of them as adding a `WHERE` clause to every query. For example a policy like this ...
Think of a policy as adding a `WHERE` clause to every query. For example, a policy like this:
```sql
create policy "Individuals can view their own todos."
@@ -47,7 +47,7 @@ on todos for select
using ( (select auth.uid()) = user_id );
```
.. would translate to this whenever a user tries to select from the todos table:
That policy translates to this whenever a user tries to select from the todos table:
```sql
select *
@@ -217,7 +217,7 @@ Using the `anon` Postgres role is different from an [anonymous user](/docs/guide
Policies are SQL logic that you attach to a Postgres table. You can attach as many policies as you want to each table.
Supabase provides some [helpers](#helper-functions) that simplify RLS if you're using Supabase Auth. We'll use these helpers to illustrate some basic policies:
Supabase provides some [helpers](#helper-functions) that simplify RLS if you're using Supabase Auth. The examples below use these helpers.
### SELECT policies
@@ -247,15 +247,16 @@ Alternatively, if you only wanted users to be able to see their own profiles:
```sql
create policy "User can see their own profile only."
on profiles
for select using ( (select auth.uid()) = user_id );
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
```
### INSERT policies
You can specify insert policies with the `with check` clause. The `with check` expression ensures that any new row data adheres to the policy constraints.
Say you have a table called `profiles` in the public schema and you only want users to create a profile for themselves. In that case, we want to check their User ID matches the value that they are trying to insert:
Say you have a table called `profiles` in the public schema and you only want users to create a profile for themselves. In that case, check that their user ID matches the value they are trying to insert:
```sql
-- 1. Create table
@@ -408,7 +409,7 @@ Supabase provides special "Service" keys, which can be used to bypass RLS. These
<Admonition type="note">
Supabase will adhere to the RLS policy of the signed-in user, even if the client library is initialized with a Service Key.
A Service Key bypasses RLS only when the request carries no user access token. If the request carries one, it runs under the RLS policies of that signed-in user, even when the client library was initialized with a Service Key.
</Admonition>
@@ -418,7 +419,143 @@ You can also create new [Postgres Roles](/docs/guides/database/postgres/roles) w
alter role "role_name" with bypassrls;
```
This can be useful for system-level access. You should _never_ share login credentials for any Postgres Role with this privilege.
This can be useful for system-level access. **Never** share login credentials for any Postgres Role with this privilege.
## 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 the tests directory and a test file:
```bash
mkdir -p supabase/tests
touch supabase/tests/profiles_rls.test.sql
```
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. Cover `anon` as well as `authenticated`.
3. Run the suite:
```bash
supabase test db
```
This example tests a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row:
```sql supabase/tests/profiles_rls.test.sql
begin;
select plan(11);
-- Seed two users. The rows come later, through the policies under test.
insert into auth.users (id, email)
values
('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
('22222222-2222-2222-2222-222222222222', 'other@example.com');
-- Signed-out visitors hold 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)
values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111')$$,
'42501',
null,
'anon cannot insert a profile'
);
-- The owner reads and writes their own row.
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$$,
array['owner.png'],
'the owner reads their own profile'
);
select results_eq(
$$update profiles set avatar_url = 'updated.png' returning avatar_url$$,
array['updated.png'],
'the owner updates their own profile'
);
-- The with check clause rejects the row, which raises.
select throws_ok(
$$insert into profiles (id, user_id)
values (gen_random_uuid(), '22222222-2222-2222-2222-222222222222')$$,
'42501',
null,
'the owner cannot create a profile for someone else'
);
-- 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 is_empty(
$$delete from profiles returning id$$,
'another user deletes no profiles'
);
-- The row is still there, still holding the owner's value.
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq(
$$select avatar_url from profiles$$,
array['updated.png'],
'the other user changed nothing'
);
select results_eq(
$$delete from profiles returning avatar_url$$,
array['updated.png'],
'the owner deletes their own profile'
);
select * from finish();
rollback;
```
For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing).
## Test your policies
@@ -558,9 +695,9 @@ For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/d
## RLS performance recommendations
Every authorization system has an impact on performance. While row level security is powerful, the performance impact is important to keep in mind. This is especially true for queries that scan every row in a table - like many `select` operations, including those using limit, offset, and ordering.
Every authorization system has an impact on performance. Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering.
Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), we have a few recommendations for RLS:
Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), these are the recommendations for RLS:
### Add indexes
@@ -688,10 +825,11 @@ create function private.has_good_role()
returns boolean
language plpgsql
security definer -- will run as the creator
set search_path = '' -- every name inside must be schema-qualified
as $$
begin
return exists (
select 1 from roles_table
select 1 from public.roles_table
where (select auth.uid()) = user_id and role = 'good_role'
);
end;
@@ -704,9 +842,11 @@ to authenticated
using ( (select private.has_good_role()) );
```
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.
<Admonition type="caution">
Security-definer functions should never be created in a schema in the "Exposed schemas" inside your [API settings](/dashboard/project/_/settings/api)`.
A `security definer` function in an exposed schema is callable over the Data API with the creator's privileges. Never create one in a schema listed under "Exposed schemas" in your [API settings](/dashboard/project/_/settings/api).
</Admonition>