Now-Next

SaaS architecture

Who bypasses row-level security in PostgreSQL? Measured

BYPASSRLS, table owners, FORCE and superusers in PostgreSQL 17, measured: who sees every row, what role membership passes on, and what RLS never covers.

Chris van Eijk, Now-Next · · 7 min read

A row-level security policy applies to some roles and not to others, and PostgreSQL decides which without telling you. A query that should return one tenant’s rows and returns everybody’s is not a broken policy; it is a role the policy does not apply to. The documentation names them in two sentences: “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.”

What those sentences leave open is what happens one step away from such a role: a member of it, a function it owns, a session that was already open when the attribute changed. We set up eight roles on PostgreSQL 17.11 and measured each case. Membership of the table’s owner passes the bypass on; membership of a BYPASSRLS role does not, until the session runs SET ROLE. FORCE binds the owner only until the owner turns it off again, which it may. And one operation and two side channels are not checked against a policy at all.

Who bypasses row-level security in PostgreSQL?

A superuser, and a role with the BYPASSRLS attribute, on every statement that runs as that role, with or without FORCE ROW LEVEL SECURITY. And the role that owns the table, together with every role that inherits from the owner, as long as the table does not have FORCE. What counts is the role the statement runs as, not the one that logged in: through a view owned by the application role, our superuser and our BYPASSRLS role both counted 0. We ran select count(*) on a table of 30 invoices, 20 for tenant 1 and 10 for tenant 2, without setting a tenant; under the policy that returns 0, and 30 means the policy did not apply.

RoleTable without FORCETable with FORCE
superuser3030
role with BYPASSRLS3030
table owner300
member of the owner role300
member of the owner role, NOINHERIT, with its own SELECT00
member of the BYPASSRLS role00
the same member after SET ROLE to the BYPASSRLS role3030
application role with SELECT on the table00

All roles had the default INHERIT except where the row says otherwise. The bypass is not limited to reading: without a tenant set, the BYPASSRLS role inserted a row for tenant 2, updated tenant 1’s 20 rows and deleted tenant 2’s 11 — its 10 and the one just inserted — on the table with FORCE. The application role got new row violates row-level security policy for table "invoices" on the insert, and UPDATE 0 and DELETE 0.

Is BYPASSRLS inherited through role membership?

No. A role that was a member of the BYPASSRLS role, with INHERIT, counted 0 rows, and with row_security = off it got the error an ordinary role gets, query would be affected by row-level security policy for table "invoices". After SET ROLE to the BYPASSRLS role, the same session counted 30. Role attributes belong to the role that is current. Privileges and ownership are passed on by membership that inherits, which is the default: a NOINHERIT member of the owner counted 0 rows, and 30 after SET ROLE to the owner.

Ownership is passed on. A member of the role that owns the table counted all 30 rows of the table without FORCE, with nothing in pg_roles to show for it: rolsuper and rolbypassrls are both false for that role. If your migrations run as a group role and the application’s login is a member of that group, the application bypasses the policy on every table that group owns and that has no FORCE.

So there are two different questions to ask about a login, and they need two different queries:

-- the current role and every role it is a member of, if superuser or BYPASSRLS
select r.rolname, r.rolsuper, r.rolbypassrls
from pg_roles r
where pg_has_role(current_user, r.oid, 'MEMBER') and (r.rolsuper or r.rolbypassrls);

-- tables this role counts as the owner of, where the policy is not forced
select c.oid::regclass
from pg_class c
where c.relkind in ('r', 'p') and c.relrowsecurity and not c.relforcerowsecurity
  and pg_has_role(current_user, c.relowner, 'USAGE');

For the member of the BYPASSRLS role the first query returned that role; for the application role, nothing. The second returned invoices for the owner and for the member of the owner while the table had no FORCE, and nothing for the other roles. A NOINHERIT member of the owner is not in that second list and is one SET ROLE away from it; 'MEMBER' instead of 'USAGE' includes it.

Does FORCE ROW LEVEL SECURITY stop the owner?

It stops the owner from bypassing by accident, and that is all. On the table with FORCE, the owner counted 0 rows. Then it ran ALTER TABLE invoices NO FORCE ROW LEVEL SECURITY, which succeeded, and counted 30. A member of the owner role turned it off the same way and counted 30. The documentation puts all of it with the owner: “Enabling and disabling row security, as well as adding policies to a table, is always the privilege of the table owner only.” The application role and the BYPASSRLS role, neither of them the owner, both got must be owner of table invoices.

That makes FORCE a guard against a mistake, not against a role. It is still the setting to have: it turns “the application happens to connect as the owner” from a silent total bypass into ordinary isolation. What it cannot do is make the owning role safe to hand to anything that runs SQL you did not write.

