Back to News & Insights
Web Development August 19, 2026 · 8 min read

Postgres RLS multi-tenancy: two leaks that survive correct policies

I wrote an earlier post about two traps that silently switch Postgres row level security...

Postgres RLS multi-tenancy: two leaks that survive correct policies

I wrote an earlier post about two traps that silently switch Postgres row level security off: connecting as a role that is exempt from policies, and a transaction-local GUC that reverts to an empty string on a pooled connection.

Both of those are failures of the policy layer. You fix them and the policies start doing their job.

This post is about the harder category: two ways data crosses the tenant boundary while every policy is working exactly as written. Both were raised by a reader named Rahul S in the comments on that post, both survive a correct two-role split, and neither goes through the read path — which is why you will not catch them by testing SELECT.

Assume you have done everything right. The app connects as a role that owns nothing and holds neither superuser nor BYPASSRLS. Every tenant table has ENABLE and FORCE ROW LEVEL SECURITY. Every policy has USING and WITH CHECK, both wrapped in NULLIF(currentsetting(...), '').

A SECURITY DEFINER function runs with the privileges of the function's owner, not the caller's. That is the entire point of the feature, and it is useful. It is also the role exemption from the first post, arriving through a door that does not look like a database connection at all.

Who owns your functions? Whoever ran the migration that created them. Which is your migration role. Which is usually a superuser.

Superusers are unconditionally exempt from row security, and FORCE does not contain them — FORCE only removes the owner's exemption. So inside that function body, RLS is simply off.

Called by your properly contained application role, against the seed data in the repo below — two documents belonging to Acme, one to Globex:

Same session. Same role. Same policies. The direct query is filtered and the function is not, because the function body executes as its owner.

Read the two lines above the result, because together they are the whole condition. documents has FORCE ROW LEVEL SECURITY enabled — and it does not help. FORCE removes the owner's exemption; this function's owner is a superuser, and nothing removes that one.

The corollary is worth having: if your tables are owned by a non-superuser role and you use FORCE, a SECURITY DEFINER function owned by that role stays contained. Whether this is a footnote or a breach comes down entirely to who owns the function.

It does not look like data access. The obvious version of this bug is a function that returns rows. The realistic version is an audit trigger, a updatedat maintenance function, a search helper, an RPC endpoint someone exposed through PostgREST. Nobody reviewing "add an audit trigger" is thinking about tenant isolation, and the function does not appear anywhere near your connection configuration.

It survives every test in the first post. Your role is contained. Your startup assertion passes. SELECT is filtered. The isolation suite is green. The exemption is inside a function that the suite never calls.

Ask the catalog rather than grepping migrations, because migrations lie about what is actually in the database:

For each row, one of three things has to be true: it does not touch tenant-scoped tables, it filters by organization itself, or it does not need to be SECURITY DEFINER at all. SECURITY INVOKER is the default and is almost always what you want.

If a function genuinely needs elevated privileges, give it an owner that is not a superuser — a role that owns only what the function needs, with FORCE on the tables so ownership alone does not exempt it.

Without it, a caller who can create objects in a schema earlier on the search path can shadow a table or operator the function references, and have your elevated function execute their definition. That is a separate escalation path that happens to live on the same feature.

This one is not a mistake in your setup. It is documented behaviour, and it is load-bearing for the database's correctness. From the Postgres documentation on row security:

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation