When you're building a multi-tenant app, there's one bug category that's worse than any other: a user seeing someone else's data. Not a crash, not a broken button — a genuine privacy failure.
I recently built a Next.js + Supabase + Stripe starter kit, and before I'd call the database layer "done," I wanted to actually prove the security held, not just assume it did because the code looked right.
The setup
Supabase's Row Level Security (RLS) lets you write policies directly in Postgres that filter rows based on who's asking — the database itself becomes the security boundary, not just your application code. That's powerful, but it also means a single wrong policy (or a missing one) silently exposes everything.
Here's the policy pattern I used for an owner-scoped table:
create policy "projects: owner reads" on public.projects for select
to authenticated using ((select auth.uid()) = owner_id);
Simple enough. But "simple enough" is exactly the kind of thing worth verifying rather than trusting.
The actual test
Rather than just trusting the policy, I ran an impersonation test directly in the SQL editor:
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"<a-real-user-id>"}';
select id, name from public.projects;
rollback;
This temporarily pretends to be a specific user (inside a transaction that touches nothing) and asks: what can this user actually see?
Run it with the real owner's ID — you get their rows. Run it with a fake or different user's ID — you should get zero rows back, not an error. That's the key detail: RLS filters rows out silently rather than throwing a permission error, so "nothing happened" is actually the success signal, not a bug.
I did the same check for writes — attempting to delete another user's row from a different account's session, confirming it returns 0 rows affected rather than either succeeding or throwing.
Why this matters more than it seems
It's easy to write an RLS policy that looks right and ship it without ever proving it under an adversarial condition. The impersonation test takes two minutes and catches exactly the failure mode that would otherwise only surface in production, with a real user's data.
If you're building something similar
A few things I'd flag for anyone doing this themselves:
Always test with at least two accounts — one policy working for the legitimate owner tells you nothing about whether it blocks everyone else
Test both reads and writes separately — a table can have a working select policy and a broken (or missing) delete policy
The "pass" condition for a negative test is often silence (0 rows), not an error — know what success actually looks like before you run it
I ended up packaging this pattern, along with tested Stripe billing and auth, into a small starter kit: https://liviug.gumroad.com/l/saas-starter. The RLS testing pattern above is genuinely useful on its own regardless of what you're building.
Top comments (0)