1 · Check if you're exposed (30 seconds)
Don't guess. Run the exact request an attacker would run. You need three things, all public: your project URL and anon key (Supabase Dashboard → Settings → API, or just view-source on your own site, since they're in the bundle), and a table name.
Paste them into this command builder, run what it gives you in a terminal:
Build my exposure test
Nothing you type leaves this page. It just formats the command for you to run locally.
It returns rows you didn't create / other users' data → you are wide open. Do section 2 and 3 now, before anything else. It returns [] or a permission error → RLS is doing its job for anonymous visitors. Finish the guide to check the signed-in case too. It returns only rows with no owner → that's your public data behaving as designed; confirm that's really all it can reach.
Quick dashboard check, too: open Table Editor, click a table, and look for the RLS badge. In Authentication → Policies you'll see every table and whether RLS is enabled. Supabase's Security Advisor (under Advisors) also flags “Row Level Security not enabled” as an error. If it's listed there, it's real.
2 · Turn on RLS in the dashboard
1Go to your project → Table Editor → pick the table (start with the one holding user data, usually profiles, orders, messages).
2Click Edit table (or the table's ⋯ → Edit) and tick Enable Row Level Security (RLS). Save. Repeat for every table. RLS is per table, not per project, and the one you forget is the one that leaks.
3Don't stop here. This is the part people miss:
The moment RLS is enabled and a table has no policies, the anon and authenticated roles can do nothing with it: reads return empty and writes fail. That's safe, but your app will look broken (empty lists, failed saves) until you add the policies in the next section. Meanwhile your service_role key and the dashboard still see everything, which is why the app “works in the SQL editor but not in the browser.” Plan to do steps 2 and 3 in the same sitting.
3 · Add policies that match how your app actually works
A policy is a rule Postgres checks on every row, for every request. The expression auth.uid() is the signed-in user's ID from their token, and almost every real-world policy is built on it. In the dashboard: Authentication → Policies → your table → New policy. Pick the pattern that matches:
| Your situation | Policy pattern | Who gets what |
|---|---|---|
| Private user data (profiles, notes, orders, dashboards) | "Users can view their own rows": auth.uid() = user_id | Each signed-in user sees and edits only rows where the owner column is them |
| Public read, private write (blog posts, product listings, public profiles) | SELECT for everyone; INSERT/UPDATE only for the owner | Visitors can browse; nobody can create or change someone else's content |
| Logged-in only (internal tools, community data) | TO authenticated with USING (true) | Anonymous visitors get nothing; any signed-in user gets access |
| Public content table (truly public reference data) | SELECT for everyone, no write policies at all | Readable by all, writable only by you via dashboard / service role |
The one policy 90% of apps need
For a typical table with a user_id column (a UUID referencing auth.users), this single dashboard-created policy (or the SQL in section 4) covers view, create, edit and delete, each restricted to the owner:
- SELECT:
USING (auth.uid() = user_id): you can only read your rows. - INSERT:
WITH CHECK (auth.uid() = user_id): you can't create a row claiming to be someone else. INSERT has no USING. It only has WITH CHECK. - UPDATE: needs both:
USINGpicks which existing rows you may touch,WITH CHECKstops you reassigning the row to another user mid-update. - DELETE:
USING (auth.uid() = user_id): you can only delete your rows.
user_id column? Add one first
RLS needs something to check against. Add a column user_id uuid references auth.users(id), set it to auth.uid() when inserting from your app (or as a default: default auth.uid()), and backfill existing rows before enabling policies. Rows with a NULL owner match nobody and become invisible to everyone, including you in the app.
4 · Or do it all in SQL
Prefer the SQL Editor (Dashboard → SQL Editor)? This is the complete, canonical setup for one table. Swap profiles and user_id for yours and re-run it per table:
-- 1. Turn RLS on for the table
alter table public.profiles enable row level security;
-- 2. One policy per operation, owner-only
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using (auth.uid() = user_id);
create policy "Users can create their own profile"
on public.profiles for insert
to authenticated
with check (auth.uid() = user_id);
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "Users can delete their own profile"
on public.profiles for delete
to authenticated
using (auth.uid() = user_id);For the public-read pattern instead (anyone can view, only owners can write), the read policy is just:
create policy "Anyone can view posts"
on public.posts for select
to anon, authenticated
using (true);Keep the INSERT / UPDATE / DELETE policies from the block above, unchanged. And if a policy name already exists, Postgres will error, so drop policy "name" on public.profiles; first, or pick a new name.
5 · Policy generator
Tell it your table and owner column; it writes the enable + four owner-only policies for you. Paste the result into the SQL Editor and run it once.
Generate my RLS setup
Owner-only policies for the authenticated role, the safe default for user data.
6 · Test it actually works
"RLS: enabled" in the dashboard is not proof. Policies fail silently, and a wrong column name or a missing INSERT policy just looks like an empty app. Run all three tests:
- Re-run the section-1 curl as an anonymous visitor. For a private table it must now return
[]. If it still returns rows, a permissive policy (or RLS still off on that table) is letting it through. - Use the app as User A, then as User B. Create data as A in a private/incognito window; sign in as B and confirm A's rows are invisible and uneditable everywhere: lists, detail pages and direct URLs.
- Try creating data while signed in. If inserts fail with
new row violates row-level security policy, your INSERT policy'sWITH CHECKdoesn't match what the app sends. Usually the app isn't settinguser_id, or sets it to something other thanauth.uid().
Anonymous curl → empty. User B → can't see User A's rows. User A → app works exactly as before. All three, on every table with user data. That's a secured table.
7 · Traps that still bite after RLS is on
The service_role key bypasses RLS completely
That's its job. It's your server-side admin key. Which also means: if service_role is anywhere in your client code (check for NEXT_PUBLIC_ variables and your served JS bundle), RLS might as well not exist. It belongs only in server code and Edge Functions, never shipped to a browser.
New tables start unprotected
Every new table you (or your AI builder) create begins with RLS off until you enable it. Building a new feature next week re-opens the hole unless enabling RLS + policies is part of how you create tables. In SQL-first projects, include the alter table … enable row level security and policies in the same migration as the create table.
Views, storage and realtime are separate
Postgres views can expose underlying tables if they're created with owner privileges. Prefer views owned appropriately or add security_invoker = true (Postgres 15+). Supabase Storage has its own policies on storage.objects ; table RLS doesn't cover files. Check both if your app uses them.
“It worked in the dashboard” proves nothing
The Table Editor and SQL Editor run as a privileged role that bypasses RLS. Always test through the API (the curl) or the app as a real user. Never judge by what the dashboard shows you.
Before you share your link
- RLS enabled on every tableTable Editor / Advisors shows no "RLS not enabled" errors, including tables you added later.
- Every table has policiesRLS on + zero policies = locked out. RLS on + a policy per operation you use = working and safe.
- Anonymous curl returns nothing privateSection 1's test, re-run after the fix, on your most sensitive table.
- Two-account test passedUser B cannot see, edit or delete User A's rows anywhere in the app.
- service_role never reaches the browserServer-only env var, absent from client bundles and
NEXT_PUBLIC_variables.
That's this table secured. The uncomfortable question is what else shipped in the same afternoon: the Stripe secret in the bundle, the API route with no auth check, the OG image that makes your launch post look broken. Those don't show up in Supabase at all. They show up on your live site.