Row level security in Postgres, and when a policy protects nothing
Row level security moves the question of who may see a row out of your application and into the database, where it gets answered on every query whether or not the code that wrote the query remembered to ask. That is the strongest position available for tenant data, which is exactly why the ways it silently does nothing are worth knowing precisely.
Where the evidence about a policy comes from
Two questions with two different sources. What your rules say lives in your migrations, and a rule that admits everybody is visible there before anything runs. What the database actually did when a request arrived is a fact about the deployment, and no amount of reading establishes it.
Repository validation
E2Reads what your migrations declare, table by table and command by command: which tables have row level security switched on, what each expression tests, which commands have no rule at all, and whether the identity a rule compares against comes from the session or from the request. Findings cite the migration. A rule created by hand in a database session and never committed is outside what a repository can show, and the control it belongs to is reported uncovered rather than passed.
Runtime validation
E4Builds and runs the application, signs in as two accounts, and asks for one account's rows using the other account's session. A rule that reads correctly and a rule that held when somebody asked are two different claims, and only the second is a fact about the deployed system. Where a run cannot reach a table, the control is reported uncovered rather than passed.
The ways a policy ends up protecting nothing
Each of these leaves you with row level security switched on, a policy in place, and no protection. That is worse than having none, because the migration, the code review and the table's own settings all say the job is finished.
Enabled and forced are two different switches
Enabling row level security on a table makes the database consult policies for ordinary roles. It does not apply them to the role that owns the table, and it never applies them to a superuser. So a policy can be correct, enabled, and completely inert, because the role your application connects as happens to own the table it was meant to protect. Forcing row level security is the setting that closes that, and it is the one most schemas are missing. Enabled means policies are consulted. Forced means they are consulted for the owner too.
A policy is evaluated per command, and commands do not share
Postgres checks policies per command, so a select, an insert, an update and a delete each need a rule. A table with a careful read rule and nothing else is not a table with three gaps: those three commands are refused outright, so the first symptom is a broken feature rather than a leak. The damage usually arrives with the fix, when one rule written for all commands turns the read rule into the write rule. Read rules are evaluated with a using expression against rows that already exist. Writes need a with check expression against the row as it will be, and an update rule with only a using clause lets a caller take a row they legitimately hold and move it into somebody else's scope.
A rule that only asks whether somebody is signed in scopes nothing
The expression is where all the work is, and two of them scope nothing: a literal true, and a test that the caller is authenticated. Where sign-up is open, authenticated is a set containing every stranger who filled in a form thirty seconds ago, so a rule that stops there hands them the whole table. Both usually begin as a way to unblock a feature on a Tuesday, and nothing about the running application looks any different once they are in place.
The comparison has to be against something the caller cannot set
A policy is only as good as the value on the other side of its comparison. An application that uses one database role for every user has to put the caller's identity somewhere the policy can read it, which in practice means a setting written into the session at the start of the transaction and read back inside the policy expression. Two conditions make that sound and both get missed. The value has to be derived from the verified session on the server rather than taken from anything in the request, and it has to be scoped to the transaction, so it cannot survive into whatever the connection pool hands to the next request.
Connecting as a superuser removes the whole mechanism
Policies do not apply to a superuser, and a role carrying the bypass attribute is exempt by design. An application still using the connection string that was convenient during setup is very often connecting as exactly that, which makes every policy in the schema decoration. Nothing warns you, because the queries work and the rows come back, and they would have come back with no policies at all. Give the application its own role, owning none of the tables it reads and holding no bypass attribute, then go and check what it is connecting as rather than what you meant it to.
Every policy is a predicate on every query
The rule is added to the query the planner runs, so the column your expression compares against is now part of the access path for every read of that table. If it is not indexed, the policy protecting the table is also the reason the table gets slower as it fills up, and a subquery inside a policy can be evaluated per row unless the planner can fold it away. This is not an argument against any of it. It is the reason to look at the plan on a large table after adding a policy, and to keep the expression simple enough that an index can be used.
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 |
|---|---|---|---|
| Object ownership is enforced server-side | Failed | E4 | Rows belonging to the second tenant came back on a read made with the first tenant's session |
| Injection-safe data access | Verified | E2 | Every statement read was parameterised |
| Secrets are managed outside source code | Partial | E2 | A connection string with a password in it is committed in a compose file |
| Server-side input validation on state-changing endpoints | Verified | E2 | Typed schemas with bounds on the write routes that were read |
| Database queries are index-backed at scale | Uncovered | Not graded | No plan was taken against a table of production size, so the cost of the policy predicate is not established |
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 does not tell you
- A public URL check cannot see any of this. There is no question about your database that a stranger holding your address can answer, so on that check the controls this page is about are reported uncovered rather than passed.
- Rules are read where they are declared. A policy created by hand in a database session and never committed to a migration is outside what a repository read can establish, and the control it belongs to is reported uncovered rather than passed on it.
- Reading a rule and testing it are different claims. An expression can read correctly and still never apply, because of the role the application connects as or a setting nobody wrote down, so only a run that asks the deployed system for another account's rows establishes what it does.
- 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
What is row level security?
A feature of Postgres that attaches rules to a table so the database itself decides which rows a caller may see or change. Once it is enabled on a table, every query against that table is evaluated with the matching rule added to it, so a query that forgot to filter by tenant returns nothing rather than everything. It moves the decision out of the code that writes queries and into the table, which is why it is the strongest position available for tenant data.
What is the difference between enabling and forcing it?
Enabling makes the database consult policies for ordinary roles. It exempts the role that owns the table, and superusers are exempt regardless. Forcing applies the rules to the owner as well. If your application connects as the role that owns its tables, which is common, only forcing makes any of your policies do anything at all.
Why does my policy return no rows at all?
Because a command with no rule is refused rather than allowed. Policies are per command, so a table carrying only a read rule refuses inserts, updates and deletes. Write one rule per command, with a using expression for the rows the command may touch and a with check expression for the row a write will leave behind.
Is a rule that checks the user is logged in enough?
No, and it is the most common empty rule there is. Where anybody can create an account, being authenticated is a set that includes every stranger who has just done so, and a rule that stops there returns the whole table to all of them. The expression has to compare a column on the row against the caller's own identity, taken from the session rather than from the request.
How does a policy know who is asking?
In an application that uses one database role for every user, the server writes the caller's identity into a session setting at the start of the transaction and the expression compares against it. What makes that safe is that the value comes from the verified session rather than from anything the caller sent, and that it is scoped to the transaction so it cannot leak into the next request that borrows the same pooled connection.
I am on Supabase. Is this the same thing?
The mechanism is exactly this, because it is Postgres underneath. What differs is the platform around it: which key reaches the browser, what the exposed schema publishes, and the helper functions their policies are usually written with. Those specifics are covered on the Supabase RLS audit page rather than repeated here.
What does StackAttest establish about this?
Repository validation reads what your migrations declare, per table and per command, and reports the rules that admit everybody, the commands with no rule, and the comparisons made against a value the caller supplies, citing the migration each came from. Runtime validation signs in as two accounts and asks for one account's rows using the other's session, which is the only layer that can say what the deployed system did. Each result carries the grade that records how it was established.
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.