TL;DR — Key Takeaways
- Enabling RLS and creating policies does not guarantee enforcement because superusers
BYPASSRLSroles and table owners can still bypass policies. - Local and managed environments can expose the same flaw for different reasons, so tests must inspect both the runtime role and table ownership.
- Strong verification should test successful tenant access, no-context denial and blocked cross-tenant writes using the same identity the application actually runs under.
The row-level security migration looked complete. Both tenant tables had RLS enabled. Each table had a policy comparing its tenant_id with a transaction setting. A local proof selected a tenant, queried the event table and printed a plausible count.
The application still saw every tenant’s rows in Azure Database for PostgreSQL Flexible Server.
The policy expression was not the problem. The application connected as the role that owned the tables. PostgreSQL normally exempts table owners from row-level security unless the table uses FORCE ROW LEVEL SECURITY.
The local proof missed the defect for a different reason. It ran through a local administrator with the SUPERUSER attribute, and superusers always bypass RLS. The managed database administrator was not a superuser, but it owned the tables. Two environments skipped the same policy through two different exemptions.
Figure 1: A Policy can Exist and Remain Outside the Query Path
Enabling RLS is Only the First Gate
The original migration used the expected shape:
ALTER TABLE event_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY event_log_tenant_policy ON event_log
USING (
tenant_id = current_setting(‘app.tenant_id’, true)
);
The second argument to current_setting is missing_ok. When the transaction has no tenant value, the function returns NULL instead of raising an error. The comparison then does not return true, so a role subject to the policy sees no rows.
That is a useful fail-closed policy, but only after PostgreSQL decides that the current role must obey RLS. PostgreSQL documents three relevant cases:
- A superuser always bypasses row security.
- A role with BYPASSRLS always bypasses row security.
- A table owner normally bypasses row security unless the table has FORCE ROW LEVEL SECURITY.
Since this policy applies to all commands and has no separate WITH CHECK expression, PostgreSQL also uses the USING expression to validate inserted and updated rows.
Checking only pg_policy or relrowsecurity therefore proves configuration, not enforcement. The effective role and the table owner form part of the security control.
The Local Proof Printed a Result Instead of Proving Isolation
The first proof script opened a transaction, set the tenant value and printed a row count. It did not create records for two tenants and assert that only one tenant remained visible. Moreover, it did not inspect the current role.
That allowed a bypassed query to look healthy. Setting app.tenant_id succeeds even when no policy uses it. A count printed under that setting says nothing about whether RLS filtered the result.
The proof needed to inspect both identity and table state:
SELECT
session_user,
current_user,
rolsuper,
rolbypassrls
FROM pg_roles
WHERE rolname = current_user;
SELECT
n.nspname AS schema_name,
relname,
pg_get_userbyid(relowner) AS table_owner,
relrowsecurity,
relforcerowsecurity
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = ‘public’
AND relname IN (‘event_log’, ‘outbox’);
Those two queries explain more than a list of policy names. They show whether the session can bypass RLS through a role attribute, whether it owns the protected tables and whether forced enforcement is active.
The environment difference also mattered. Azure Database for PostgreSQL Flexible Server does not give the customer administrator full PostgreSQL superuser access. In this deployment, however, that administrator had created and owned the application tables. The local administrator bypassed RLS as a superuser. The managed administrator bypassed it as the owner.
Figure 2: Similar Query Results hid Different Identity Models
A Seed Workaround Disabled the Tables That Needed Protection
An earlier review had raised a valid concern: A seed migration can fail when forced RLS applies and the session has no tenant context. The attempted repair added NO FORCE ROW LEVEL SECURITY before the seed.
The implementation targeted the event and outbox tables, while the seed inserted into a separate tenant registry that had no RLS. The workaround therefore disabled forced enforcement on the two tables that carried tenant data and did nothing useful for the table being seeded.
The mistake survived because the migration still completed. PostgreSQL kept the policies, and ordinary catalog checks continued to show RLS enabled. NO FORCE does not disable RLS for normal roles. It restores the owner’s exemption, which was exactly the application’s access path.
I removed those two NO FORCE statements and added a dedicated migration:
ALTER TABLE event_log FORCE ROW LEVEL SECURITY;
ALTER TABLE outbox FORCE ROW LEVEL SECURITY;
This made the table owner subject to the existing policies without changing their expressions. In this deployment, the runtime role now needed a correct tenant context.
A separate non-owner application role would also have solved the bypass, and the repository already provisions one. The earlier migration creates a read-write login and grants it privileges on the existing tables and sequences, so the role-provisioning path existed before this fix. The running application still connected as the owner. Forcing RLS closed the immediate hole without changing that connection. Moving the runtime onto a dedicated non-owner role remains the cleaner end state because it separates table ownership and migrations from application access.
Forced RLS Exposed the Dispatcher’s Missing Tenant Context
The request path already opened a transaction and set app.tenant_id. The background dispatcher did not. It queried the outbox globally for unpublished rows.
Before forced RLS, owner bypass made that query work and concealed the missing context. After the fix, the same query did not throw an error. The fail-closed policy returned zero rows. The dispatcher would have continued polling an apparently empty outbox while messages waited indefinitely.
The empty result made the failure hard to detect. Security enforcement often changes a bad query into no visible rows instead of an exception. A worker can remain healthy, log no database error and stop doing useful work.
I changed the dispatcher to read tenant identifiers from the unprotected tenant registry, then open one transaction per tenant:
read tenant identifiers
for each tenant
begin transaction
set local app.tenant_id
select unpublished outbox rows
publish each message
mark each row as published
commit
The tenant registry acted as bootstrap data. It contained tenant identifiers and names, while the event payloads and queued messages stayed behind RLS. A larger system could partition dispatch work, use a privileged function with a narrow contract or give the worker a separate access design. For this runtime, a tenant-by-tenant transaction kept the same policy active for web and background work.
Figure 3: Forced RLS Turned Tenant Context Into a Requirement for Every Reader
The Isolation Proof Tested Denial as Well as Success
A separate one-off proof used a non-superuser table owner to reproduce the managed database model. It checked four conditions:
- relrowsecurity and relforcerowsecurity were both true.
- A transaction for tenant A saw only tenant A’s rows.
- A session with no tenant context saw zero protected rows.
- A transaction for tenant A could not insert a row for tenant B.
The negative checks mattered more than the original successful query. A useful isolation test must place known data on both sides of the boundary and fail when the wrong side becomes visible.
The committed verification scripts still use ungrouped counts, so they should not be treated as a complete isolation gate. A total of one can still be the wrong row. A stronger assertion returns the tenant identity beside the count:
BEGIN;
SET LOCAL app.tenant_id = ‘tenant-a’;
SELECT tenant_id, count(*)
FROM event_log
GROUP BY tenant_id;
INSERT INTO event_log (
tenant_id,
slack_event_id,
payload
) VALUES (
‘tenant-b’,
‘cross-tenant-test’,
‘{}’::jsonb
);
ROLLBACK;
The SELECT must return only tenant-a. The INSERT must fail against the policy’s implicit WITH CHECK expression. The no-context check should use a fresh connection so an earlier transaction cannot influence the result. The reusable verification scripts still need this grouped assertion. Using SET LOCAL kept the tenant value inside the transaction and avoided returning a pooled connection with another tenant’s session state.
This test should also run through the same database role as the application. A dedicated test owner can validate forced RLS, while a dedicated non-owner role can validate the more common application model. Running only as a local superuser tests neither one.
A synthetic message then passed through the event transaction, entered the outbox and reached the tenant-aware dispatcher, which published it. That end-to-end path checked that stronger isolation had not silently stopped the worker.
Boot-Time Migration Failures Could Leave RLS Disabled
The deployment had another silent failure path. The application runs its own migrations on boot, inside a try that logs a failure and continues:
try {
await migrate();
} catch (e) {
console.error(‘Migration failed’, e);
}
A failed force-RLS migration therefore leaves the service running with the owner exemption still in place, and the only signal is one line in a container log. The dispatcher container never calls migrate() at all. The deployment still needs a separate migration step that must succeed before either runtime starts.
A green migration log proves that PostgreSQL accepted the DDL. It cannot prove that the runtime role obeys the policy, because the role that runs the migration and the role that serves requests can differ in exactly the privilege that matters. That needs testing with the identity the application actually uses.




