A comment on my last article was better than the article. The subject was single-database multi-tenancy, and @to21as argued that the predicate should not live in the ORM at all: put it in Postgres as a row-level security policy, and a Messenger worker, a line of native SQL and an ad-hoc psql session all get the same WHERE clause whether anyone remembered it or not. That is correct, and it is the strongest version of the case against doing it in Doctrine. It closes four of the five holes I had just finished listing.
Then came the warning: their two RLS bugs had both been invisible in tests, because the test connection was a superuser and superusers bypass RLS.
The trap is wider than superusers, and the wider version is the one that lands on a Symfony deployment. A plain role that merely owns the table bypasses that table's policies too. Not a superuser. No BYPASSRLS. Just the owner. And the role that owns your tables is, in almost every Symfony deployment I have read, the same role your application connects with.
Everything below was measured on PostgreSQL 18.3, on a throwaway database, and every command is in the article so you can disagree with the result rather than with me.
The setup, which is the one you would write
CREATE ROLE app LOGIN PASSWORD '...';
CREATE DATABASE app_db OWNER app;
That second line is not a strawman. It is what my own deploy guide says, and it is what makes doctrine:migrations:migrate work without a privilege dance, which is why it tends to be what a deploy guide says. Then a migration does the usual:
CREATE TABLE invoice (
id int PRIMARY KEY,
organization_id int NOT NULL,
total_cents int NOT NULL
);
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
USING (organization_id = current_setting('app.organization_id', true)::int);
RLS is on. The policy is right. The role is not a superuser and has no BYPASSRLS:
current_user | is_superuser | has_bypassrls | table_owner | rls_enabled | rls_forced
--------------+--------------+---------------+-------------+-------------+------------
app | f | f | app | t | f
Two invoices in the table, one for each of two organizations. The application connects as app, sets nothing, and asks:
SELECT count(*) FROM invoice;
2. No error, no warning, no log line. Every tenant's rows, through a policy that is enabled and correct.
One line changes the answer
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
Same role, same connection, same query. 0. Set the tenant and you get exactly the one row you should:
SET app.organization_id = '2';
SELECT count(*) FROM invoice; -- 1
The documentation is not hiding this. PostgreSQL 18, section 5.9, read on 2026-08-21:
Superusers and roles with the
BYPASSRLSattribute 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 withALTER TABLE ... FORCE ROW LEVEL SECURITY.
"Normally bypass" is doing a lot of work in a sentence most of us read once, while looking for the syntax of CREATE POLICY.
Why a Symfony project walks into this and a test suite does not catch it
Three things have to line up, and a standard deployment lines up all three.
Doctrine creates the tables, under the credentials in DATABASE_URL. There is one connection string in a Symfony app. It runs the migrations and it serves the requests, so the serving role is the owning role. Nothing in the framework, in Doctrine or in Postgres considers that unusual, because it is not unusual. It is the default shape.
The failure direction is extra rows, not missing ones. A broken filter that returns nothing gets noticed in about four seconds. A filter that returns everything looks like a working page. Under RLS with an owner role you are not in a degraded state, you are in the state you were in before you wrote any of it.
Your fixtures make the two indistinguishable. 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 this is the negative one: seed a second organization, and assert the first user cannot count it. If your suite has only the positive assertion, the day the policy stops applying is a day nothing turns red.
Which is how you ship a database with row-level security enabled on every tenant table, policies written and reviewed, and not one of them in force.
Two fixes, and I measured both
FORCE ROW LEVEL SECURITY
Add it to the same migration that enables RLS, next to the policy:
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
Cheap, local, no infrastructure request. The catch is that it is per table and there is no default: the next tenant table someone adds in six months comes back with relforcerowsecurity = false and a policy that does nothing. This is a guarantee that decays.
A serving role that does not own anything
CREATE ROLE app_migrate LOGIN PASSWORD '...'; -- owns the schema, runs migrations
CREATE ROLE app_serve LOGIN PASSWORD '...'; -- serves requests, owns nothing
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_serve;
Connecting as app_serve, with no FORCE anywhere and nothing set, the same two tables return 0 of 2. Set the tenant inside a transaction and you get 1. The bypass never existed, because ownership never existed.
This is the version that does not decay, and it is the version that costs you something real: two connection strings, a migration step that runs as a different user than the app, default privileges to get right for future tables, and a deploy document that now has a paragraph in it. If you are shipping a repo that other people deploy on infrastructure you will never see, that paragraph is a support cost forever. That trade is the actual decision, and it is not a database question.
The guard that survives the next table
Whichever you pick, this belongs in your suite, not in a runbook:
SELECT c.relname, c.relrowsecurity AS enabled, c.relforcerowsecurity AS forced
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
AND a.attname = 'organization_id'
AND NOT a.attisdropped
WHERE c.relkind = 'r' AND n.nspname = 'public';
Every table with an organization_id column, and whether its policies actually apply. Assert that the list of unprotected ones is empty and the test names the new table for you the day someone adds it. On my demo database it correctly reported one table protected and one not, which is the only reason I trust the rest of this article.
And once RLS does apply, the setting has two ways to be missing
The policy reads current_setting('app.organization_id', true). There are two different empty states behind that call, they fail differently, and only one of them is loud.
SET LOCAL outside a transaction block does nothing. Postgres raises a warning, not an error, and PDO does not turn warnings into exceptions:
$pdo->exec("SET LOCAL app.organization_id = '2'"); // no exception
$pdo->query("SELECT current_setting('app.organization_id', true)")->fetchColumn();
// string(0) ""
A Symfony request has no open transaction until something flushes, so a kernel.request listener that issues SET LOCAL is issuing it into nothing. Scoping a request with RLS means an explicit transaction wrapped around the whole request, which is a much bigger architectural commitment than the two lines it looks like.
Then the two empty states diverge, and this is worth knowing before it happens in production:
-
never set:
current_setting(..., true)returnsNULL,NULL::intisNULL, the comparison isNULL, you get zero rows and no error. A blank dashboard. -
set, then discarded: the setting exists and is the empty string, and
''::intthrowsSQLSTATE[22P02] invalid input syntax for type integer: "". A 500 on every query against a tenant table.
Same missing tenant, one silent and one fatal, decided by whether that variable was ever touched on the connection. Write the policy so you choose which one you get, rather than finding out.
And the reason it has to be SET LOCAL rather than SET: a plain SET outlives the transaction. Measured on one connection, two consecutive transactions:
-- request A, plain SET, tenant 2
rows: 1
-- request B on the same connection, sets nothing
current_setting: '2'
rows it can see: 1
Request B never identified itself and is reading tenant 2. With persistent connections or a pooler in transaction mode, request B is a different customer.
What I would keep
RLS is the stronger mechanism. The comment that started this was right that it closes the holes an ORM-level filter leaves open, and I said so at the time. What I would not do is adopt it on the strength of ENABLE ROW LEVEL SECURITY and a policy that reviews well, because that pair is exactly the configuration I measured returning every row in the table.
Three things, if you are reaching for it this week. Check relforcerowsecurity, not relrowsecurity, because the first is the one that means anything when your app owns its tables. Decide between FORCE and a non-owning serving role on deployment cost, not on elegance, since both were airtight when measured. And test the negative case, because the positive one passes with the policy switched off.
I maintain ShipAnvil, a Symfony 7.4 LTS SaaS starter. Its tenancy layer is the Doctrine filter from the previous article, not this, and the paragraph about deployment cost is why. The article stands on its own. If your production database has RLS enabled today, the pg_class query above takes ten seconds and I would run it before finishing this page.
Written by Eric Mollenthiel, freelance Symfony developer in Lyon, France.
More at mollenthiel.fr.
Top comments (11)
Really enjoyed this one. The part that stood out to me is that ENABLE ROW LEVEL SECURITY can give you a completely misleading security signal when the application role also owns the tables.
The testing point is probably the most important takeaway for me. A single-tenant fixture can pass whether RLS is actually enforcing the policy or completely bypassed. The negative cross-tenant assertion is what turns the security claim into something falsifiable.
I also like that you don't stop at the owner/FORCE RLS issue. The SET vs SET LOCAL distinction and connection reuse introduce a second, very different class of failure: the policy may be enforced correctly while the tenant context itself is stale.
That's a great example of why I prefer testing the security invariant at the actual enforcement boundary, rather than testing that the configuration merely exists.
Thanks for sharing the measurements and the concrete queries. This is exactly the kind of database security footgun that's easy to review as “configured correctly” and still ship broken. 🔐
Testing the invariant at the enforcement boundary rather than testing that the configuration exists is the sentence I would keep, and it has a sharper edge than it looks.
A configuration assertion is at least honest about being one. The trap I walked into this week is a test that does sit on the boundary and still goes hollow, because the fixture stops being a violation.
Different codebase, nothing to do with RLS. A guard test dropped a deliberately malformed content file and asserted the rest of the site still rendered. It had been watched failing by hand first, so it was a real test. Then a fix landed on the parser that produced that malformation, and the fix made the fixture valid. The test kept passing, on nothing at all: green, boundary-level, and worthless, without a line of it changing.
What repairs it is the same shape as the double run: assert both sides in the same test. Assert that the malformed file breaks the pages it is supposed to break, and then that it leaves the others alone. A test that only asserts the positive side cannot notice that its own fixture went stale.
The same failure is available here.
owner run and serving-role run return the same setis the assertion that survivesFORCElanding, but it only means something while a second tenant's rows are present and never returned. The day someone trims that fixture, the assertion stays green and stops saying anything about the policy. So the second tenant is not fixture furniture, it is half of the claim, and the suite should say so out loud rather than rely on it quietly.Exactly. The fixture is part of the security claim, not just test data.
If removing the violating state doesn't make the test fail, then the test was never actually proving the invariant.
I particularly like the “assert both sides” formulation because it makes the test self-defending: prove the allowed path, prove the forbidden path, and make the fixture contain the evidence required for both. Otherwise a perfectly green suite can slowly become a test of nothing.
Removing the violating state was the half I had not measured, so I ran it, and it caught my own advice out.
Five worlds, tenant 1 queried, PostgreSQL 18.3, three assertions side by side. Equality is the one I gave earlier in this thread: the owner run and the serving run return the same set. Scoped is the ordinary one: the serving role returns only its own rows. Disjoint is yours, made executable: set the context to the second tenant, assert that set is non-empty and does not intersect the first.
Equality is green in four rows and three of them are broken. Two runs that both return everything are still equal, so it never noticed the world where there is no policy at all. It is red in exactly one row, the one the article was about.
Scoped does not rescue it. Both single-tenant rows are green on both assertions, and one of those rows has the owner bypass sitting in it, waiting for a second customer to sign up.
Only the disjointness line notices that the fixture stopped being a violation. It is red in the three broken rows equality sleeps through, green in the sound one, and between them the pair covers all four broken worlds.
The part I did not expect was how to write it without privilege. Under FORCE the owner is subject to the policy too, so there is no unprivileged connection left that can read the whole table, and reaching for a superuser to check the fixture puts the check back outside the enforcement boundary you named. Querying as the second tenant is what stays inside it: same role, same policy, nothing the application could not do itself.
So the double run needs both halves stated. Equality proves nobody is above the policy. Disjointness proves there is a policy, and that the fixture still contains something for it to refuse. I only had the first one written down.
This is an excellent result. The 1-tenant cases are exactly the edge case I had in mind: the negative control can disappear simply because the fixture no longer contains another tenant to separate from.
I especially like that you kept the check inside the enforcement boundary by querying as the second tenant rather than using a privileged connection. Otherwise we'd be proving the policy from outside the policy.
And the equality/disjointness split is much clearer now: equality tells us whether two views coincide; disjointness tells us whether the boundary actually separates what it is supposed to separate.
That's a much stronger two-part check than either assertion alone. 🔐
Staying inside the boundary was the part I nearly got wrong, so I am glad it is the part you picked out. The whole exchange became the follow-up article, and you are in it by name.
Then I went looking for the next place the pair goes hollow, and there is one: all three assertions only read. Same fixture, same
FORCE, three shapes of policy, tenant 1 writing towards tenant 2.The bottom row is a database where tenant 1 can file an invoice into tenant 2's books, and all three of our assertions are green on it.
WITH CHECK (true)is not a strawman. Seed a table that already hasFORCEon, with the wrong context, and Postgres saysnew row violates row-level security policy for table "invoice". That is a loud error whose obvious repair is to relax the check, and it is the same shape as theNULLIFtrap on the read side: the tempting fix turns a loud failure into a silent one.What I did not expect is the two refusals sitting next to it on that row.
UPDATE ... WHERE id = 1is blocked, and not byWITH CHECK, which istrue. It is the SELECT policy'sUSINGexpression being applied to the new row, and only because the statement reads a column. Footnote [a] of "Policies Applied by Command Type" inCREATE POLICY: "If read access is required to either the existing or new row (for example, a WHERE or RETURNING clause that refers to columns from the relation)." Same reasonINSERT ... RETURNING idis refused while the identicalINSERTwithout it goes through.So on that database the write side is guarded by the read policy, incidentally, and only while the statement reads something. Take the read away and it is gone:
No
WHERE, nothing read, nothing checked.USINGstill filters on the way in, so tenant 1 only touches its own rows, and then hands all of them to tenant 2. The tenant that lost its data cannot see it, and the tenant that received it never asked for it.Same lesson as the fixture one, one level up. Equality and disjointness say the boundary separates what is already in the table. Neither of them says anything about what a tenant can put there. The fourth assertion is one line and it belongs in the same test: as tenant 1, attempt a write labelled tenant 2, and assert that it raises.
PostgreSQL 18.3, one permissive
ALLpolicy, nothing else in the way.Eric, this is an excellent next failure boundary. 🔍
What I really like here is that the original assertions are not wrong. Equality, scoped visibility, and disjointness still prove something useful about the read side. The mistake would be treating that evidence as if it also proved write isolation.
Your WITH CHECK (true) case makes that distinction painfully clear: the database can preserve perfectly separated views while still allowing one tenant to create state inside another tenant's boundary.
The RETURNING and WHERE behavior is especially interesting because it creates accidental protection. A write appears guarded, but only because the statement also causes the read policy to participate. Remove the read dependency and the protection disappears.
That feels like another version of the same verification problem we keep finding: a control can appear to enforce a property because a neighboring mechanism happens to block the tested path. Then one small change in execution shape removes that incidental protection. 🔁
So I agree that the write assertion belongs beside the read assertions:
as tenant A, attempt to create or move state labelled as tenant B, and require the database itself to refuse it.
At that point the test is no longer just asking whether tenants see separate worlds. It is also asking whether they are prevented from writing into each other's worlds.
And the fact that this became the follow-up article is fantastic. Thanks for including me by name. 😄🔐
This is exactly the kind of security control that needs a negative test, not just a catalog assertion. I’d run the same cross-tenant query suite twice: once as the serving role and once as the owner/migration role, with the owner run expected to fail the build unless FORCE RLS is deliberate and verified. For pooled connections, add a request-boundary canary too: begin transaction, SET LOCAL tenant, query, rollback, then prove the next checkout has no tenant context. That catches both policy bypass and context leakage—the two failure modes that otherwise hide behind a green test suite.
The double run is the right shape; the assertion inside it is the part I would word carefully. I re-measured on 18.3 while writing this: once
ALTER TABLE ... FORCE ROW LEVEL SECURITYis in place, the owner run returns exactly what the serving role returns, same rows, no error. So "the owner run must fail the build" turns red the day someone applies the correct fix. The invariant that survives both worlds is owner run and serving-role run return the same set: it fails loudly today, and it keeps passing after FORCE lands.The other half of the negative test is the fixtures. The failure direction is extra rows, never zero: with a single organisation in the fixture, both runs return that one row whether the policy applies or is completely inert. A second tenant's data has to be present and never returned.
On the canary, one detail is in our favour.
SET LOCALis reverted at the transaction boundary whichever way it ends, and what comes back is the empty string, not NULL. So the next query does not quietly return zero rows, it throws22P02 invalid input syntax for type integer: "". The two "no context" states fail differently, and only the never-set one is silent.The leak I would actually hunt with that canary is the missing
LOCAL. A plainSETthat commits survives the whole session: the next transaction on the same connection still reads the previous tenant. A rollback undoes it, a commit does not. Under PHP-FPM the connection normally dies with the request, so it never shows; behind pgbouncer in transaction mode, or inside a long-running Messenger worker, the next unit of work inherits it.The migration-role bypass is exactly the kind of RLS footgun teams miss. Policies feel comprehensive until one privileged operational path silently sits outside the model everyone is reasoning about.
What surprised me while measuring it is how little privilege it takes: not a superuser, not
BYPASSRLS, just owning the table. And that is the roledoctrine:migrations:migrateruns as in a stock deployment, since the sameDATABASE_URLboth creates the schema and serves the requests.The other half of why it hides is the direction of the failure: extra rows, never zero. A fixture with a single organisation returns that one row whether the policy applies or is completely inert, so the suite stays green either way.