Skip to content

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

How we use AISend a correction

Illustration accompanying Postgres row-level security does nothing for the role your Symfony app connects with
Generated illustration

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
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [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. [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.

    ReportedSupportedSource: dev.to (mollenthiel)View cited source
  3. [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).

    ReportedSupportedView cited source

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · August 21, 2026

    Row-level security in Symfony: the role that ran your migrations bypasses every policy you wrote

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Entities

Loading related stories