Find out whether your row level security policies scope anything
Row level security is the thing standing between a key that is public by design and the rows in your database. It either scopes a row to its owner or it does not, and while you are the only account in the database the difference is invisible. This page is about the policies themselves: what they say, which commands they cover, and what the running system does when someone asks for a row that is not theirs.
How a policy actually gets checked
Policies are declarative, so they can be read. Whether they hold is a fact about the running system, so it has to be tested. The report keeps the two apart and grades them differently.
Repository validation
E2Reads the policies your migrations declare, table by table and command by command: which tables have row level security enabled, what each using and with check expression actually tests, and whether the caller's identity comes from the session or from the request. Findings cite the migration they came from.
Runtime validation
E4Builds and runs the application, signs in as one account, and attempts to read and change data belonging to another. That is evidence a policy review cannot produce: not what the rule says, but what the deployed system returned when someone asked. Where a run cannot reach a behaviour, the control is reported uncovered rather than passed.
Public URL scan
E4Tests the deployed application from outside: transport security, security response headers, and what the running service discloses about itself. It cannot see a policy, and it does not report the publishable key in your bundle, because that key is meant to be there. What it does observe is observed against the running system; what it cannot reach stays uncovered rather than assumed.
The policy mistakes that keep turning up
Every one of these is a rule that looks finished. The audit establishes which of them are present in your schema and what the deployed system does about them.
A table with row level security switched off
A table in the exposed schema with row level security off is readable by whoever holds the publishable key, and that key is in the browser by design. There is no second lock behind it. Supabase flags this state in its own dashboard, and it is still the most common finding in an application that shipped in a hurry.
A policy that permits everything
A policy whose expression is a plain true returns every row. So does one that only tests that the caller is authenticated: if sign-up is open, authenticated means anyone who filled in a form. Rules like these usually start as a way to unblock development, and nothing about the application looks different afterwards, so nobody goes back to them.
A tenant identifier the caller supplies
A policy that compares a column against a value taken from the request scopes nothing, because the request belongs to the caller. The identity has to be derived from the session, through auth.uid() or a claim in the verified token, so that changing the value in flight changes nothing about what the database is willing to return.
A correct read rule and no thought about writes
Policies are per command. A table often has a SELECT policy that scopes rows properly and no rule at all on INSERT, UPDATE or DELETE. Postgres refuses a command it has no policy for, so the first symptom is a broken feature rather than a leak, and the damage tends to arrive with the fix: one policy written for all commands makes the read rule the write rule, and an UPDATE policy with a using clause and no with check lets a caller move a row out of their own scope. Coverage is reported per command, not per table.
Filtering in the query, and calling it authorisation
A client-side query that filters on the current user's id looks like a restriction and is not one. The filter is part of the request, so it is under the caller's control, and dropping it is a one-line change to code that already runs on their machine. What the database returns without that filter is the only answer that counts.
A view that steps around the policy
A view in an exposed schema is queryable through the API like any table. Unless it is declared to run as the invoker, it executes with its owner's privileges, so the policies on the tables underneath it never apply to whoever called it. It is an easy way to publish exactly the data those policies were written to protect.
What the result looks like
An illustrative extract, not a real customer result. Every row carries the evidence grade behind it, and a control nobody could establish says so.
| Control | Status | Evidence | Note |
|---|---|---|---|
| Row level security enabled on every exposed table | Failed | E2 | One table in the migrations is exposed with no policy |
| Policies scope rows to the caller, not to any session | Failed | E2 | Two policies test only that the caller is authenticated |
| Cross-account read refused by the running system | Verified | E4 | A read of another account's rows returned no data |
| Write commands covered by rules of their own | Partial | E2 | Update policy carries a using clause and no with check |
| Policies on tables created outside the repository | Uncovered | Not graded | Schema changed in the dashboard is not visible to the layers that ran |
Mapped to published standards
Each control is tested against a named clause, so a result means something outside our own vocabulary. Mapping is not certification, and none of these bodies endorse StackAttest.
What this audit is not
- It does not read your Supabase dashboard. Policies are read where they are declared, in your repository, and tested where they take effect, in the running application. A rule that exists only in the dashboard is outside what the run can see, and the control it belongs to is reported uncovered.
- It is not a penetration test. A deterministic check and a runtime probe are not an adversarial human engagement, and there are classes of weakness only a person hunting for them will find.
- It is not SOC 2 and does not replace it. SOC 2 attests to organisational controls over time; this validates technical controls in the software. They answer different questions for different buyers.
- It does not replace human due diligence. It gives a reviewer repeatable evidence to start from, which is a different thing from being the reviewer.
- Controls the run could not reach are reported as uncovered rather than passed. A result you did not earn is worse than no result.
Questions
How do I check my Supabase RLS policies?
Read them per table and per command, then test them. Reading establishes what each policy claims to scope. Testing establishes what the deployed system returns when one account asks for another account's rows. An audit does both and records which kind of evidence sits behind each result.
My table has row level security enabled. Is it protected?
Not necessarily. Enabling it only means policies are consulted. If the policy behind it evaluates to true, or asks only whether the caller is signed in, every row is still readable by anyone who can create an account. Enabled and scoped are different claims and only the second one protects anything.
Is it a problem that the Supabase key is in my frontend?
The publishable key belongs there. It is safe because row level security decides what it can reach, which is exactly why an unprotected table matters so much: the key is public, so the policy is the only thing left. The service role key is the one that must never appear in client code.
Can you test whether one account can read another account's data?
That is what runtime validation is for. The run signs in as one account and attempts a read and a write against data belonging to another, then records what the deployed system did. If a run cannot reach that behaviour, the control is reported as uncovered rather than passed.
Does filtering by user id in my query keep other users out?
No. The filter travels with the request, so it is under the caller's control. It shapes what your application asks for, not what the database is willing to answer. Authorisation has to live in a policy or on a server, somewhere the caller cannot edit it.
Do you need my Supabase credentials?
No. There is no integration with Supabase and nothing asks for your project keys. Source evidence comes from a read-only connection to your repository, and runtime evidence comes from building and probing the application itself.
Related
Find out what is actually true of your application
Start with the layer you can run today. The report says which layers ran and what they could not reach, so the result is honest about its own limits.