SaaS architecture
PostgreSQL views and row-level security, measured
Which views leak other tenants' rows under row-level security, measured in PostgreSQL 17: view owner, BYPASSRLS, security_invoker, materialized views.
A view is the easiest way around a row-level security policy that nobody meant to build. The policy sits on the table; a view reads that table with the rights of whoever owns the view, not of whoever queries it. If that owner may bypass the policy, every tenant sees all tenants’ rows through the view — while a direct query on the table still looks perfectly isolated.
We measured four kinds of view on the same table, with the application connected as tenant 2. A view owned by the table owner returned all three tenants’ rows, until the table had FORCE ROW LEVEL SECURITY. A view created by a migration role with BYPASSRLS returned all three tenants with and without FORCE, and even after the application lost its right to read the table. A view with security_invoker = true returned only tenant 2. And a materialized view returned whatever its owner’s policy let through at the last refresh: all tenants, nothing, or one other tenant’s totals.
The setup
PostgreSQL 17.10 in a throwaway container, measured on 4 October 2026.
- A table
invoices (tenant_id, id, amount_cents)with three tenants of 10 invoices each, owned byowner_role, with row-level security enabled and the usual policy:using (tenant_id = nullif(current_setting('app.tenant', true), '')::int). - Three roles:
owner_role(owns the table, as migrations often do),migrator(a role withBYPASSRLS, as a migration or admin role often has) andapp(the application; may read the table and the views). - Each test:
appsetsset_config('app.tenant', '2', true)in a transaction and reads the view. A direct query on the table returned 10 rows, all tenant 2, in every test in whichappwas allowed to read it.
Which views showed other tenants’ rows?
| View | Without FORCE | With FORCE ROW LEVEL SECURITY |
|---|---|---|
| plain view, owned by the table owner | tenants 1, 2, 3 (30 rows) | tenant 2 (10) |
plain view, owned by a BYPASSRLS role | tenants 1, 2, 3 | tenants 1, 2, 3 |
view with (security_invoker = true) | tenant 2 | tenant 2 |
| materialized view | whatever the last refresh saw — see below | unchanged until the next refresh |
The PostgreSQL documentation for CREATE VIEW says it in one sentence: by default “the row-level security policies of the view owner are applied”. A table owner normally bypasses its own policies, so a view owned by the table owner bypasses them too. FORCE ROW LEVEL SECURITY makes the owner subject to the policy, and the view with it — the policy then reads app.tenant from the querying session, which is why the second column showed tenant 2 and not nothing. A view owned by an ordinary role that owns nothing and has no BYPASSRLS returned only tenant 2, with and without FORCE. A role with BYPASSRLS — or a superuser — “always bypass[es] the row security system”, and FORCE does not change that.
Access to the table runs through the view owner too. When we revoked app’s right to read the table at all, both plain views and the materialized view kept working, and the BYPASSRLS view still returned all 30 rows.
Which view is safe?
security_invoker = true, available since PostgreSQL 15. Then, in the words of the documentation, “the policies and permissions of the invoking user are used instead, as if the base relations had been referenced directly from the query using the view.” It returned tenant 2 with and without FORCE, and it was the only view that failed with permission denied for table invoices once app could no longer read the table — exactly as a direct query does.
create view invoice_totals with (security_invoker = true) as
select tenant_id, count(*) from invoices group by tenant_id;
-- an existing view
alter view invoice_totals set (security_invoker = true);
The application role needs SELECT on the table itself as well as on the view.
What about materialized views?
A materialized view cannot have row-level security. ALTER TABLE … ENABLE ROW LEVEL SECURITY on one gave:
ERROR: ALTER action ENABLE ROW SECURITY cannot be performed on relation "mv_totals"
DETAIL: This operation is not supported for materialized views.
Its rows are whatever the policy let through at the moment of the last refresh — the policy as it applies to the materialized view’s owner, with the session settings of whoever ran the refresh. We created the materialized view as the table owner, and tenant 2 read it after creating it and after each refresh:
| Owner = table owner; created or refreshed | What tenant 2 saw |
|---|---|
created, table without FORCE | totals of tenants 1, 2 and 3 |
refreshed with FORCE, no tenant set (a scheduled job) | nothing — the refresh succeeded and left it empty |
refreshed with FORCE, session still set to tenant 3 | the total of tenant 3 |
refreshed by a BYPASSRLS role (granted MAINTAIN), no tenant set | nothing |
refreshed by that BYPASSRLS role, session set to tenant 1 | the total of tenant 1 |
None of these gave an error, and the last two show that refreshing as a role that may see everything does not help: the owner’s policy decides. The second is the quiet one: a nightly refresh without a tenant empties a report for everybody. The third is the dangerous one: a refresh from a connection that still carries a tenant fills the view with that tenant’s data and shows it to everyone else.
A materialized view over tenant data therefore needs two things: a tenant_id column, and something in front of it that filters on that column — a security_invoker view or a function that adds where tenant_id = … — with no direct SELECT on the materialized view for the application. And the materialized view has to be owned by a role that sees every tenant — one with BYPASSRLS, or the table owner on a table without FORCE; running REFRESH as such a role is not enough.
How do you find these views in your own database?
This query lists every view and materialized view that reads a table with row-level security and is not security_invoker: one row per view and table, with the owner, whether that owner bypasses the policy, whether it also owns the table, and whether the table has FORCE. On our test database it returned exactly the three views above that showed other tenants’ rows at some point, and not the security_invoker views (we also tried one declared with security_invoker = yes, which PostgreSQL stores as written).
select c.oid::regclass as view, c.relkind as kind,
pg_get_userbyid(c.relowner) as owner,
(r.rolsuper or r.rolbypassrls) as owner_bypasses_rls,
t.oid::regclass as rls_table,
(t.relowner = c.relowner) as owner_owns_table,
t.relforcerowsecurity as forced
from pg_class c
join pg_roles r on r.oid = c.relowner
join pg_rewrite rw on rw.ev_class = c.oid
join pg_depend d on d.classid = 'pg_rewrite'::regclass and d.objid = rw.oid
join pg_class t on t.oid = d.refobjid and t.relrowsecurity and t.oid <> c.oid
where c.relkind in ('v', 'm')
and not exists (select from pg_options_to_table(c.reloptions)
where option_name = 'security_invoker'
and lower(option_value) in ('true', 'on', '1', 'yes'))
group by 1, 2, 3, 4, 5, 6, 7
order by 1;
Every row is a view to check. It leaks if owner_bypasses_rls is true, or if owner_owns_table is true and forced is false. kind = 'm' is a materialized view: check who owns it and how it is refreshed. The owner-and-FORCE part of this is the first gap in row-level security in PostgreSQL: tenant isolation; a view is one more place where it shows.
What we did not measure
Views on views (the documentation says a security_invoker view underneath is checked as the current user), SECURITY DEFINER functions inside views — those are in RLS: a role per tenant or a session variable — updatable views and WITH CHECK OPTION, and the performance of a security_invoker view against a plain one. The test used three tenants and thirty rows; which rows come back follows from whose policy applies, not from the size of the table, and we did not measure timings.
This belongs to the decisions that are cheap early and expensive later; the rest of that list is in multi-tenant SaaS on PostgreSQL: six decisions.
Sources: the PostgreSQL 17 documentation on CREATE VIEW (security_invoker, policies of the view owner) and row security policies (BYPASSRLS, FORCE), read on 4 October 2026; security_invoker appears in the CREATE VIEW documentation from version 15, not in 14. All measurements are our own, on PostgreSQL 17.10, on 4 October 2026.