Your prototype works. That is not the same as safe to sell.
The dashboard opens. User A sees User A’s documents. User B sees User B’s
documents. That looks like data isolation, but it may only prove that the page
requested or displayed a filtered result.
If the database still accepts a direct request for another account’s row, the
boundary has already failed. User B does not have to use the screen you
designed. They can call the same data API and request User A’s row by ID.
The useful launch question is concrete:
Can User B read User A’s row?
Start with a red test
The ShipTested public example makes this failure visible with a small Node.js
20 teaching harness. It has no package dependencies.
npm run demo
npm run test:failures
npm run test:launch
The deliberately vulnerable function returns every document it receives. It
ignores the requesting user:
return [...rows];
The exploit test asks for documents as User B and checks whether a row owned by
User A is present.
When that test passes, it means the vulnerable behavior was successfully
reproduced. It is a red exploit demonstration, not launch approval.
The fixed policy model makes the intended invariant explicit:
return rows.filter((row) => row.userId === requestingUserId);
Its launch check expects User B to receive only doc-b, never doc-a. Run
npm run test:launch to exercise the fixed invariants. Read both versions side
by side; the diff is the point.
Move the invariant to the database
A JavaScript filter can explain the rule, but it cannot enforce a Supabase
database boundary. In a real project, Row Level Security must be enabled on the
relevant table, and the applied policies must match the operations your
application permits.
The example includes this minimal SELECT policy shape:
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 says that an authenticated request may select a document only when the
authenticated user’s ID matches user_id.
It is intentionally narrow. It does not define INSERT, UPDATE, or DELETE
policies, and it does not account for your project’s grants, schema, ownership
rules, functions, or other tables.
That boundary matters: the repository’s JavaScript check is a policy model. The
SQL is an example policy shape. Neither proves that your live migrations are
applied correctly or that your Supabase project is ready to launch.
Run the two-user test against your project
Use two ordinary test accounts and the same client path your application uses:
- Sign in as User A and create a private row.
- Save that row’s ID.
- Sign out, then sign in as User B.
- Through the normal data API, request User A’s exact row ID.
- Repeat the isolation check for every operation your app permits: insert, update, and delete as well as select.
- Sign back in as User A and confirm the intended own-row flow still works.
Do not use a service-role key for this test. It bypasses RLS and cannot show
what an ordinary authenticated user is allowed to do.
Do not run the test with production data. Synthetic users and rows are enough.
For the read case, the passing invariant is simple:
User B receives zero rows owned by User A, while User A can still retrieve
the row.
Both halves matter. If neither user can read anything because the query or
migration is broken, you have not demonstrated correct isolation.
For mutations, assert the state after each request. User B must not be able to
create a row attributed to User A, change User A’s row, or delete it. User A
should still be able to complete each operation the product intentionally
supports.
If User B receives or changes User A’s data, keep the exact request, response,
identities, and minimal fixture. Turn that reproduction into a test before
changing the policy. A stable red case gives you evidence that the fix
addresses the failure you actually found.
Know what green does—and does not—mean
A passing two-user check is evidence for one tested invariant on one applied
database path.
It is not a security certification.
It does not automatically cover every table, storage bucket, RPC, view, server
route, or privileged function. It also does not prove that future migrations
preserve the policy.
Keep the test in the launch suite and run it when the schema or authorization
path changes. Add cases as the product adds operations.
The goal is not to claim that an app is “secure.” The goal is to preserve a
falsifiable statement:
User B cannot read or mutate User A’s rows through the ordinary authenticated
path, and User A can still complete the intended flow.
Your prototype working is the start. A red/green isolation test is evidence you
can use before you sell it.
Run the broader launch-readiness checklist
Sources: Supabase Row Level Security
and database testing.
ShipTested uses AI heavily to produce this material and verifies the technical
examples by running them. There are no fake testimonials and no guaranteed
outcomes — the point is that you run the tests yourself instead of trusting an
author.
Top comments (0)