Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

A PostgreSQL row-level security setup check can print “nothing to report” for two reasons that say nothing about whether the data is protected. It can read only the tables where RLS is already switched on, so tables that never had RLS enabled drop out of the output. It can also treat an empty result from information_schema.table_privileges as proof that a role has no access. Catching a missing FORCE ROW LEVEL SECURITY means reading two catalog flags for every table, inspecting the policies and bypass attributes that decide who is affected, and confirming which PostgreSQL role the Symfony connection actually uses.

What the two RLS flags control

Row-level security has two separate switches on each table. ENABLE ROW LEVEL SECURITY turns policy enforcement on. FORCE ROW LEVEL SECURITY decides whether the table owner is also subject to those policies. By default the owner bypasses RLS, so a check that only looks at the owner’s view of a table can look correct when non-owner roles are not protected.

Two situations bypass RLS regardless of the table flags. The PostgreSQL documentation, section 5.9 “Row Security Policies,” states: “Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table.” The same section also describes the closed default: “If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified.”

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The catalog exposes the two switches in pg_class as relrowsecurity (enabled) and relforcerowsecurity (forced). The PostgreSQL 18 catalog reference documents both columns. The combinations behave as follows:

State relrowsecurity relforcerowsecurity Who the table’s policies apply to
RLS disabled false Does not switch policies on No policy applies; ordinary SQL privileges govern access
RLS enabled, not forced true false Non-owner roles, except superusers and BYPASSRLS roles. The table owner bypasses policies.
RLS enabled and forced true true Non-owner roles and the table owner, except superusers and BYPASSRLS roles

Policies stored in pg_policy are evaluated only when relrowsecurity is true. A policy that exists on a disabled table protects nothing, which is why the disabled tables need their own line in any report.

The FORCE check, step by step

Run these queries on the database the application uses. Run them as the application’s role or a role with the same attributes. A superuser or BYPASSRLS session will see every row and can make a clean result look better than it is.

Inventory every ordinary and partitioned table

SELECT n.nspname AS schema_name,
       c.relname AS table_name,
       pg_catalog.pg_get_userbyid(c.relowner) AS owner,
       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;

rls_enabled tells you whether policies are enforced at all. force_rls tells you whether that enforcement reaches the table owner. Read both columns for every row.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Find enabled tables that are missing FORCE

Add this condition to the inventory query:

  AND c.relrowsecurity
  AND NOT c.relforcerowsecurity

Any row returned means the owner of that table bypasses its policies. The owner usually also has the most privileges, so this is the case most worth fixing.

List tables where RLS is off

Replace app with your schema name and run:

SELECT n.nspname AS schema_name,
       c.relname AS table_name
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 NOT c.relrowsecurity
  AND n.nspname = 'app'
ORDER BY c.relname;

Every table here should be one you have deliberately left to SQL privileges alone. Any table that holds tenant or user data and appears in this list is unprotected by row policies.

Blind spot 1: a check that only looks at tables with RLS enabled

The checker code for the scenario in the title was not available, so the two blind spots below describe failure patterns that these PostgreSQL behaviours make possible. They are not a reconstruction of any particular script.

Suppose a check filters on relrowsecurity and then looks for missing FORCE. In a schema with app.orders (enabled and forced) and app.invoices (never enabled), the filter sees only orders. The check finds nothing missing FORCE and reports clean. That output is accurate about enabled tables and silent about invoices, which is the one that matters.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The fix is structural rather than clever: the report needs the RLS-off list from the previous section as a separate section, compared against the tables the application is meant to protect.

Blind spot 2: treating an empty privilege view as no access

information_schema.table_privileges lists grants to or by roles that are currently enabled. An empty result therefore means no matching grants are visible for those roles. It does not establish that a login lacks effective privileges through role membership or other grant paths, and it says nothing about RLS filtering.

For a specific role and table, use the privilege inquiry functions. These answer SQL-privilege questions:

