Free fix guides
Supabase · Security · 5-minute fix

How to turn on Row Level Security in Supabase

Your Supabase anon key is public — it's sitting in your site's JavaScript right now, by design. Row Level Security (RLS) is the only thing deciding whether that key opens one user's rows or your entire database. If you shipped with an AI builder, there's a good chance it's off. Here's how to check, turn it on, and prove it works.

⏱ 6 min read Fix time: ~5 min per table Works: Dashboard or SQL Updated Oct 2026

The 10-second version

RLS off + no policies to save you = anyone on the internet can run one curl command with your public anon key and download every row in the table — emails, profiles, orders, all of it. No hacking required. It's the single most common way vibe-coded apps leak data, and Supabase shows a red “RLS is not enabled” warning for exactly this reason. The fix below takes about five minutes per table.

What RLS actually does

Every request to your database passes through your policies. No RLS means no gate at all.

Anyone + anon key it's in your page source no gate Entire table returned every user, every row ✗ RLS OFF Signed-in user + anon key + their JWT Policy: auth.uid() = user_id only their rows pass Just their data ✓ ✓ RLS ON + POLICY

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 — 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.


    
⚠ How to read the result

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 — still finish the guide to check the signed-in case. 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:

Turning RLS on with zero policies = locked door, no keys

The moment RLS is enabled and a table has no policies, the anon and authenticated roles can do nothing with it — reads return empty, 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 — it's the anchor for almost every real-world policy. In the dashboard: Authentication → Policies → your table → New policy. Pick the pattern that matches:

Your situationPolicy patternWho gets what
Private user data — profiles, notes, orders, dashboards“Users can view their own rows” — auth.uid() = user_idEach signed-in user sees and edits only rows where the owner column is them
Public read, private write — blog posts, product listings, public profilesSELECT for everyone; INSERT/UPDATE only for the ownerVisitors can browse; nobody can create or change someone else's content
Logged-in only — internal tools, community dataTO authenticated with USING (true)Anonymous visitors get nothing; any signed-in user gets access
Public content table — truly public reference dataSELECT for everyone, no write policies at allReadable 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:

No 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 — 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 — a wrong column name or a missing INSERT policy just looks like an empty app. Run all three tests:

  1. 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.
  2. 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 un-editable everywhere — lists, detail pages, direct URLs.
  3. Try creating data while signed in. If inserts fail with new row violates row-level security policy, your INSERT policy's WITH CHECK doesn't match what the app sends — usually the app isn't setting user_id, or sets it to something other than auth.uid().
✓ What “done” looks like

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

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.

Fixed RLS? Now scan the rest of your launch.

ShipReady scans your live site — no code upload — for the other vibe-code blockers: leaked OpenAI & Stripe keys in the client bundle, unprotected API routes, and the missing OG image. Secret findings stay locked until you verify the site is yours. First scan is free.

Scan my live site free → Free scan · No card · No repo access — URL only

Quick answers

Is the Supabase anon key a secret?

No — it's designed to be public and ships in every visitor's browser. Its safety comes entirely from RLS. Without RLS, “public key” effectively means “public database.” With correct policies, the same key is fine to expose, because Postgres enforces the rules on every request regardless of where the key came from.

I turned RLS on and my app went empty. Did I break it?

Almost certainly not — that's RLS working with no policies yet. An empty result (not an error) is what “no policy allows this” looks like for reads. Add the owner policies from section 3/4 and your data comes back for the right users. Your rows were never deleted.

Do I need RLS if my app has no login?

If the table holds anything you wouldn't paste in a public tweet — submissions, emails, waitlist signups — yes. Either enable RLS with a narrow policy (e.g. INSERT-only for anon, no SELECT) or don't let the browser talk to the table directly at all and go through a server route you control instead.

Does RLS slow my app down?

Simple policies like auth.uid() = user_id add negligible overhead — it's an indexed comparison Postgres is built for. Problems only appear with policies that call unindexed functions or subquery other tables per-row. For a typical vibe-coded app, you will never notice it.