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.
Top comments (1)
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.