Files
supabase/apps/docs/content/guides/database/postgres/row-level-security.mdx
Miranda Limonczenko 21265b2e59 docs(database): make writing and running the tests part of the procedure (#49276)
Ref DOCS-1274

Follow-up to #49017, now merged.

This is the go-to-green piece: everything aimed at the three failing
eval checks, and nothing else. Technical corrections follow in the PR
stacked on this one.

## Problem

`build-docs-002-rls-guide` points an agent at this guide with a
vibe-coder prompt that never says RLS, policy, role, or test. Grants,
policies, access probes, indexes, and security-definer placement all
pass. Three checks fail, and have failed on every recorded run:

| Check | What it measures | Why it failed |
| --- | --- | --- |
| `pgTAP test file(s) written under supabase/tests/` | Any `.sql` file
exists | The agent never wrote one. |
| `supabase test db runs at least 8 assertions and all pass` | Suite
runs, ≥8 assertions, none failing | Nothing to run. The only example was
`plan(4)`, under the floor even if copied perfectly. |
| `tests assert allow and deny per operation … for anon and
authenticated` | LLM judge on coverage | Never reached the judge: "no
test files to review". |

The guide already had a `Test your policies` section, so this isn't a
strength problem. Agents don't read the page. They fetch it through an
LLM extraction guided by their own query, and that query asked for
enabling RLS, policy syntax, `auth.uid()`, indexes, and security definer
functions. It never mentioned tests. A section about testing never
enters the extract, so more testing prose cannot reach the agent.

There was also a plain documentation bug underneath it: `Secure a table
with RLS` said a table isn't secured until the suite passes, but the
procedure beneath it ran 1–3 and ended on `grant`. A reader following
the numbered steps finished without ever being told to write a test.

## Solution

Put the tests where the procedure and the examples already are.

- **`Secure a table with RLS`** opens with the four steps that finish a
table, ending on `supabase test db`. Until the suite passes, you don't
know whether the policies do what you intended.
- **`Enable RLS and set the grants` gains step 4** — `supabase test new
<table>_rls.test`, then `supabase test db`. The procedure ends on a
passing suite instead of a grant.
- **The public-read example** gains its policy and
`announcements_rls.test.sql`, so a test file rides along in the
enable-RLS extract.
- **The four policy examples** are followed immediately by
`profiles_rls.test.sql`, so one rides along in the `create policy`
extract too.
- **`Run the test suite` shrinks** to creating and running the files. It
no longer carries content that has to survive extraction.
- Each file leads with its own path as a comment, so it survives if the
fence metadata is dropped.

### How that maps to the three checks

| Check | Addressed by |
| --- | --- |
| Test files written | A complete test file now sits inside both
extracts an agent's own query pulls, and step 4 of the procedure names
the command that creates one. |
| ≥8 assertions, all passing | `announcements_rls.test.sql` is
`plan(10)`, `profiles_rls.test.sql` is `plan(14)`. Either alone clears
the floor; together, 24. |
| Coverage judge | `profiles` asserts allow **and** deny for all four
operations. Allowed writes use `returning` + `results_eq`, proving state
changed rather than that nothing raised. `using`-filtered denials use
`is_empty`, asserting the row is unchanged rather than that an error was
raised — the case the rubric explicitly fails suites for getting wrong.
Both files switch role with `set local role` and identity with `set
local request.jwt.claim.sub`, and cover `anon` as well as
`authenticated`. |



## Manual testing

1. Open the [Row Level Security
guide](https://docs-git-docs-rls-tests-in-procedure-supabase.vercel.app/docs/guides/database/postgres/row-level-security)
on the preview. `Secure a table with RLS` opens with a four-step
definition of done ending on `supabase test db`.
2. Read `Enable RLS and set the grants`. The procedure runs 1–4 and ends
on writing and running the test, not on the grant.
3. Scroll to `DELETE policies`. The four policies are followed
immediately by `profiles_rls.test.sql`, not a pointer to a later
section.
4. Open the [markdown
version](https://docs-git-docs-rls-tests-in-procedure-supabase.vercel.app/docs/guides/database/postgres/row-level-security.md),
which is what agents fetch. Both test files are present, each leading
with its path.


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

## Documentation

- Updated database security guidance for enabling row-level security and
configuring grants.
- Added per-table pgTAP testing requirements and revised `supabase test
db` examples.
- Expanded examples for permitted and denied access across public and
authenticated roles.
- Added dedicated guidance for profile testing and security-definer
member/non-member cases.
- Documented recursive-policy `42P17` failures and the security-definer
workaround.
- Clarified indexing, denial diagnosis, returned-row verification, and
table-hardening links.
- Streamlined the general policy-testing guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:23:33 -07:00

778 lines
30 KiB
Plaintext

---
id: 'row-level-security'
title: 'Row Level Security'
description: 'Secure your data using Postgres Row Level Security.'
subtitle: 'Secure your data using Postgres Row Level Security.'
---
Postgres [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) gives you granular authorization rules that run inside the database.
<Admonition type="danger">
A table in an exposed schema without RLS is readable and writable by any role with a grant on it. Enable RLS on every table in an exposed schema. On projects that still grant `anon` and `authenticated` by default, revoke those grants. Adding policies doesn't remove them.
</Admonition>
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: 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.
## Understand Row Level Security
### What a policy does
[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.
Think of a policy as adding a `WHERE` clause to every query. A policy like this:
```sql
create policy "Individuals can view their own todos."
on todos for select
to authenticated
using ( (select auth.uid()) = user_id );
```
That policy translates to this whenever a user selects from the todos table:
```sql
select *
from todos
where auth.uid() = todos.user_id;
-- Policy is implicitly added.
```
You write RLS rules in SQL, so a rule can express whatever access logic your app needs. Because RLS is a Postgres primitive, it also protects your data when it is reached through third-party tooling, which is what makes it "[defense in depth](<https://en.wikipedia.org/wiki/Defense_in_depth_(computing)>)". Combine RLS with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database.
### Grants and policies
Postgres runs two checks before a client touches a table. Grants decide whether a role can run an operation on the table at all. Policies decide which rows that operation applies to. Set both for every table you expose.
On existing projects, a new table in `public` starts with every privilege already granted to all three roles:
| Role | Granted automatically | What it should keep |
| --------------- | -------------------------------------- | ------------------------------------------------------- |
| `anon` | `select`, `insert`, `update`, `delete` | Only what signed-out visitors are meant to read |
| `authenticated` | `select`, `insert`, `update`, `delete` | Only the operations your app exposes to signed-in users |
| `service_role` | `select`, `insert`, `update`, `delete` | Full access. It bypasses RLS, so keep it server-side |
Adding policies doesn't take those grants back. A table protected only by policies still hands `anon` an insert path if you never revoke the grant.
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 [Enable RLS and set the grants](#enable-rls-and-set-the-grants).
### Authenticated and unauthenticated roles
Supabase maps every request to one of the roles:
- `anon`: an unauthenticated request (the user is not logged in)
- `authenticated`: an authenticated request (the user is logged in)
These are [Postgres Roles](/docs/guides/database/postgres/roles). You can use these roles within your Policies using the `TO` clause:
```sql
create policy "Profiles are viewable by everyone"
on profiles for select
to authenticated, anon
using ( true );
-- OR
create policy "Public profiles are viewable only by authenticated users"
on profiles for select
to authenticated
using ( true );
```
<Admonition type="note" title="Anonymous user vs the anon key">
Using the `anon` Postgres role is different from an [anonymous user](/docs/guides/auth/auth-anonymous) in Supabase Auth. An anonymous user assumes the `authenticated` role to access the database and can be differentiated from a permanent user by checking the `is_anonymous` claim in the JWT.
</Admonition>
A policy that reads `to anon using ( true )` grants every unauthenticated visitor read access to every row the role can already reach through grants. Use it only for data that is meant to be public.
### Views and RLS
Views bypass RLS by default because they are usually created with the `postgres` user. This is a feature of Postgres, which automatically creates views with `security definer`. A view over a protected table hands out every row its policies were meant to withhold, so a view needs the same attention as a table. To create one safely, see [Expose a view safely](#expose-a-view-safely).
## Secure a table with RLS
Follow these steps for every table in an exposed schema:
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.
Enable RLS, then set the grants to match what each role does in your app:
1. Enable RLS on the table.
```sql
alter table public.reports enable row level security;
```
Once RLS is enabled, no data is accessible through the [API](/docs/guides/api) when using a publishable key, until you create policies.
2. Revoke any existing grants from both client roles.
```sql
revoke all on table public.reports from anon, authenticated;
```
3. Grant back only the privileges the role needs.
```sql
-- Signed-in users manage reports. Signed-out visitors get nothing.
grant select, insert, update, delete on table public.reports to authenticated;
```
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
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 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.
These examples use a `profiles` table where each user manages only their own row:
```sql
create table profiles (
id uuid primary key,
user_id uuid references auth.users,
avatar_url text
);
alter table profiles enable row level security;
revoke all on table profiles from anon, authenticated;
grant select, insert, update, delete on table profiles to authenticated;
```
Supabase provides [helper functions](#helper-functions) that simplify RLS if you are using Supabase Auth. `auth.uid()` returns the ID of the user making the request.
#### SELECT policies
You can specify select policies with the `using` clause.
```sql
create policy "Users can view their own profile."
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 adheres to the policy constraints, so a user cannot create a row that belongs to someone else.
```sql
create policy "Users can create their own profile."
on profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
```
#### UPDATE policies
You can specify update policies by combining the `using` and `with check` expressions. The `using` clause decides which existing rows can be updated. The `with check` clause decides what the resulting row is allowed to look like, which stops a user from reassigning `user_id` to someone else.
```sql
create policy "Users can update their own profile."
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id ) -- checks the existing row
with check ( (select auth.uid()) = user_id ); -- checks the resulting row
```
If no `with check` expression is defined, the `using` expression decides both which rows are visible and which new rows are allowed.
<Admonition type="caution">
To perform an `UPDATE` operation, a corresponding [`SELECT` policy](#select-policies) is required. Without a `SELECT` policy, the `UPDATE` operation will not work as expected.
</Admonition>
#### DELETE policies
You can specify delete policies with the `using` clause.
```sql
create policy "Users can delete their own profile."
on profiles for delete
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/<table>_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:
```sql
create policy "rls_test_select" on rls_test
using ( auth.uid() = user_id );
```
Use:
```sql
create policy "rls_test_select" on rls_test
to authenticated
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.
### 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 <table>_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
Add an [index](/docs/guides/database/postgres/indexes) on every column your policies filter on. Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. For a policy like this:
```sql
create policy "rls_test_select" on test_table
to authenticated
using ( (select auth.uid()) = user_id );
```
You can add an index like:
```sql
create index userid
on test_table
using btree (user_id);
```
A column counts as indexed only when it comes first in a `btree` index. Postgres can't use a multi-column index to filter on a column that isn't the leading one, so a composite primary key indexes its first column and no others. A membership table keyed on `(team_id, user_id)` has no index on `user_id`:
```sql
create table team_members (
team_id uuid references teams (id),
user_id uuid references auth.users (id),
primary key (team_id, user_id)
);
-- The primary key covers team_id. A policy filtering on user_id needs its own index.
create index team_members_user_id_idx
on team_members
using btree (user_id);
```
### Call functions with `select`
You can use `select` statement to improve policies that use functions. For example, instead of this:
```sql
create policy "rls_test_select" on test_table
to authenticated
using ( auth.uid() = user_id );
```
You can do:
```sql
create policy "rls_test_select" on test_table
to authenticated
using ( (select auth.uid()) = user_id );
```
This method works well for JWT functions like `auth.uid()` and `auth.jwt()` as well as `security definer` Functions. Wrapping the function causes an `initPlan` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row.
<Admonition type="caution">
You can only use this technique if the results of the query or function do not change based on the row data.
</Admonition>
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`.
```sql
create view <VIEW_NAME>
with(security_invoker = true)
as select <QUERY>
```
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.
## RLS reference
These are the functions and patterns available inside a policy expression.
### Helper functions
Supabase provides some helper functions that make it easier to write policies.
#### `auth.uid()`
Returns the ID of the user making the request.
<Admonition type="caution" title="`auth.uid()` Returns `null` When Unauthenticated">
When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`.
This means that a policy like:
```sql
USING (auth.uid() = user_id)
```
will silently fail for unauthenticated users, because:
```sql
null = user_id
```
is always false in SQL.
To avoid confusion and make your intention clear, we recommend explicitly checking for authentication:
```sql
USING (auth.uid() IS NOT NULL AND auth.uid() = user_id)
```
</Admonition>
#### `auth.jwt()`
<Admonition type="caution">
Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the `user_metadata` claim can create security issues in your application as this information can be modified by authenticated end users.
</Admonition>
Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two:
- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data.
- `raw_app_meta_data` - cannot be updated by the user, so it's a good place to store authorization data.
The `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs:
```sql
create policy "User is in team"
on my_table
to authenticated
using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams'));
```
<Admonition type="caution">
Keep in mind that a JWT is not always up-to-date. In the team policy example, even if you remove a user from a team and update the `app_metadata` field, that will not be reflected using `auth.jwt()` until the user's JWT is refreshed.
Also, if you are using Cookies for Auth, then you must be mindful of the JWT size. Some browsers are limited to 4096 bytes for each cookie, and so the total size of your JWT should be small enough to fit inside this limitation.
</Admonition>
#### MFA
The `auth.jwt()` function can be used to check for [Multi-Factor Authentication](/docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2):
```sql
create policy "Restrict updates."
on profiles
as restrictive
for update
to authenticated using (
(select auth.jwt()->>'aal') = 'aal2'
);
```
### Use security definer functions
A "security definer" function runs using the same role that _created_ the function. This means that if you create a role with a superuser (like `postgres`), then that function will have `bypassrls` privileges. For example, if you had a policy like this:
```sql
create policy "rls_test_select" on test_table
to authenticated
using (
exists (
select 1 from roles_table
where (select auth.uid()) = user_id and role = 'good_role'
)
);
```
We can instead create a `security definer` function which can scan `roles_table` without any RLS penalties:
```sql
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 public.roles_table
where (select auth.uid()) = user_id and role = 'good_role'
);
end;
$$;
-- Update our policy to use this function:
create policy "rls_test_select"
on test_table
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.
<Admonition type="caution">
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>
### 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.
The JWT-based `service_role` key is a legacy alternative. Prefer a secret key where possible.
<Admonition type="note">
A secret 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 secret key.
</Admonition>
You can also create new [Postgres Roles](/docs/guides/database/postgres/roles) which can bypass Row Level Security using the "bypass RLS" privilege:
```sql
alter role "role_name" with bypassrls;
```
This can be useful for system-level access. **Never** share login credentials for any Postgres Role with this privilege.
## Related content
- [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance): diagnose whether policies are your bottleneck, and tune ones that are already correct.
- [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended): schema-wide RLS test helpers and a worked multi-tenant example.
- [Testing your database](/docs/guides/database/testing): the CLI test workflow that `supabase test db` runs.
- [Securing your API](/docs/guides/api/securing-your-api): grants, dedicated schemas, and pre-request checks around the Data API.
- [Column Level Security](/docs/guides/database/postgres/column-level-security): restrict access to individual columns.
- [`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main): a community extension that adds user creation and role impersonation helpers to pgTAP.