Enabling Row Level Security is the easy part. Writing the policies is where people stall, disable RLS to make the app work, and leave their database open. So here are the policies for the common cases, ready to adapt. The pattern is almost always the same: scope every row to its owner using auth.uid().
Enable RLS first
alter table public.notes enable row level security;
Once this is on, the table denies everything until you add policies. That strictness is the point; you open access deliberately, one operation at a time.
Scan your own app for issues like these
Paste your live URL. We check what your app serves publicly for exposed keys and misconfigurations. No account, no install.
Let users read only their own rows
create policy "read own notes" on public.notes for select using ( auth.uid() = user_id );
using filters which existing rows the request can see. Here, a user can select a note only if its user_id matches their authenticated id. An anonymous request has no auth.uid(), so it sees nothing, which is exactly what you want.
Let users create rows that belong to them
create policy "insert own notes" on public.notes for insert with check ( auth.uid() = user_id );
with check validates new rows. This stops a user from inserting a note with someone else's user_id. Insert policies use with check, not using, because there is no existing row to filter yet.
Let users update and delete only their own rows
create policy "update own notes" on public.notes for update using ( auth.uid() = user_id ) -- which rows they can touch with check ( auth.uid() = user_id ); -- and can't reassign to someone else create policy "delete own notes" on public.notes for delete using ( auth.uid() = user_id );
The update policy uses both clauses: using limits which rows the user may change, and with check stops them from changing a row's owner to escape the rule. This pair is the most common place a subtle hole slips in, so it is worth getting exactly right.
Public read, authenticated write (blog or content pattern)
-- Anyone can read published posts; only the author can insert or update
create policy "public can read posts"
on public.posts for select
using ( published = true );
create policy "author can insert"
on public.posts for insert
with check ( auth.uid() = author_id );
create policy "author can update own posts"
on public.posts for update
using ( auth.uid() = author_id )
with check ( auth.uid() = author_id );This is the pattern for any content with public visibility: blogs, docs, product listings. The published = true condition means draft rows are never exposed even through a public select.
Team or organization scoped access (multi-tenant)
-- Users can only see rows that belong to their organization
-- Assumes a separate 'members' table linking user_id to org_id
create policy "org members can read"
on public.projects for select
using (
org_id in (
select org_id from public.members
where user_id = auth.uid()
)
);
create policy "org members can insert"
on public.projects for insert
with check (
org_id in (
select org_id from public.members
where user_id = auth.uid()
)
);For multi-tenant apps, scope rows to the user's organization rather than directly to their user id. The subquery runs per row but Supabase optimizes it. If performance is a concern, add an index on members(user_id).
Admin bypass using a custom JWT claim
-- Give admins access to all rows using a custom claim in the JWT
-- Set 'app_metadata: { role: "admin" }' on the user in the Supabase Auth dashboard
create policy "admins can read all"
on public.orders for select
using (
auth.uid() = user_id -- normal users see own rows
or (auth.jwt() ->> 'role') = 'admin' -- admins see everything
);auth.jwt() reads the raw JWT claims. You set the role claim in the user's app_metadata via the Supabase Auth admin API or dashboard — users cannot set app_metadata themselves, which is what makes it safe to use for privilege escalation.
Debugging a failing RLS policy
When a query returns nothing or errors unexpectedly with RLS enabled, run this in the Supabase SQL editor to see what the policy evaluates to for a specific user:
-- Temporarily set the auth context to a specific user and run the query
set local role authenticated;
set local "request.jwt.claims" to '{"sub": "your-user-uuid-here", "role": "authenticated"}';
select * from public.notes;
-- should return only that user's rowsAlso useful: explain (analyze, verbose) select * from public.notes will show the RLS filter being applied. And check the Supabase dashboard under Authentication, Policies to confirm the policy is attached to the right table and operation.
Then confirm it holds
Do not trust that RLS works because the app works. Prove the disallowed path is closed: with your public anon key (not the service_role key), try to read the table as a logged-out user and confirm you get an empty result. The step-by-step check is in how to check if your Supabase database is exposed, and the full launch checklist is in the Supabase RLS checklist.
Frequently asked questions
Do I need a policy for every operation?
Yes. Once Row Level Security is enabled on a table, it denies everything by default, and each operation you want to allow — select, insert, update, delete — needs its own policy. A common mistake is enabling RLS and writing only a select policy, which then blocks inserts and confuses people into disabling RLS again. Write a policy per operation you actually need.
What's the difference between USING and WITH CHECK?
USING decides which existing rows a request can see or act on, so it applies to select, update, and delete. WITH CHECK decides which new or changed rows are allowed, so it applies to insert and update. For an update you often want both: USING to limit which rows the user can touch, and WITH CHECK to stop them from reassigning a row to someone else. Getting these right is the core of a safe policy.
What is auth.uid() and why is it everywhere?
auth.uid() returns the id of the currently authenticated user, taken from their JWT, and it is the anchor for owner-scoped policies. A policy like 'using (auth.uid() = user_id)' means a request can only touch rows whose user_id matches the logged-in user. Because the id comes from the verified token and not from the request body, users cannot spoof it.
How do I test that my policies work?
Test the disallowed path, not just the allowed one. Using your public anon key as a logged-out or wrong user, try to read a table you should not be able to read and confirm you get nothing back. Then log in and confirm you can only see your own rows. If an anonymous request ever returns real rows, a policy is missing or too permissive.
Does the service_role key bypass RLS?
Yes. A Supabase client initialized with the service_role key ignores all RLS policies — it has full admin access to every table. This is intentional for trusted server-side operations like migrations or admin tasks, but it means any route that uses the service_role client skips your policies entirely. Use the anon key or a user-session client for queries that should be subject to RLS.
Why is my RLS policy blocking inserts when I have a select policy?
Because each operation needs its own policy. Enabling RLS and adding a 'for select' policy does nothing to allow inserts — they are blocked by default. Add a separate 'for insert with check (...)' policy for the insert operation. If you are using the Supabase dashboard's 'Enable RLS' toggle and applying a template, check which operations the template covers.
How do I allow public (unauthenticated) read access in Supabase?
Create a select policy with 'using (true)' — this allows any request, including anonymous requests with no session, to read the table. Only use this for genuinely public data like blog posts or product listings. Never use 'using (true)' on a table that contains user data, payment info, or anything private.
Check your policies actually protect the data
The gap between RLS enabled and RLS configured correctly is where data leaks. A scan finds your exposed Supabase key and, for your own verified app, checks whether an anonymous request can still read your tables. Run a free scan and confirm your policies are doing their job.