Test Supabase RLS with two accounts
Check private-row isolation with two users, positive controls and signed-out requests. Free Supabase checker, SQL fixture and authorization worksheet.
Sep 9, 2026 · 5 min read
Field Notes from Thunkle, a studio that takes AI-built apps from prototype to secure, production-ready software.
By Thunkle · Resource version 1.0.0
Your account can read its own document. That is the first test. The next is whether a different account can read it too.
This exercise creates two synthetic private records and checks their visibility through the Supabase REST API. The downloadable script first verifies two distinct users and confirms that each can read the correct record. Then it makes cross-account and signed-out requests. It reports PASS, EXPOSED or INCONCLUSIVE without printing tokens or record contents.
The result applies to those records, that table and that moment. It does not certify an app, test writes, or exercise storage, functions, views, organization membership or your application's own API routes.
Free downloads
Save the checker locally before running it. The checker and worksheet are free, with no email gate. The download directory is covered by an MIT license; a backlink is not required.
We verified this release against four real local Supabase configurations. Read the experiment, observed results and reproduction instructions.
Set up a disposable fixture
Use an isolated local or staging Supabase project you control, with two ordinary Supabase Auth users. Use synthetic data throughout. This example models records that are private to an individual; a team-sharing model needs different expected results.
Run this SQL once in that disposable project's SQL editor. Replace USER_A_UUID and USER_B_UUID with the two test user IDs before executing it. The table name is intentionally specific. If it already exists, inspect your earlier fixture instead of overwriting it.
create table public.thunkle_authz_fixture (
id uuid primary key,
user_id uuid not null references auth.users(id),
test_marker text not null
);
alter table public.thunkle_authz_fixture enable row level security;
revoke all on public.thunkle_authz_fixture from anon, authenticated;
grant select on public.thunkle_authz_fixture to anon, authenticated;
create policy "Owner reads fixture"
on public.thunkle_authz_fixture for select to authenticated
using ((select auth.uid()) = user_id);
insert into public.thunkle_authz_fixture (id, user_id, test_marker)
values
('11111111-1111-4111-8111-111111111111', 'USER_A_UUID', 'THUNKLE_A'),
('22222222-2222-4222-8222-222222222222', 'USER_B_UUID', 'THUNKLE_B');
This fixture grants SELECT to both client roles so the exercise can observe row filtering. There is no anonymous SELECT policy, so the signed-out requests should see no rows. In an actual app, a role that has no legitimate use for an operation can also have its table grant revoked. A permission error from that design requires interpretation; this checker conservatively marks non-200 responses inconclusive.
This SQL creates a correctly isolated fixture. Passing it demonstrates the checker on that fixture. It does not test your existing tables. To apply the method to those tables, first define their intended ownership and sharing rules, seed dedicated records, and adapt the assertions and fields accordingly.
Run the read check
Download two-account-read-check.mjs. It requires Node.js 20.6 or later and has no npm dependencies. Supply these variables through your local environment or a local, gitignored environment file:
THUNKLE_SUPABASE_URL=https://YOUR_STAGING_PROJECT.supabase.co
THUNKLE_PUBLIC_KEY=YOUR_PUBLISHABLE_OR_LEGACY_ANON_KEY
THUNKLE_TOKEN_A=ORDINARY_USER_A_ACCESS_TOKEN
THUNKLE_TOKEN_B=ORDINARY_USER_B_ACCESS_TOKEN
THUNKLE_ROW_A=11111111-1111-4111-8111-111111111111
THUNKLE_ROW_B=22222222-2222-4222-8222-222222222222
THUNKLE_TABLE=thunkle_authz_fixture
Obtain fresh user access tokens by signing into the staging app as each test user. Use their access tokens, not refresh tokens, project keys, or administrator credentials. Keep the environment file outside shared folders and restrict access to it; never commit it or paste it into an issue. The public key belongs in the apikey header; the script adds each user's token separately and omits user authorization for signed-out checks.
With a local environment file and Node.js 20.6 or newer:
node --env-file=.env.authz.local two-account-read-check.mjs
If variables are already supplied by your environment, run node two-account-read-check.mjs.
Interpret the result
- PASS (exit 0): Both owners could read their exact fixtures, and all four unauthorized reads returned empty arrays. Record the scope and continue testing other resources and operations.
- EXPOSED (exit 1): At least one unauthorized read returned a row. Inspect the grants and SELECT policies, correct unintended access, then repeat both allowed and denied tests.
- INCONCLUSIVE (exit 2): A control, session, request or response was unsuitable. Resolve the cause and rerun. This is not a security pass.
A successful HTTP response can contain an empty array after row filtering. That is why this check examines the returned data. A broken token, missing fixture, HTML login page or network failure must not look like successful authorization enforcement.
The script follows no redirects, makes GET requests only and prints outcome labels rather than credentials or response bodies. There is no separate analytics endpoint. Requests go to the project origin you configure.
Expand the test to real product rules
Use the companion worksheet to record expected access before testing. For private records, test both directions. For shared records, include an allowed teammate, an outsider and a former member. Preserve a successful request as a positive control for each permitted action.
Writes need additional care and separate tests: a response code alone does not establish whether data changed. Use disposable records, check affected rows and verify the resulting state as the owner. A filtered update can affect zero rows without raising an error. Never run mutation experiments on customer records.
The Supabase RLS guide explains policy construction. Our controlled two-account experiment shows why these positive and negative controls matter. If you need someone to map and test your application's actual access rules, Thunkle's code audits cover the repository and running product.
References and scope
- Supabase: Row Level Security — grants, roles and owner-scoped row policies.
- Supabase: API keys — choosing a client-safe project key.
- OWASP: Testing for Insecure Direct Object References — testing object access across identities.
This is a point-in-time test, not a transactionally consistent snapshot. Keep fixtures unchanged and sessions valid during the run. Review and adapt the source before using it in an authorized environment.
Working on something like this?
If you're migrating, securing or building an AI-made app, Thunkle can help. Get a fixed quote in 24 hours.
