Imoran ai LogoImoran ai Logo

Multi-Tenant SaaS Auth with Supabase Row-Level Security

How I keep one tenant’s data invisible to another using Supabase row-level security: deny-by-default tables, auth.uid() policies, an admin override, and how to actually test the isolation.

Published: June 28, 2026

Last updated: June 30, 2026

Multi-Tenant SaaS Auth with Supabase Row-Level Security

In a multi-tenant app, the worst bug you can ship is one customer seeing another customer's data. I build with Supabase a lot, and the feature that lets me sleep about this is row-level security. It moves the "can this user see this row" decision out of my application code and into the database itself.

Here's how I use it to keep tenants isolated, drawn from building Fasco, where shoppers must only ever see their own orders while an admin can see everything.

Why filtering in the frontend isn't enough

The naive version of access control is a query like "give me the orders where user_id equals the current user." It works until it doesn't. One forgotten filter on one endpoint, one clever request with a different ID, and you've leaked data. The rule lives in dozens of places in your code, and it only takes one of them to be wrong.

Row-level security flips this. You write the rule once, on the table, and the database enforces it on every single query no matter where it comes from. Even if my API code asks for every order in the table, Postgres hands back only the rows the current user is allowed to see.

Turning it on

By default a table with RLS enabled returns nothing. You opt in, then add policies that grant access. That "deny by default" posture is exactly what you want.

alter table orders enable row level security;

At this point every query against `orders` returns zero rows until a policy says otherwise. That feels broken the first time, but it's the whole point: access is something you explicitly grant, not something you forget to remove.

A policy for "only your own rows"

Supabase exposes the logged-in user's ID through `auth.uid()`. A policy that lets people read only their own orders looks like this:

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

The `using` clause is a condition Postgres checks against every row. If it's false for a row, that row simply doesn't exist as far as the query is concerned. No application code involved, no way to forget it.

You add separate policies for insert, update, and delete, each with its own condition, so a user can create an order for themselves but never modify someone else's.

Letting admins see everything

Tenant isolation can't mean nobody has the full picture. The store owner needs every order. I handle that with a second policy keyed off a role stored on the user, so admins pass the check that regular users fail.

create policy "Admins read all orders"
on orders
for select
using (
  exists (
    select 1 from profiles
    where profiles.id = auth.uid()
      and profiles.role = 'admin'
  )
);

Policies are additive. A row is visible if any policy allows it, so a normal user sees their own orders through the first policy, and an admin sees all of them through the second.

Test the isolation directly

Don't just click around as one user and assume it's fine. Sign in as user A, sign in as user B in another session, and confirm B genuinely cannot read A's rows even when you ask for them by ID. The point of RLS is that the database says no for you, so verify the database actually says no.

Once those policies are in place, multi-tenancy stops being a thing I have to remember in every query. The boundary lives in one place, the database enforces it, and a missing filter in my frontend can't turn into a data leak.

Frequently asked questions

What is row-level security?

A Postgres feature where you write the access rules on the table itself, so the database, not your application code, decides which rows each user can see.

Why not just filter by user in the frontend?

One forgotten filter leaks data. With RLS the rule lives in one place and the database enforces it on every query, even if your code asks for everything.

How do admins see everything then?

Policies are additive. A second policy keyed off an admin role grants full access, while regular users only ever pass the "own rows" policy.

How do I test tenant isolation?

Sign in as two different users and confirm one genuinely can't read the other's rows even by ID. The whole point is that the database says no for you.

Let's Work
Together

Hand Reaching

Have a project in mind? We'd love to hear about it. Let's create something great together!

PRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENTPRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENTPRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENT