Supabase: lock down your database with RLS

You built your app on Supabase, wired up auth, and it all works — clean. There's one box a lot of projects skip without realizing: Row Level Security. It's not an advanced option reserved for experts; it's the step that decides who's allowed to read what. Here's the mental model, then how to set it up properly in a few minutes.

First, understand the public (anon) key

Supabase gives you two keys, and the confusion about their roles is where everything starts.

  • The anon (public) key is meant to live in the browser. It's in your front-end bundle, visible to anyone who opens the network tab. That's by design: it's not a password, just a label that says "this request comes from a visitor."
  • The service_role (secret) key bypasses every protection. It stays on the server — never in the browser, never in the repo.

The classic misunderstanding is thinking the public key "protects" access. It protects nothing, and that's not its job. Supabase exposes your tables through an auto-generated API: with the anon key, anyone can call https://your-project.supabase.co/rest/v1/your_table. So the question isn't "how do I hide the key?" (impossible, and pointless) but "what is this key allowed to do?".

The real bodyguard is RLS

The answer to that question is Row Level Security: rules, in the database itself, that decide row by row who can read, insert, update or delete.

Three possible states, and that's the whole game:

  • RLS disabled: the auto-generated API serves and modifies every row for anyone holding the public key — that is, everyone. The table is open for read and write.
  • RLS enabled, no policies: by default, everything is denied. The table is locked — your app reads nothing. Safe, but broken.
  • RLS enabled, with policies: every access goes through your rules. This is the state you want.

The trap in quickly-generated projects is staying in the first state without seeing it. Supabase actually warns you when a public table has RLS off — but a table created directly in SQL (which an AI generating migrations often does) can slip under the radar. Good news: going from the first state to the third takes a few minutes.

Close the door properly, in a few minutes

Three moves, in order.

1. Enable RLS on every exposed table. In SQL, one line per table:

alter table todos enable row level security;

At that moment, the table is locked (deny by default). That's expected — now you add the rules that reopen exactly what's needed.

2. Write clear policies. The most common pattern: a user only touches their own rows, identified by auth.uid(). Separate reads from writes.

create policy "Read own todos" on todos for select using ( auth.uid() = user_id );

create policy "Insert own todos" on todos for insert with check ( auth.uid() = user_id );

The nuance pros know: using filters the existing rows you're allowed to see or modify; with check validates the new values you write. For deliberately public data (a blog, say), a read policy using ( true ) is perfectly valid — what matters is that "public" is a conscious choice, not an oversight.

3. Keep the service_role key on the server. It ignores RLS by design: its place is in your server's environment variables, never in the front-end or in Git.

Verify instead of assuming

The pro reflex isn't "I enabled RLS, done," it's testing the way a visitor would.

  • In the dashboard, walk through your tables: Supabase flags the ones with RLS off. None should stay red without a reason you can state out loud.

  • Test logged out. Call your REST endpoint with only the anon key and look at what comes back:

    curl 'https://your-project.supabase.co/rest/v1/todos' -H "apikey: YOUR_ANON_KEY"

On a protected table, you should get an empty list, not the whole database.

  • Test across users. Logged in as user A, try to read user B's rows: the result should be empty.

The right question for each table: "with only the public key, what can someone do here?" Then you tune the policies until the answer matches your intent exactly. While you're at it, version those policies as migrations — they're part of your app, just like the rest of the code.

In short

The public key in the browser isn't a leak — it's by design. What actually protects your data is RLS: enable it on every table, write explicit rules per operation, keep the secret key on the server, and verify by putting yourself in a visitor's shoes. That's a few minutes of config that turn a prototype into something solid. Consciously deciding who can read what — that's exactly what vibecoding like a pro looks like.