A full lesson from the course — free

Lesson 1 — Data Isolation I: Can Another User Read This Row?

Module: Data isolation · Time: ~30 minutes · You'll need: your Supabase

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

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:

Evidence (paste the request + result you ran):

_______________________________________________________________

Status: ☐ pass ☐ red case captured → fix, then re-run

That was Lesson 1 of 8

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.