Launch-readiness teardown

Your Supabase UI Filter Is Not an Access Policy

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:

  1. Sign in as User A and create a private row.
  2. Save that row’s ID.
  3. Sign out, then sign in as User B.
  4. Through the normal data API, request User A’s exact row ID.
  5. Repeat the isolation check for every operation your app permits: insert, update, and delete as well as select.
  6. 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.

Inspect the exact test

Run the broader launch-readiness checklist

Sources: Supabase Row Level Security and database testing.

Test this on your own app before you sell it.