Compare an expected list of tenant-scoped tables with the catalog flags in pg_class and the policies in pg_policies, and fail CI when a table is unclassified, has row security off or has no policy. Also check that the request role is neither a superuser nor a BYPASSRLS role.
Why drift happens
Row-level security is per table. A developer adds a new table with a tenant_id column, forgets to enable RLS, and nothing breaks. Queries work, tests pass, and one table is now unprotected. The OWASP Multi-Tenant Security Cheat Sheet warns about a related trap: a hand-maintained list can drift, since the same omission that leaves RLS off a new table can leave that table out of the test.
The catalog checks
The cheat sheet names the sources to compare: PostgreSQL's pg_class.relrowsecurity and relforcerowsecurity, plus the pg_policies view, and pg_roles for role attributes. A query along these lines lists ordinary tables in a schema with their status:
SELECT c.relname,
c.relrowsecurity,
c.relforcerowsecurity,
(SELECT count(*) FROM pg_policies p
WHERE p.schemaname = n.nspname
AND p.tablename = c.relname) AS policy_count
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = 'public';
Read the columns this way.
| Column | Good value for a tenant table |
|---|---|
| relrowsecurity | true |
| relforcerowsecurity | true if the owner role should be covered |
| policy_count | at least 1 |
PostgreSQL's documentation notes that when RLS is enabled with no policy, a default-deny policy applies. So a table with RLS on and zero policies is closed to normal roles, which is safe but usually a bug you want to see. A table with RLS off is wide open to anyone with table privileges.
Classify, do not guess
Do not decide by column name alone. The cheat sheet suggests deriving the tenant-scoped inventory from the schema or an explicit classification, and gating schema changes so every new table must be marked tenant-scoped, intentionally shared, or otherwise isolated.
A simple scheme:
- Keep a small file mapping each table to a class.
- Fail if a table exists in the database but not in the file.
- For tenant-scoped tables, fail if RLS is off or no policy exists.
- For shared tables, require a reason in the file.
Check the request role
Policies do nothing for a role that bypasses them. PostgreSQL documents that superusers and roles with BYPASSRLS always bypass row security, and table owners normally do too. The sheet says to assert in the deployed environment that the request-path role has neither rolsuper nor rolbypassrls set, and to check the deployed role, not only the one declared in source control.
SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname = current_user;
Run it as the application's login, in the same environment that serves traffic.
Put it in CI
- Run the table query against a migrated test database on every pull request.
- Make the job a required check so an unclassified or unprotected table blocks the merge.
- Run the role assertion in a deployed environment after release.
- Add a behavioural test: two tenants, same pooled connection, second tenant must see none of the first's rows.
Try it yourself
Click through it at your own pace, or open it full screen. Look at how a failing check stops a merge, the behaviour you want for an unprotected table.
Safeguard supports policies and gates on pull requests plus SBOM and dependency scanning. Try the free scan at gold.safeguard.sh/scan.
Quick check
Does a table with RLS on and no policy leak data?
Not to normal roles, because the default is deny. It is still worth flagging, since the table is probably unusable and someone may fix it with a permissive role.
Do views need row security too?
Check how your Postgres version treats views, and test it. Do not assume a view inherits the caller's restrictions without verifying in the documentation and with a test.
Sources
Last reviewed: 19 October 2026.