project, two test accounts, and the reference repo cloned.
What you'll prove
By the end of this lesson you'll have a repeatable, falsifiable statement about your own app:
Through the ordinary authenticated client, User B receives zero rows owned by User A — and User A can still read their own.
Not "the dashboard looks right." A test you can re-run every time the schema changes, with the request, response, and identities recorded as evidence.
Why this one hides
Your prototype works. User A signs in and sees User A's documents; User B sees User B's. That looks like data isolation. It usually only proves that the page asked for a filtered result.
The screen is not the boundary. User B doesn't have to use the screen you built — they can call the same data API and ask for User A's row by ID. If the database still answers, the boundary already failed, and it will keep looking correct in the UI while cross-account data leaks underneath it.
The useful launch question is concrete: can User B read User A's row?
See it red, then green (reference repo)
From the reference repo, run the isolation tests:
npm run demo
npm run test:failures # the exploit: passes when the leak reproduces
npm run test:launch # the fixed invariant
The deliberately vulnerable function returns every row it's handed — it ignores who's asking:
return [...rows];
The fixed model makes the intended invariant explicit:
return rows.filter((row) => row.userId === requestingUserId);
Read both side by side; the diff is the whole lesson. And note the trap: npm run test:failures passing means the vulnerable behavior reproduced — that's exploit evidence, not launch approval.
The repo is a policy model in plain Node — it teaches the invariant. It is not proof about your live database. That's what the next section is for.
Apply it to your own app ← the part the free article doesn't do
This is the check that actually protects you. Do it against your real Supabase project, through the same client path your app uses.
1. Move the invariant into the database. A JavaScript filter explains the rule; it can't enforce it. Confirm Row Level Security is enabled and a SELECT policy exists on the table:
alter table public.documents enable row level security;
create policy "users can read their own documents"
on public.documents
for select
to authenticated
using ((select auth.uid()) = user_id);
This is the SELECT policy — it governs the read test below. Once RLS is enabled, every operation is denied until a policy allows it, so a working app already has (or needs) matching insert, update, and delete policies. Each operation gets its own policy and its own isolation check (step 5). If User A can't create the row in step 3, you're missing the insert policy — add it before continuing.
2. Create two ordinary test accounts — User A and User B. Not service-role, not admin. The ordinary signed-in client, because that's what your customers use.
3. As User A, create a private row and save its exact ID.
4. Sign out. Sign in as User B. Through the normal data API, request User A's exact row ID — the same call your app makes, just with the other account:
const { data } = await supabase
.from('documents')
.select('*')
.eq('id', USER_A_ROW_ID) // the ID you saved in step 3
.maybeSingle(); // .single() throws on zero rows; maybeSingle() returns null
// PASS: data is null. FAIL: you can see User A's row.
5. Repeat for every operation the table allows — not just select. Try to update and delete User A's row as User B. RLS SELECT policies don't cover writes; you need policies (and this test) for each.
6. Positive control: sign back in as User A and confirm they can still read their own row. If neither user can read anything, your query or migration is broken — that's not isolation, that's an outage.
If User B can read or change User A's data: stop. Capture the exact request, response, identities, and a minimal fixture. Turn that into a failing test before you touch the policy — a stable red case is how you'll know the fix addressed the real failure and not a symptom.
Common gotchas
- Testing with an admin / service-role client. If your test uses the service-role key, you're measuring the key, not RLS — it bypasses the policy and everything "passes." Use the ordinary signed-in client, the exact path your app uses. (That's the whole of Lesson 2.)
- Both users see nothing → not isolation, a broken query or migration. A zero-row result only means "isolated" when the positive control (User A reads their own row) also passes.
- The RLS in the dashboard isn't necessarily the RLS that's deployed. Test the project your app actually talks to; a policy sitting in a migration file proves nothing until it's applied.
- Wrong access path. If your app reads through an RPC, a view, or a
security definerfunction, test that path — a policy on the base table doesn't automatically cover it.
What green does — and does not — mean
A passing two-user check is evidence for one invariant, on one table, on the migration you have applied right now. It is not a security certification.
It does not automatically cover other tables, storage buckets, RPCs, views, server routes, or privileged functions, and it does not prove future migrations preserve the policy. Keep the test in your launch suite and re-run it whenever the schema or an authorization path changes.
⚠️ One more trap, handled in Lesson 2: a correct RLS policy does nothing if a server route reads the table with the service-role key, which bypasses RLS entirely. Green here is necessary, not sufficient — Lesson 2 closes that door.
Workbook — record your evidence
Fill this in for your app before you move on:
- ☐RLS is enabled on the table, with a SELECT policy that ties rows to
auth.uid() - ☐User B requesting User A's row by ID gets zero rows (select)
- ☐User B cannot update User A's row
- ☐User B cannot delete User A's row
- ☐Positive control: User A can still read their own row
- ☐The two-user check is committed to the launch test suite
Evidence (paste the request + result you ran):
_______________________________________________________________
Status: ☐ pass ☐ red case captured → fix, then re-run
The rest of the money path, same format
Data isolation, secrets, and payment integrity — each with a runnable test and an apply-to-your-app workbook — plus the reference repo and the go/no-go checklist. $29, one-time.