Build1 publisherNot yet confirmed elsewhere3 min readPublished
Postgres row-level security does nothing for the role your Symfony app connects with
A table owner bypasses its own policies with no superuser rights and no BYPASSRLS, and one connection string makes the owner the serving role. Tests seeded with a single tenant never notice.
The Engineer · Build desk

What happened
- PostgreSQL's manual states that table owners normally bypass row security on their own tables, without needing superuser rights or the BYPASSRLS attribute.
- On PostgreSQL 18.3, a non-superuser owner role with RLS enabled and a correct tenant policy counted both organizations' invoices and got 2, with no error or log line.
- Symfony apps carry a single DATABASE_URL, so the role that runs the migrations owns the tables and also serves every request.
- The commenter who argued for putting the predicate in Postgres also reported two RLS bugs that tests missed, because the test connection was a superuser.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Any tenant table with RLS enabled and forcing off, owned by the connecting role, gives an auditor a control that exists in the schema and does nothing at query time.
- constraint A passing functional suite stops being evidence of tenant isolation, so confidence in database-enforced boundaries has to be rebuilt on negative assertions that most suites never wrote.
- decision Teams that pushed the predicate into Postgres to stop depending on developer memory now have to choose between forcing RLS per table or splitting the migrating role from the serving role.
- cost The bill is per table rather than per platform, and a tenant table added next quarter starts out unforced unless someone remembers the extra line.
The column that decides everything is the last one in the diagnostic. The status query in the dev.to post prints current_user, is_superuser, has_bypassrls, table_owner, rls_enabled and rls_forced, and on the throwaway database it reads app, f, f, app, t, f [4]. Five of those six values are what a reviewer wants to see. The sixth is the one that governs whether the policy on that table runs at all [1].
The arithmetic is short. Two invoices, one per organization, and a count of 2 from a connection that set no tenant at all [5]. That is every row in the table, 100 percent of it, through a policy that was enabled and correctly written [14]. After FORCE ROW LEVEL SECURITY, the same connection running the same query against the same policy returns 0, and returns 1 once the tenant setting is present [6]. Nothing about the predicate changed between those two results [16].
What makes this a deployment problem rather than a documentation footnote is ownership. A Symfony app has one DATABASE_URL, so the role that ran doctrine:migrations:migrate is the role that serves the request, and the role that created the table owns it [7]. Deploy guides arrive there honestly: creating the database owned by the app role is what lets migrations run without a hand-written privilege grant [8]. Postgres does not treat the arrangement as unusual, and the manual says table owners "normally" bypass row security, in a sentence most people read once while looking for CREATE POLICY syntax [1].
The thread that produced this started with a commenter, @to21as, reporting two RLS bugs that were both invisible in tests because the test connection was a superuser [12]. The obvious lesson from that is to stop testing as superuser, and it is not enough. A plain non-superuser role that happens to own the tables behaves the same way, because ownership is a separate bypass from the superuser one [1]. The fixtures conceal it in either case: seeding one organization and asserting that its user sees its own row passes identically whether the policy applies or is inert [10]. The assertion that fails is the negative one, and a suite without it stays green on the day enforcement stops [10].
The failure direction is what carries this through code review. Too many rows, not too few [9]. A filter that returns nothing is found in seconds; a filter that returns everything renders a page that looks correct [9]. What ships is not weakened isolation, it is the state the database was in before any policy was written.
The remedy in the article is one statement per table, added to the migration that already enables RLS [13]. The check is that same six-column query, run in production as the application role: rls_enabled true, rls_forced false, table_owner equal to current_user, and the policy is decoration [4][1].
What to watch
- Whether Symfony or Doctrine tooling ships a default that separates the migrating owner role from the runtime role, instead of one DATABASE_URL for both.
- Whether the PostgreSQL docs harden the "normally bypass" wording in section 5.9, or add a warning next to CREATE POLICY.
- Any published survey counting production tables with rls_enabled true and rls_forced false under an owner login.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence74
- Adoption
- Insufficient
- Hype gap+14
- Incentives26
- Confidence68
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
PostgreSQL 18 documentation, section 5.9, read on 2026-08-21: superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table; table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY.
ReportedSupportedSource: PostgreSQL 18 documentation, section 5.9, as quoted by dev.toView cited source - [2]
The measurements were published on dev.to and taken on PostgreSQL 18.3 on a throwaway database, with every command reproduced in the article.
- [3]
Setup: CREATE ROLE app LOGIN; CREATE DATABASE app_db OWNER app; an invoice table with an organization_id column; ALTER TABLE invoice ENABLE ROW LEVEL SECURITY; and CREATE POLICY tenant_isolation ON invoice USING (organization_id = current_setting('app.organization_id', true)::int).
- [4]
The diagnostic row for that table read: current_user app, is_superuser f, has_bypassrls f, table_owner app, rls_enabled t, rls_forced f.
- [5]
With two invoices in the table, one for each of two organizations, the app role connected, set nothing, and ran SELECT count(*) FROM invoice, which returned 2, with no error, no warning and no log line.
- [6]
After ALTER TABLE invoice FORCE ROW LEVEL SECURITY, the same role on the same connection running the same query got 0; after SET app.organization_id = '2' it got 1.
- [7]
A Symfony app has one connection string, DATABASE_URL, so the role that runs doctrine:migrations:migrate is the role that serves requests, which makes the serving role the table-owning role; neither the framework, Doctrine nor Postgres treats that as unusual.
- [8]
The author's own deploy guide creates the database owned by the app role, because that is what makes doctrine:migrations:migrate work without a privilege dance.
- [9]
The failure direction is extra rows rather than missing ones: a broken filter that returns nothing is noticed in about four seconds, while a filter that returns everything looks like a working page.
- [10]
A functional test that seeds one organization, logs a user in and asserts it sees its own row passes identically whether the policy applies or is inert; the assertion that catches the problem is the negative one, seeding a second organization and asserting the first user cannot count it.
- [11]
Commenter @to21as argued the tenant predicate should live in Postgres as a row-level security policy rather than in the ORM, so that a Messenger worker, native SQL and an ad-hoc psql session all get the same WHERE clause; the author says this closes four of the five holes he had listed for the ORM approach.
- [12]
@to21as reported that their two RLS bugs had both been invisible in tests, because the test connection was a superuser and superusers bypass RLS.
- [13]
The recommended fix is to add ALTER TABLE ... FORCE ROW LEVEL SECURITY to the same migration that enables RLS, next to the policy, described as cheap and local with no infrastructure change.
- [14]
Both of the two seeded rows were visible, that is 100 percent of the tenant data, through an enabled and correct policy.
- [15]
By the author's own count, moving the predicate into Postgres leaves one of the five listed holes open.
- [16]
Between the count of 2 and the count of 0, the role, connection, query and policy text were identical; the only difference was the FORCE ROW LEVEL SECURITY statement.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toRow-level security in Symfony: the role that ran your migrations bypasses every policy you wrote
1 article · August 21, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
- Postgres Row-Level SecurityFollow
- Security Regression TestingFollow
- Symfony and Doctrine Deployment PracticeFollow
- Insecure Defaults and MisconfigurationFollow
- Multi-tenant data isolationFollow