What a one-account security test misses
A reproducible Supabase experiment across four access-control configurations: why owner reads and signed-out checks can miss cross-account exposure.
Sep 9, 2026 · 6 min read
Field Notes from Thunkle, a studio that takes AI-built apps from prototype to secure, production-ready software.
By Thunkle · Controlled experiment · Version 1.0.0
An app can return the right record to its owner, return nothing to a signed-out visitor, and still expose that record to every other signed-in user.
We reproduced that combination in a disposable local Supabase project on September 9, 2026. An owner-only functional test and a signed-out read test both looked normal. A request from a second account exposed the ownership failure.
This is a four-configuration lab study of a known authorization failure mode, not a survey of deployed apps or a newly discovered Supabase vulnerability. We created the insecure configurations ourselves. No customer records or third-party systems were involved.
The question
Can a test distinguish a correctly private record from a record that is merely hidden from signed-out visitors? And can it avoid calling a broken, unreadable fixture a security pass?
Those are different questions. The first requires another identity. The second requires a positive control: a request that should work and demonstrably does.
The setup
We created two ordinary Supabase Auth users, A and B, and two synthetic rows in one table. A owned row A; B owned row B. Each row had a fixed UUID and a marker so the checker could confirm it had read the intended fixture, not just any row.
The intended rule was individual privacy: an owner could read their own row, but neither another user nor a signed-out visitor could read it. We explicitly granted SELECT to the anon and authenticated roles, then varied RLS and its SELECT policy. This keeps the table grants constant while testing row filtering; it is not a recommendation to grant anonymous access in every application.
The downloadable checker and setup guide use the REST API, not an administrator database connection, to evaluate access. The experiment runner uses local database privileges only to create the fixture and change its configuration.
Each measured run followed the same sequence:
- Verify that the two access tokens identify distinct authenticated users.
- Confirm that A can read the exact A fixture and B can read the exact B fixture, including their owners and markers.
- If both controls work, request A's row as B and B's row as A.
- Request each row again without a user access token.
A valid unauthorized read returning an empty array counted as PASS. Any returned row counted as EXPOSED. Failed controls, non-200 responses or unsuitable response shapes counted as INCONCLUSIVE. The checker did not print credentials or row contents.
Observed results
1. Owner-scoped RLS: PASS
With RLS enabled and an authenticated SELECT policy using (select auth.uid()) = user_id, both owners read their fixtures. Both cross-account requests and both signed-out requests returned empty arrays.
This is the intended behavior for these individually private records.
2. All-authenticated RLS: EXPOSED
We replaced the owner policy with an authenticated SELECT policy using true. Both owners still read their fixtures. Both signed-out requests still returned empty arrays.
But B could now read A's row, and A could read B's row. The two cross-account checks reported EXPOSED.
A successful owner read plus an empty signed-out read did not establish user-to-user isolation. The missing condition was ownership, not login. A policy named “authenticated users can read” can be appropriate for shared content, but it was wrong for the private-record rule we were testing.
3. RLS disabled with SELECT grants: EXPOSED
We disabled RLS without changing the grants. Both owners read their fixtures, both cross-account requests returned rows, and both signed-out requests returned rows.
All four unauthorized checks reported EXPOSED. The explicit grants matter: this result does not imply that every table with RLS disabled is automatically reachable by every role.
4. RLS enabled with no SELECT policy: INCONCLUSIVE
We re-enabled RLS and removed the authenticated SELECT policy. Both owners now received empty results for their own fixtures.
The checker stopped at its positive controls and did not run the four unauthorized checks. Its result was INCONCLUSIVE, not PASS. Denying the owners violated the fixture's intended behavior; a negative-only test could miss that broken setup.
Reproduce the experiment
The run used Node.js 26.7.0, Supabase CLI 2.115.0, PostgreSQL 17.6, PostgREST 16.1 and GoTrue 2.195.0. The downloadable scripts require Node.js 20.6 or newer. Local reproduction also requires Docker, the Supabase CLI and psql on your PATH. Later server versions may behave differently; record your versions alongside any rerun.
- Observed result JSON, September 9, 2026
- Local experiment runner (.mjs)
- Read-only checker (.mjs)
- Checker unit tests (.mjs)
Create a new, empty directory specifically for this experiment. Save the three .mjs files there and run these commands from that directory:
supabase init --yes
supabase start --exclude realtime,storage-api,imgproxy,mailpit,postgres-meta,studio,edge-runtime,logflare,vector,supavisor
node --test two-account-read-check.test.mjs
node run-local-experiment.mjs
If another local Supabase project is running, choose unused API, database and shadow-database ports in the generated supabase/config.toml before starting. Our run used 57431, 57432 and 57430 respectively. Keep local email confirmations disabled so the synthetic signups receive sessions without sending email.
The experiment runner changes data and policies. It is different from the GET-only checker. It accepts only loopback API and database addresses, creates two synthetic accounts, refuses an existing fixture table, applies the four configurations and restores the owner-scoped policy after testing. Use a fresh disposable project, never a shared development project. Inspect the source before running it.
Afterward, run supabase stop from the experiment directory to stop its containers. Supabase may retain local data for later restarts; the synthetic accounts and table are not individually removed by the runner.
All ten checker unit tests passed at release. The real local integration run also matched the expected result for each of the four configurations. The MIT license covers the downloadable code, worksheet and result file.
What this does and does not show
The experiment demonstrates why we test allowed and denied reads across identities. It does not measure how often these mistakes occur in real apps. Four deliberately selected configurations are not a representative sample, and their results must not be converted into a vulnerability prevalence percentage.
This test covers one table, two rows and SELECT access through the Supabase REST API. It does not cover writes, views, RPCs, storage, realtime, application endpoints, organization membership, column-level disclosure or behavior after a session expires. Requests run sequentially, not inside a consistent database snapshot. The local integration used a legacy anon project key; the new publishable-key path was not integration-tested in this release.
A fixture PASS is not an app-wide security verdict. To test a product, define its actual ownership and sharing rules, prepare synthetic records for those rules, and extend the checks. Our authorization worksheet provides a starting point for that wider coverage.
Use the method
Start with the two-account Supabase walkthrough. For policy construction, read the Supabase RLS guide. For help checking your application's real access rules, see Thunkle's security audits.
The underlying principles are established in Supabase's RLS documentation and OWASP's object-reference authorization testing guidance. This experiment makes the distinction runnable with a small, inspectable fixture.
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.