SELECT has_table_privilege('app_user', 'app.orders', 'SELECT') AS can_select,
       has_table_privilege('app_user', 'app.orders', 'INSERT') AS can_insert,
       has_column_privilege('app_user', 'app.orders', 'status', 'UPDATE') AS can_update_status;

Those functions do not tell you whether policies filter the rows that role can reach. For that, check the role’s attributes and whether it owns the table:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
SELECT r.rolname AS role_name,
       r.rolsuper,
       r.rolbypassrls,
       pg_catalog.pg_get_userbyid(c.relowner) = r.rolname AS owns_table
FROM pg_catalog.pg_roles AS r
CROSS JOIN pg_catalog.pg_class AS c
WHERE r.rolname = 'app_user'
  AND c.oid = 'app.orders'::regclass;

If rolsuper or rolbypassrls is true, the role bypasses policies. If owns_table is true and force_rls is false, the role bypasses policies on that table.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Confirm the role the Symfony connection uses

In Symfony, the Doctrine integration provides a DoctrineDBALConnection service. The PostgreSQL role is whatever user is in the connection string, usually set through DATABASE_URL in your environment configuration. The Symfony user object, its roles, and firewall decisions do not change the PostgreSQL current_user, its ownership, or its BYPASSRLS attribute. Ask the database.

use DoctrineDBALConnection;

final class RlsSetupCheck
{
    public function __construct(private Connection $connection) {}

    public function connectedRole(): array|false
    {
        return $this->connection->fetchAssociative(
            'SELECT current_user AS role_name, r.rolsuper, r.rolbypassrls
             FROM pg_catalog.pg_roles AS r
             WHERE r.rolname = current_user'
        );
    }
}

This sample is illustrative and has not been tested against a particular Symfony or DBAL release. Confirm the method name against your locked DBAL version. If the application sets a session value that policies read, such as a tenant identifier, the checker must set and query it on the same connection and inside the same transaction lifecycle the application uses. Otherwise the policy evaluates against a different context from the one you are inspecting.

Troubleshooting: when the check reports nothing

  • The missing-FORCE query returns no rows, but the RLS-off list contains a protected table. The first query cannot see tables with RLS disabled. Treat the RLS-off list as a finding, then decide whether to enable RLS and write policies.
  • Everything is clean, but the connected role has rolsuper or rolbypassrls set to true. The check ran as a bypassing role. Rerun it with the role the application uses.
  • The privilege view is empty, but has_table_privilege returns true. Trust the function for the SQL-privilege question. The view only covers grants to or by currently enabled roles.
  • Policies exist in pg_policies for a table whose rls_enabled is false. Those policies are dormant. Before running ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;, confirm that a policy applies to each command and role you need. A table with RLS enabled and no applicable policy is default-deny for non-owner roles.
  • The owner sees rows, but the application role does not. That is the owner bypass. Set ALTER TABLE app.orders FORCE ROW LEVEL SECURITY; and retest the owner’s queries.

What a complete setup report should contain

  • Schema, table, owner, relrowsecurity, and relforcerowsecurity for every ordinary and partitioned table in the application’s schemas.
  • The connected PostgreSQL role, its rolsuper and rolbypassrls values, and whether it owns each table.
  • Applicable policies from the pg_policies view, including command type, roles, the USING expression, and the WITH CHECK expression.
  • Effective SQL privileges from has_table_privilege and has_column_privilege, not inferred from an empty privilege view.
  • A note that referential-integrity checks bypass row security, and that row-security policies do not replace standard SQL privileges.

Trade-offs before setting FORCE

Forcing RLS on a table changes the owner’s view of it. Migrations, seed scripts, and maintenance jobs that run as the owner will now see filtered rows and can fail writes that policies block. Run them against a staging copy of the forced tables before changing production. If a maintenance role needs unrestricted access, grant it deliberately: BYPASSRLS bypasses policies on every table it can reach, so a shared role with that attribute quietly weakens every table, not only the one you intended.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Check the PostgreSQL major version in use before relying on catalog or view details, and check the locked Symfony and Doctrine DBAL versions before relying on the PHP sample.

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.