Reading a policy is not testing a policy
A policy's text tells you what its author intended. Only a request tells you what the database returns. Here is a procedure for asking, with three identities, one endpoint and a control for every result.
We have argued elsewhere that policies drift rather than fail, and that reading migrations is a weak way to catch it. This post is the other half: a procedure for asking the database instead, in enough detail to run today.
Scope. This tests one path: requests through Supabase's Data API, which PostgREST serves. It does not test table owners or roles with BYPASSRLS, views, functions, other schemas, grants on their own, or every write command.
What the text cannot tell you
Two things specifically, and both are properties of the request rather than of the policy.
How multiple policies combine
PostgreSQL combines permissive policies for the same command with OR: a table with three select policies returns a row if any of them matches. The effective rule is the union, and the union is not written down anywhere, because each policy was added separately for a separate requirement. Restrictive policies (as restrictive) combine with AND and can only narrow that result; without at least one permissive policy, nothing is granted at all (PostgreSQL: row security policies).
select policyname, permissive, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public' and tablename = 'bookings';
That query lists the policies. It does not tell you what their union permits, and working that out by hand against a schema that has changed since they were written is easy to get wrong. It also has to be redone whenever any input changes.
Two facts sit outside pg_policies and change what it means. Policies apply only when row-level security is enabled on the table, and a role needs a table grant before any policy is consulted:
select relname, relrowsecurity, relforcerowsecurity
from pg_class
where oid = 'public.bookings'::regclass;
select grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public' and table_name = 'bookings'
and grantee in ('anon', 'authenticated');
What the role resolves to at request time
A policy referencing auth.uid() behaves differently depending on what the request carries. A request made with only the project's public key runs as the anon role, and auth.uid() returns null. A request carrying a signed-in user's access token runs as authenticated, and auth.uid() returns that user's id. The secret service-role key bypasses row-level security, so policies never apply to it (Supabase: row level security, Supabase: API keys, PostgREST: authentication).
The policy text is identical in every case. The behaviour is not. So the test has to be run per identity, and the identity is the variable.
Before you test: fixtures and a control
An empty result only means something if you know what should have come back. Set this up first, in a non-production project with synthetic data:
- Two throwaway users, A and B, each owning at least one row in the table under test.
- The immutable id of one row owned by B.
- A positive control: B's own token must return that row. If B cannot read it, an empty result for A proves nothing.
Testing it with a request
Three identities, one endpoint. Print the HTTP status with every request, because the status and the body together are the result.
The anonymous read
The public key is public by design and ships in your client bundle, so anyone can make this request.
export PROJECT="https://EXAMPLE-PROJECT.supabase.co"
export ANON="<your publishable or legacy anon key>"
# A publishable key (sb_publishable_...) goes in the apikey header only.
curl -s -w "\nHTTP %{http_code}\n" "${PROJECT}/rest/v1/bookings?select=*&limit=5" \
-H "apikey: ${ANON}"
# A legacy anon key is a JWT: send it as the apikey and as the bearer token.
curl -s -w "\nHTTP %{http_code}\n" "${PROJECT}/rest/v1/bookings?select=*&limit=5" \
-H "apikey: ${ANON}" \
-H "Authorization: Bearer ${ANON}"
Run it against every table in your exposed schema, not only the ones you think are sensitive. Include join tables, audit tables and tables added for older features; they are easy to overlook.
The cross-tenant read
Sign in as user A, take A's access token, and ask for the row you know belongs to B.
export USER_A_JWT="<access token for user A>"
export ROW_OWNED_BY_B="<immutable id of a row owned by user B>"
curl -s -w "\nHTTP %{http_code}\n" "${PROJECT}/rest/v1/bookings?select=*&id=eq.${ROW_OWNED_BY_B}" \
-H "apikey: ${ANON}" \
-H "Authorization: Bearer ${USER_A_JWT}"
The apikey header stays the public key while the bearer token is the user's; that combination is what a real browser session sends. Run the same request with B's token first: that is your positive control.
If B gets the row and A gets 200 with [], this request did not expose B's row to A. If A gets the row, you have a tenant-isolation failure, and the anonymous test would never have shown it.
The write attempt
Reads are only half of it, and writes are governed by different clauses. An insert policy uses with check. An update policy uses using to decide which existing rows may change and with check to decide what the changed row may look like. For update and all policies written without with check, PostgreSQL reuses the using expression in its place (PostgreSQL: CREATE POLICY). So the question is not whether a with check clause exists, but whether any permissive policy lets this caller write a row it should not.
Run this only in a disposable non-production project with synthetic data, and fill in real ids from your fixtures. It creates a row when it succeeds.
curl -s -w "\nHTTP %{http_code}\n" -X POST "${PROJECT}/rest/v1/bookings" \
-H "apikey: ${ANON}" \
-H "Authorization: Bearer ${USER_A_JWT}" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d '{"listing_id":"<fixture listing id>","renter_id":"<user B id>","starts_at":"2026-10-01T10:00:00Z","ends_at":"2026-10-02T10:00:00Z","total_cents":1000}'
A 201 means user A just created a row owned by user B: a write-isolation failure that no amount of reading the select policies would reveal. A 403 with error code 42501 means the database refused the write. Any other status is not yet a policy result; read the error body first.
Reading the result honestly
200with rows. Something granted this caller access. If the caller should not see those rows, that is the finding.200with[]. This request returned no rows for this caller. Row-level security filters reads silently, but an empty array has other causes too: a filter that matched nothing, a row that does not exist, a table with no data. It counts as evidence of isolation only when your positive control returned the row to its owner.401or403. Either the request was not authenticated the way you intended, such as a missing, expired or mismatched key or token, or the database refused the operation, such as a missing table grant or a failedwith check(42501). Read the error code in the body before concluding anything (PostgREST: errors).404for the table. The API does not expose a table by that name: it may be outside the exposed schemas, misspelled, or missing from PostgREST's schema cache. It tells you nothing about the table's policies.
Test the environment your users actually reach. Preview branches and staging projects can have different policies from production.
Common questions
Can I do this without real user accounts?
For the anonymous read, yes. For the cross-tenant tests you need two identities, because you are testing whether the database distinguishes between them. Create them in a non-production project with synthetic rows, and remember that you are then testing that project's policies rather than production's.
Is running this against production safe?
The reads are the same kind of request your own frontend makes. Never run the write test against production: it creates data when it succeeds. Nothing in this post changes schema or policies.
Should this live in CI instead?
Both, because they answer different questions. A CI test, for example with pgTAP, asserts that your policies behave as intended against a known fixture and catches regressions at merge time (Supabase: testing your database). A request against a live project tells you what that system does right now, including anything applied outside your migrations.
RowShield is not affiliated with, or endorsed by, Supabase.
Updated 15 September 2026: corrected what an empty result proves, how using and with check govern writes, and how to read 401 and 403; added a positive control, primary sources and a scope note.