BLDR
All posts

Supabase auth and row-level security, explained for operators

A working login isn’t the same as protected data. What row-level security does, how to read a policy, and the two-browser test every SaaS app must pass.

If you build a SaaS app, there’s one test that matters more than any feature: can user A see user B’s data? If the answer is yes, you don’t have a product yet. You have a liability.

Supabase gives you the tools to get this right, but it won’t do it for you automatically, and coding agents don’t always set it up correctly. Here’s what you need to understand, in operator terms rather than engineer terms.

Two separate jobs: who you are and what you can see

Authentication answers “who is this person?” Sign up, log in, reset a password, sign in with Google. Supabase Auth handles this and gives every user a unique ID.

Authorization answers “what is this person allowed to do?” Which rows can they read, which can they change. In Supabase, this is mostly handled by row-level security, or RLS.

People mix these up all the time. A working login page doesn’t mean your data is protected. It’s entirely possible to have flawless sign-in and no authorization at all, which means any logged-in user can read everyone’s records.

Why the database has to enforce it

In a typical Supabase app, the browser talks to the database directly using a public key that ships with your website. Anyone can find that key by opening their browser’s developer tools. That’s by design and it’s fine, as long as the database itself decides what each request is allowed to see.

Think of territory rules in a CRM. Hiding other reps’ accounts in the interface isn’t enough; the system has to refuse to return them even if someone asks for them directly. RLS is that refusal, enforced by the database on every query.

So hiding a button doesn’t count as security. If the only thing stopping user A from seeing user B’s invoices is that there’s no link to them, a curious user with developer tools can still ask the database for them.

What a policy looks like

A policy is a rule attached to a table. Here’s a typical setup for a table where each row belongs to one user:

alter table proposals enable row level security;

create policy "Users can read their own proposals"
on proposals for select
using ((select auth.uid()) = user_id);

create policy "Users can create their own proposals"
on proposals for insert
with check ((select auth.uid()) = user_id);

create policy "Users can update their own proposals"
on proposals for update
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

In plain English: turn on security for this table, then only allow reading, creating, and updating rows where the row’s user_id matches the person making the request. You don’t need to write these by hand. You do need to read them and understand what they allow.

One detail surprises people. Once RLS is on and a table has no policies, it returns nothing to the public key. That’s the safe default. If your app suddenly shows empty lists after you enable RLS, you’re missing a policy, not losing data.

The two-browser test

Here’s how to check your app the way an attacker would, without any technical tools:

  1. Open your app in a normal browser window and sign up as User A. Create a few records.
  2. Open a private or incognito window and sign up as User B. Create a few different records.
  3. As User B, confirm you see only User B’s records.
  4. Copy the URL of one of User A’s records, such as a detail page, and paste it into User B’s window. You should get an error or an empty page, never User A’s data.
  5. Sign out completely and try the same URL. You should be sent to the login page.

Run this after every change that touches data. It takes about five minutes, and it catches the most common and most damaging mistake in small SaaS apps.

The key that skips all of this

Supabase also gives you a secret key, often called the service role key, that bypasses RLS entirely. It exists for trusted server code, like a payment webhook handler. It must never appear in browser code or in any environment variable that starts with NEXT_PUBLIC_, because that prefix sends it to every visitor.

If an agent ever suggests using the secret key to “fix” a permissions error, say no. That turns security off rather than repairing it. The right fix is almost always a missing or incorrect policy.

Auth, permissions, and the two-browser test are covered step by step in the SaaS foundations module of the sprint.