What about functions?

A SECURITY DEFINER function runs as its owner, and the owner’s bypass comes with it. Called by the application role, with no tenant set:

FunctionResult
SECURITY DEFINER, owned by the BYPASSRLS role30
SECURITY DEFINER, owned by the table owner, table without FORCE30
SECURITY DEFINER, owned by the table owner, table with FORCE0
ordinary function, owned by the BYPASSRLS role0

The last row is the reassuring one: who owns a function only matters when it is SECURITY DEFINER. The first is the one to look for. A helper written by whoever runs the migrations, marked SECURITY DEFINER to get at a lookup table, reads every tenant if that role has BYPASSRLS. Views behave the same way with their owner; that is measured in PostgreSQL views and row-level security.

Who can give a role BYPASSRLS?

The attribute: a superuser, or a role that has both CREATEROLE and BYPASSRLS itself. A role with only CREATEROLE got permission denied to create role, with the detail “Only roles with the BYPASSRLS attribute may create roles with the BYPASSRLS attribute”, and for ALTER ROLE on a role it had created, “Only roles with the BYPASSRLS attribute may change the BYPASSRLS attribute”. A role with both created a BYPASSRLS role without complaint. None of the roles without the attribute could give it to itself, the one with CREATEROLE included.

The effect can be handed out without the attribute. A plain role that held ADMIN OPTION on the BYPASSRLS role, and nothing else, had no access to the table. It granted itself membership of that role, ran SET ROLE, and counted 30. Who has ADMIN OPTION on a BYPASSRLS role is the same question as who has BYPASSRLS.

The attribute takes effect at once, also in sessions that are already open. We kept a session of the application role open, counted 0, gave the role BYPASSRLS from another session, and the open session counted 30 on its next query, and 30 from a statement it prepared right then. After NOBYPASSRLS, the same session counted 0 again, and so did the prepared statement. A new statement sees the change at once. That holds for the attribute, not for membership: a session that had already run SET ROLE to the BYPASSRLS role still counted 30 after its membership was revoked, and only a new SET ROLE failed, with permission denied to set role. A pool whose connections switch role has to reset or reconnect them.

What does row-level security never check?

The documentation: “Operations that apply to the whole table, such as TRUNCATE and REFERENCES, are not subject to row security.” We measured the first, and two side channels that are not operations on the table at all.

  • TRUNCATE. A role with SELECT and TRUNCATE on the table, subject to the policy and with no tenant set, counted 0 rows and then truncated the table: all 30 rows of both tenants, in a transaction we rolled back. Do not grant TRUNCATE to a role the policy is meant to hold.
  • Row counts in the catalog. The application role, seeing 0 rows, read reltuples = 30 from pg_class and n_live_tup = 30 from pg_stat_user_tables. The size of the whole table is visible to every role that can connect. The column statistics follow the policy: pg_stats showed the application role and the owner under FORCE 0 rows for the table, and the BYPASSRLS role all three columns.
  • Foreign keys and unique constraints. They look past the policy, which tells a tenant whether a row exists. That is gap 4 in row-level security in PostgreSQL: where tenants leak.

COPY invoices TO is checked: with the tenant set to 2, the application role got tenant 2’s 10 rows and nothing else.

What we would build

RoleAttributesOwns the tablesUsed by
migration roleBYPASSRLS if data migrations touch rows; without it, under FORCE, an update that sets no tenant reports UPDATE 0yes, with FORCE on every table; no SECURITY DEFINER functions the application can callthe deploy, nothing else
application roleNOSUPERUSER NOBYPASSRLS, not a member of the migration role, no ADMIN OPTION on any role, no TRUNCATEnothe application’s pool
support or reporting roleBYPASSRLS, read-only grantsnopeople, by name, logged

The one line in that table that our measurements argue for most strongly is “not a member of the migration role”. The attributes can be read off pg_roles; membership cannot, and neither can ownership.

What we did not measure

Membership granted WITH INHERIT FALSE or WITH SET FALSE, as opposed to a NOINHERIT role. REFERENCES as a privilege. Cursors that were open when the attribute changed. Partitioned tables with different owners per partition. Managed services, where the administrator role you get is usually not a superuser and may or may not have BYPASSRLS. Versions before 17. How to debug the opposite symptom, no rows where there should be some, is in debugging row-level security in PostgreSQL; the rest of what we measured about row-level security is indexed in PostgreSQL row-level security for SaaS: what we measured.

Sources: the PostgreSQL 17 documentation on row security policies, read on 13 October 2026. All results are our own, on PostgreSQL 17.11 in a throwaway container, on 13 October 2026.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation