If you're building multi-tenant SaaS on Supabase, you probably picked it for one very good reason: Row Level Security. And you were right to.
create policy tenant_isolation on orders
for all to authenticated
using (org_id = ((select auth.jwt()) -> 'app_metadata' ->> 'org_id')::uuid);
That policy is a real security boundary. Postgres appends it whether or not you remembered a WHERE — and it holds the same for PostgREST, the JS client, a psql session, and a 3 a.m. background job. That's categorically stronger than filtering in an ORM, and it's the single best reason to build multi-tenant SaaS on Supabase.
I'm not going to argue with any of that. I'm going to point at the four things it doesn't cover — because you'll meet all four eventually, and it's better to meet them now than in month nine.
(Full disclosure: I work on Supero, which competes with Supabase (https://www.supero.dev/?utm_source=devto&utm_campaign=vs-supabase). This is a critique of a structural property of RLS, not of Supabase's implementation, which is genuinely good.)
1. RLS answers "which rows." Not "which columns, for whom."
RLS is row-level. It says nothing about columns. Postgres does have an answer for columns — grants:
revoke select (salary) on employee from authenticated;
grant select (id, name, dept) on employee to authenticated;
Those are checked against every column your query touches — including ORDER BY. But they're per database role, not per JWT claim. So the moment your requirement becomes "managers see salary, staff don't, and both log in as authenticated," grants stop fitting. You reach for a view per audience:
create view employee_staff with (security_invoker = true) as
select id, name, dept from employee;
Views work. They also multiply — one per audience per table, each needing maintenance every time the base table changes. Everyone who's done this remembers the migration where they added a column and found seven views to update, and missed one.
The part worth stating precisely (because it's easy to get wrong in my favour): if you hide salary with a column grant or a restricted view, this is blocked —
GET /employee?select=name&order=salary.desc&limit=1
Postgres refuses it. A competent Supabase team using grants or views has already closed this.
The leak shows up when you mask fields in your own API layer instead — a Next.js route that fetches the row and deletes salary from the JSON before returning it. That's extremely common once JWT-based, per-tenant logic arrives. And at that point the sort/filter surface is ungoverned again: order=salary.desc returns the top earner's name without a salary, and ?salary=gt.200000 with Prefer: count=exact becomes an oracle that binary-searches the value. (On Supabase specifically, distinct and aggregates are off by default, so two of those vectors need someone to have turned them on.)
So the criticism isn't "RLS leaks." It's that RLS protects what stays at the database boundary — and most teams eventually stop staying there.
2. Every policy is a policy someone has to remember to write
RLS is opt-in per table. Credit where due: tables created in the Table Editor get it on by default. But tables created by a migration don't — alter table … enable row level security is a line a human types. At sixty tables, over two years, with a rotating team, the failure mode is a missing policy on the table someone added last Tuesday.
Supabase does back you up here — its Security Advisor flags a public table with RLS disabled as an error. That's a good backstop; I wouldn't build without it. Two things that get less airtime:
-
Table owners bypass RLS unless you set
alter table … force row level security. If migrations run as the owner and something reuses that role, your policies simply don't apply. - You can audit it in one query and fail CI on the result:
select c.relname, c.relrowsecurity as rls_enabled,
c.relforcerowsecurity as rls_forced, count(p.polname) as policies
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policy p on p.polrelid = c.oid
where n.nspname = 'public' and c.relkind = 'r'
group by 1,2,3
having c.relrowsecurity is false or count(p.polname) = 0;
But notice what that combination is: isolation as your team's discipline plus a linter, rather than as the architecture. Better than nothing. Weaker than structural.
3. service_role bypasses all of it
The most common real-world Supabase multi-tenant failure isn't a missing policy. It's the service_role key — which bypasses RLS entirely — leaking into a client bundle, or getting used in an edge function because threading the user's JWT through was more work.
RLS is a strong boundary with a documented master key. Whether that key stays server-side is a property of your team's process, not of the database. Rotate it, never ship it to a client, and grep your repo for it before you read on.
4. Sub-tenancy gets awkward fast
A flat org_id claim handles "customer A can't see customer B." The next asks are harder: a customer with two divisions; a reseller who needs a view across fifteen customers; a user who belongs to two orgs and switches.
Each is doable — a recursive CTE over an org tree in the policy, a claims array, a SECURITY DEFINER helper. But now your isolation predicate is a non-trivial function evaluated on every row of every query — a correctness surface and a performance surface. Supabase has published the performance fix (wrap auth.uid() in a scalar subselect so it runs once; index the column), and it works. It's still one more thing you maintain.
The honest turn
Here's the objection this whole post has been arming you with: if the leak lives in the application layer, and Supero is an application layer, why not just use column grants and a view per audience?
For a two-audience system — honestly, do that. It's free, it's older than both of us, and this post showed you how.
The argument only starts paying when your audiences stop being database roles. The moment visibility depends on a JWT claim — this manager, this tenant, this customer's own staff — column grants don't fit, because they're per-role and every logged-in user shares one role. That's the point where every team ends up building an application layer anyway. And the only question left is whether the "can this caller see this field" decision is computed once and applied to both the response and the query surface, or computed twice and allowed to drift.
That's the whole of what Supero does differently: one field decision, two enforcement points (the response and what you can sort, filter, group, or aggregate by), assembled below your app across four levels — domain → project → tenant → user — with no per-table enable step to forget.
And I'll be straight about where we're not there yet, because a comparison page that hides its own gaps isn't worth reading: our access policy is permissive by default, so entities need explicit policies and generated apps can ship without them; the admin tier bypasses access policies entirely; we log who wrote a record but not who read one; and order_by/filter on a hidden field aren't yet guarded on our own storage (they are on a live external DB). If those are dealbreakers, better you know now.
Where Supabase simply wins
- Open source and self-hostable — a stronger, more proven position than ours. If that's your top criterion, the comparison is over.
- It's Postgres — every extension, tool, DBA, and Stack Overflow answer of the last fifteen years.
- Maturity and ecosystem at a scale we don't have.
- Direct SQL — the full expressive power of the database. We give you a query builder and an API; for many engineers that's a downgrade, and it's a fair objection.
What to actually do next
Run the audit query from §2 against your own database. If it returns rows, that's today's work — and it has nothing to do with me.
If you then want to see what a generated, governed app looks like, sixteen of ours are live and need no account: concierge.supero.live · atelier.supero.live · ledgerline.supero.live. Fair warning: those are single-tenant public demos with no hidden-field policies configured, so they show you the generated application — the model, the screens, the finish — not the field enforcement this post argues about. Pointing the order= test at them returns 200 because there's nothing there to refuse; that proves nothing either way.
The example apps are also on GitHub — MIT-licensed, readable Python and JS you can inspect: https://github.com/supero-platform/supero-apps
To see the enforcement itself, come find me — I'll run the aggregate vectors against a schema with a real hidden field. Thirty minutes with an engineer, not a demo.
If anything here is wrong about Supabase, tell me and I'll fix it — a comparison with an error in it is worse than no comparison.

Top comments (0)