Supabase RLS: how to protect user data
RLS solves a simple but critical question: why User A cannot read or modify User B's data, even if they manually send a request to your API.

RLS is a filter within the database itself
Row Level Security, or row-level security, is a PostgreSQL mechanism. A policy is attached to a table and checked every time a query reads, creates, modifies, or deletes data. It is best imagined as an additional condition WHERE, which the database applies regardless of the frontend.
This is important for Vibe Coding. AI might hide other people's records in buttons and the interface, but a hidden button is not protection. A user can open DevTools, change an ID in a request, or access the Data API directly. If the database does not verify the row owner, a beautiful application leaves the data exposed.
When RLS is enabled but there is no allowing policy, for standard client roles the principle applies: default deny: the row is inaccessible. The table owner and roles with bypassrls — are a separate case. Therefore, a successful query in the SQL Editor does not yet prove that the rules work for the user.
Three levels that must not be confused
| Level | What it decides | Typical mistake |
|---|---|---|
GRANT | Whether a role can perform SELECT, INSERT, UPDATE, or DELETE at all. | The policy is correct, but the operation fails with 42501, because permissions were not granted. |
| RLS policy | To exactly which rows the operation is allowed. | using (true) accidentally exposes all rows for a role that already has GRANT. |
| Application logic | What to show the user and which query to send. | The interface hides the data, but the database serves it via a direct query. |
Official Supabase documentation recommends configuring GRANT and RLS together. A policy does not automatically revoke extra permissions. A reliable approach: remove all client permissions, return only the necessary operations, and then restrict rows with separate policies.
Step 1. Creating the table and closing unnecessary access
Example — private notes. The field user_id references Supabase Auth, does not allow null and has an index. It is better to save the code as a migration so that the rules can be reproduced in test and production environments.
create table public.notes (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
body text not null,
created_at timestamptz not null default now()
);
create index notes_user_id_idx on public.notes(user_id);
alter table public.notes enable row level security;
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete
on table public.notes to authenticated;
Role anon received nothing because the notes are private. The role authenticated has four operations but does not yet have access to any rows: the policies will grant that. If the table only needs to be read, do not grant write access "just in case".
Step 2. A separate policy for each operation
Supabase Auth adds the user ID to the request. The function auth.uid() returns its UUID, and without an active session — null. Restrictions to authenticated filter out requests from an unauthorized role.
create policy "Users read own notes"
on public.notes for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users create own notes"
on public.notes for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users update own notes"
on public.notes for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users delete own notes"
on public.notes for delete
to authenticated
using ((select auth.uid()) = user_id);
USING checks the existing row: whether it can be seen, modified, or deleted. WITH CHECK checks the new state. Therefore, UPDATE has both conditions: a user can only take their own note and cannot reassign it to another UUID during editing.
Step 3. Checking allowed and forbidden scenarios
The most dangerous test is to log in as the owner, see your note, and decide everything is fine. Protection is only proven when another user cannot retrieve the same row. Create two test accounts: A and B, without real personal data.
| Scenario | Expected result |
|---|---|
A guest without a session reads notes | No access: for anon no GRANT or policy was issued. |
A creates a row with their user_id | Success. |
| A substitutes user B's UUID | INSERT rejected due to WITH CHECK. |
| B requests A's note ID | Row is not returned. |
| B modifies or deletes A's note | No foreign row is modified or deleted. |
A changes user_id notes to B | UPDATE rejected due to WITH CHECK. |
For repeatable verification, Supabase recommends SQL tests in supabase/tests/:
supabase test new notes_rls.test
supabase test db
In tests, check four operations for the owner, an external user, and a guest. If available in your Dashboard, RLS Tester (Feature Preview), it is convenient to quickly perform a SELECT as a specific role and see which policies were triggered. This is a diagnostic tool, not a replacement for negative SQL tests: separately verify INSERT, UPDATE, DELETE, JWT, and the client request.
After changing the schema, run Security Advisor: it finds RLS disabled in public, dangerous views, references to mutable user metadata, and other specific issues.
Seven mistakes that prevent RLS from saving you
- RLS was not enabled on all public tables. One helper table in an exposed schema can leak connections, emails, or internal statuses.
- Left
using (true). For SELECT, this means "all rows" for the specified role. This should only be done with truly public data. - Checking the role in
user_metadata. A user can change this metadata via the Auth API. Keep authorization data in a protected table orapp_metadata. - Inserted a secret or
service_roleinto the frontend. This key bypasses RLS. If leaked, it must be revoked immediately. - Testing only via SQL Editor. Administrative roles can bypass RLS. Test the actual role, JWT, and two different users.
- Protected the table, but exposed the view. A standard view often runs with owner privileges and can bypass base table policies.
- Added a policy, but forgot the GRANT. Or removed all operations, or restricted rows with an overly broad condition. These are different checks.
Views, roles, and teamwork
In PostgreSQL 15+, for a view that needs to execute base table policies on behalf of the user, apply security_invoker:
create view public.my_note_counts
with (security_invoker = true)
as
select user_id, count(*) as total
from public.notes
group by user_id;
Do not build roles solely inside the JWT without a session update plan: the token does not change instantly after editing app_metadata. For sensitive changes, account for the delay until the JWT updates or verify the state in a table.
security definer-functions are sometimes needed for complex team models and to avoid recursive policies, but they run with owner privileges. Keep them in a non-public schema, use search_path = '', write full schema names, and grant EXECUTE only to the necessary role. For your first project, it is safer to start with direct owner policies.
To prevent policies from slowing down a large table
Security first, optimization after correctness. But two things should be done immediately. First, index the columns used by the policy filter: in this example it is user_id. Second, use (select auth.uid()), if the value does not depend on the row. PostgreSQL can compute the helper once per query rather than repeating it for every row.
Do not disable RLS for speed. Check the query plan, indexes, and Performance Advisor. If a policy performs nested team membership checks, index the foreign keys and relationship columns.
Prompt for checking an AI-generated schema
Pass the structure to the assistant without real records, keys, or personal data. Ask it not just to "add RLS," but to create a threat model and negative tests.
Pre-production checklist
- RLS is enabled on every table accessible via the Data API.
anonandauthenticatedhave only the necessary GRANTS.- Explicit policies are created for SELECT, INSERT, UPDATE, and DELETE.
- The owner cannot reassign a row to another user.
- Other users cannot read or modify someone else's rows.
- Secrets and
service_roleexist only on the server. - Views and
security definer-functions are verified separately. - Policy and relationship columns are indexed.
- RLS Tester is used only as additional diagnostics, not as the sole test.
- Security Advisor shows no uncorrected warnings or errors.
- RLS tests run after every migration change.
Quick answers
What is RLS in Supabase?
These are PostgreSQL rules that define which rows a specific query can read or modify. They work in the database, not just in the interface.
Is the Supabase public key safe in the browser?
Publishable or legacy anon key is calculated on the client, provided RLS and GRANTS are correct. Secrets or service_role bypass RLS and should never end up in the browser. Learn more in the article “How not to leak API keys”.
Why does UPDATE not work with the correct policy?
An UPDATE also requires a SELECT policy. USING checks the existing row, WITH CHECK — change result. If everything is correct, check the GRANT.
How to test Supabase RLS?
Check the guest, owner, and other user for all four operations. The reproducible version is SQL tests in supabase/tests/ and the command supabase test db.
Primary sources
- Supabase Docs — Row Level Security, relevant GRANT documentation, policies, and tests.
- Supabase Docs — API keys, publishable and secret keys.
- Supabase Docs — Security and Performance Advisors.
- Supabase Changelog — RLS Tester (Feature Preview), April 24, 2026.
- PostgreSQL 18 Documentation — Row Security Policies.
Doubtful about your policies?
Describe the table structure without keys and private data in the Telegram chat. We will help you create an access matrix and negative tests, but perform the final check in a separate environment.
Discuss in Telegram ↗
Discussion
Share your opinion or ask about the material. Your nickname and comment text will be visible to everyone.
Log in via email
Get a one-time code. No password required.
The code is valid for 10 minutes. Logging in does not subscribe you to the newsletter. Data usage.
Or log in via ChatGPT ↗Loading comments...