A PostgreSQL row-level security setup check can pass cleanly and still miss the failure that matters. The usual reason is that RLS has two separate table flags, and a check that reads only one of them, or that treats an empty privilege result as proof of access control, will report “nothing to report” while a table stays exposed. The fix is a check that reads both table flags for every relevant table, and that verifies effective access from the database role your Symfony application actually connects as.
Why RLS needs two flags, not one
PostgreSQL stores row-level security state on each table as two independent booleans in the pg_class catalog. They answer different questions:
As an Amazon Associate I earn from qualifying purchases.
relrowsecurityis true whenALTER TABLE ... ENABLE ROW LEVEL SECURITYhas been applied. Policies on the table are evaluated only when this flag is set.relforcerowsecurityis true whenALTER TABLE ... FORCE ROW LEVEL SECURITYhas been applied. It controls whether the table owner is subject to its policies.
Table owners normally bypass row security. That is the reason a missing FORCE is easy to overlook: an application that connects as the table owner, which is common in small deployments and in migrations that also run the app, sees unrestricted rows even though policies exist and look correct. Under PostgreSQL’s documented behaviour (section 5.9, “Row Security Policies”), superusers and roles with the BYPASSRLS attribute always bypass row security, regardless of FORCE.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
When RLS is enabled and no applicable policy exists, PostgreSQL uses a default-deny posture. The same section puts it this way: “If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified.” A table with RLS enabled and no policy is therefore locked for ordinary roles, which is safe but often looks like a bug to the application team, who may then “fix” it by disabling RLS altogether.
#1 Best Overall
What each combination actually does
| Table state | Non-owner app role | Table owner role | Superuser or BYPASSRLS role |
|---|---|---|---|
RLS disabled (relrowsecurity false) |
Policies not applied; normal SQL privileges decide access | Normal SQL privileges and ownership decide access | Always bypasses row security |
RLS enabled, FORCE disabled (relrowsecurity true, relforcerowsecurity false) |
Policies applied; with no applicable policy, no rows visible | Bypasses policies as owner | Always bypasses row security |
| RLS enabled, FORCE enabled (both true) | Policies applied | Policies applied | Always bypasses row security |
The middle row is the dangerous one for a setup check. The table looks protected in any report that reads only relrowsecurity, but the owner role used by the application is not restricted by it.
The FORCE check, step by step
Start by listing every ordinary and partitioned table in the schemas your application owns or uses, along with both flags. The query below is an illustrative diagnostic, not a tested Symfony tool. Adjust the schema filter and confirm it against your PostgreSQL major version.
SELECT n.nspname AS schema_name,
c.relname AS table_name,
c.relrowsecurity AS rls_enabled,
c.relforcerowsecurity AS force_rls
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname, c.relname;
Then work through the result in this order:
- Flag every row where
rls_enabledis false and the table is one the application is meant to protect. Report these separately. They are not missing-FORCE cases; they are tables with no row security at all. - Flag rows where
rls_enabledis true andforce_rlsis false. These are the missing-FORCE cases. The equivalent filter isrelrowsecurity AND NOT relforcerowsecurity. - For each flagged table, find the owner with
pg_get_userbyid(c.relowner), and compare it with the role your Symfony connection uses. If they match, the missing FORCE means the application is bypassing its own policies. - Check that a policy exists for the commands the application runs by querying
pg_policiesfor the same schema and table (see the policy section below).
Run the check on the same database and role as production, not on a local superuser connection. A superuser session will show every row regardless of how the table is configured, which produces a clean-looking result for the wrong reason.
Rank #2
Blind spot 1: a check that only looks at enabled tables
If the query filters on relrowsecurity, tables with RLS disabled never enter the result set. A report that says “no enabled tables are missing FORCE” does not show that the intended tables have RLS enabled at all. Those tables will simply not appear.
The fix is structural rather than cosmetic: keep the full table list, and compare it against an explicit list of tables that must be protected. Any protected table with rls_enabled false should fail the check, independently of the FORCE result.
Blind spot 2: an empty privilege view is not proof of no access
The second common silent result comes from reading information_schema.table_privileges. That view lists privileges granted to or by roles that are currently enabled, so an empty result tells you that no such grant is visible through that view. It does not prove the application role lacks effective access. Privileges can arrive through role membership, ownership, or other paths the view does not show in the way a check expects.
Rank #3
For an explicit question about a named role and table, ask PostgreSQL directly:
SELECT has_table_privilege('app_role', 'public.orders', 'SELECT') AS can_select,
has_table_privilege('app_role', 'public.orders', 'INSERT') AS can_insert;
For column-level grants, use has_column_privilege(...) in the same way. Separately, check the role’s attributes and the table owner, because those determine RLS bypass, which privilege functions do not report.
These two blind spots are patterns to test for, not a diagnosis of a particular checker script. The SQL behind any specific report is not reproduced here, so compare your own queries against both patterns.
Who the database actually sees
In Symfony, the Doctrine DBAL Connection is the database access layer. Services that inject it run their queries under whatever PostgreSQL role the connection was opened with, usually the user in the DATABASE_URL value in your environment configuration. The Symfony user object and its security roles do not change the PostgreSQL role. An application can be fully authenticated in Symfony and still be connected to PostgreSQL as the table owner or as a role with BYPASSRLS.
Confirm the identity at the connection itself:
SELECT current_user,
r.rolsuper,
r.rolbypassrls
FROM pg_catalog.pg_roles AS r
WHERE r.rolname = current_user;
Run this through the same DBAL connection the application uses, for example with a short console command, so that the result reflects the real credentials rather than an administrative session.
Policy definitions matter as much as the flags
A policy applies only when its table has RLS enabled, and it applies only to the commands and roles it names. Inspect the policies in pg_policies, checking the command type, the roles, and both the USING and WITH CHECK expressions. A table with a policy for SELECT only will allow reads and still allow no inserts or updates through that policy’s scope. Likewise, a policy that is correct on paper may have an expression that evaluates to false for every real row.
Two further limits are worth recording in any setup report. Referential-integrity checks bypass row security, so a foreign-key lookup can touch rows the policy would hide. And row-security policies do not replace standard SQL privileges: a role without a SELECT grant still cannot read a table, whatever its policies say.
Tenant context set on the connection
Many multi-tenant designs read a session setting inside policies, for example through current_setting(...), and the application sets that value per request. If you use that approach, the setting and the queries must run on the same connection within the same transaction lifecycle. A value set on one pooled connection does not carry to another. Using set_config(name, value, true) limits the setting to the current transaction, which can prevent leakage between requests but also means the value disappears at commit. Confirm how your DBAL version handles transactions and connection reuse before relying on this pattern; the behaviour depends on your versions and middleware, and it is not something to assume from a generic example.
What a useful setup report should contain
- Each protected table, its schema, and both
relrowsecurityandrelforcerowsecurityvalues. - The PostgreSQL role the Symfony connection uses, its owner status for each table, and whether it has
rolsuperorrolbypassrls. - The policies that exist for each table, their commands, roles, and
USINGandWITH CHECKexpressions. - Effective privileges for the application role, from
has_table_privilegeandhas_column_privilege, not inferred from an empty privilege view. - A failing status for any protected table that is missing RLS, and a separate failing status for any table with RLS enabled and FORCE disabled whose owner is the application role.
Validate the queries above against the PostgreSQL version you deploy and the Doctrine DBAL and Symfony versions in your lock file before treating them as a gate in CI.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallQuick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




