During a routine security audit of a SaaS application running on PostgreSQL, a DBA discovered something alarming: tenant data was leaking across account boundaries, despite row-level security policies being correctly defined on every table. The culprit was not a missing policy or a misconfigured role — it was a view, behaving exactly as PostgreSQL had always intended, but in a way that silently bypassed every RLS check in place. This class of vulnerability existed in every version of PostgreSQL prior to 15, was easy to introduce accidentally, and was genuinely difficult to detect without deliberate auditing. PostgreSQL 15 introduced the security_invoker view option to close this gap — and if you are building multi-tenant applications on PostgreSQL, understanding it is not optional.
Before PostgreSQL 15, views always executed with the privileges of the view owner, not the querying user. This caused them to bypass row-level security (RLS) policies, because RLS checks use the current user's identity. PostgreSQL 15 added security_invoker = true as a view option, which makes views execute in the context of the calling user, preserving RLS enforcement. Any multi-tenant application using views over RLS-protected tables should audit its views and add security_invoker = true.
How PostgreSQL Views Execute — Security Definer vs Security Invoker
To understand the problem, you need to understand how PostgreSQL resolves permissions when a view is queried.
In PostgreSQL, a view is effectively a stored query. When a user runs SELECT * FROM my_view, PostgreSQL expands that into the underlying query and executes it. The critical question is: whose identity does PostgreSQL use when checking permissions on the underlying tables?
The Default: Security Definer Behavior
Prior to PostgreSQL 15, views always executed with the privileges of the view's owner — the role that created the view. This is called "security definer" semantics, by analogy with security definer functions. The querying user needs SELECT on the view, but does not need any privileges on the underlying tables. More importantly, the querying user's identity is not used for any permission or policy check on the underlying data.
This behavior was intentional and has legitimate uses: it allows you to expose specific columns of a restricted table to users who should not have direct access. But it has a deeply problematic interaction with row-level security.
The New Default Option: Security Invoker
PostgreSQL 15 introduced the security_invoker view option. When set to true, the view executes in the security context of the calling user, not the view owner. This means RLS policies are evaluated against the actual user running the query, and that user must also have the necessary table-level privileges.
-- PostgreSQL 15+: create a view that respects RLS
CREATE VIEW my_secure_view
WITH (security_invoker = true)
AS
SELECT id, name, data
FROM sensitive_table;The naming mirrors the distinction between SECURITY DEFINER and SECURITY INVOKER functions, which have always supported this choice at the function level. Views simply did not have this option until PostgreSQL 15.
The RLS Bypass Problem
Row-level security works by attaching policies to tables that filter or restrict rows based on the current user's identity. A typical multi-tenant setup looks like this:
-- Step 1: Create the table
CREATE TABLE orders (
id bigint PRIMARY KEY,
tenant_id text NOT NULL,
customer text,
amount numeric
);
-- Step 2: Enable RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Step 3: Create a policy restricting access to the current tenant
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant'));Now when a tenant's application session sets app.current_tenant and queries orders, they only see their own rows. This works correctly for direct table access.
Introducing a View — and Breaking RLS
Now suppose a developer creates a view to simplify queries, perhaps to join orders with a summary table or to expose a subset of columns:
-- Created by the 'app_admin' role, which owns all tables
CREATE VIEW orders_summary AS
SELECT tenant_id, customer, amount
FROM orders;
-- Grant SELECT to the application role
GRANT SELECT ON orders_summary TO app_user;This looks reasonable. But watch what happens when a tenant queries through this view:
-- Application sets the tenant context
SET app.current_tenant = 'tenant_A';
-- Query through the view
SELECT * FROM orders_summary;This query returns rows for all tenants, not just tenant_A. Because the view executes with the privileges and identity of app_admin (the view owner), and app_admin is a superuser or the table owner, RLS is bypassed entirely. The app.current_tenant setting is present in the session, but PostgreSQL evaluates the RLS policy under the view owner's identity — not the querying user's. If the view owner bypasses RLS (as table owners do by default), the policy never fires.
This is not a bug in the application code. The RLS policy is correct. The view definition is correct. The grant is correct. The bypass is a consequence of how PostgreSQL's view security model worked for decades — and it is invisible unless you know to look for it.
Why Table Owners Bypass RLS by Default
PostgreSQL table owners bypass their own RLS policies by default. To force RLS to apply to the table owner, you must explicitly enable it:
-- Force RLS even for the table owner
ALTER TABLE orders FORCE ROW LEVEL SECURITY;Even with FORCE ROW LEVEL SECURITY, if the view owner is a superuser, RLS is still bypassed — superusers always bypass RLS. This means the only reliable fix before PostgreSQL 15 was careful role engineering to ensure view owners were neither superusers nor table owners, and that is exactly the kind of subtle constraint that gets lost across deployments and team changes.
PostgreSQL 15's security_invoker Option
security_invoker = true is not the new default — the default remains security_invoker = false (legacy security definer behavior). This means upgrading to PostgreSQL 15 does not automatically fix existing views. You must explicitly opt in by altering or recreating views with security_invoker = true. Additionally, when security_invoker is enabled, querying users must have direct SELECT privileges on the underlying tables — not just on the view. This may require schema changes.
With security_invoker = true, the view evaluates all permission checks and RLS policies using the identity of the calling user, not the view owner. The fix for the broken example above is straightforward:
-- Drop and recreate with security_invoker
DROP VIEW orders_summary;
CREATE VIEW orders_summary
WITH (security_invoker = true)
AS
SELECT tenant_id, customer, amount
FROM orders;
-- The querying user now needs SELECT on the underlying table
GRANT SELECT ON orders TO app_user;
GRANT SELECT ON orders_summary TO app_user;Now when app_user queries orders_summary, PostgreSQL evaluates the RLS policy using app_user's identity, and the tenant_id = current_setting('app.current_tenant') policy fires correctly.
Enabling security_invoker on Existing Views
For existing views, you do not need to drop and recreate — you can use ALTER VIEW:
-- Enable security_invoker on an existing view
ALTER VIEW orders_summary SET (security_invoker = true);
-- Verify the setting
SELECT viewname, options
FROM pg_views
JOIN pg_class ON pg_class.relname = pg_views.viewname
WHERE viewname = 'orders_summary';
-- Or check via pg_options_to_table
SELECT relname, unnest(reloptions) AS option
FROM pg_class
WHERE relname = 'orders_summary';To audit all views in your schema and check which ones have security_invoker set:
SELECT
schemaname,
viewname,
array_to_string(reloptions, ', ') AS options
FROM pg_views
JOIN pg_class ON pg_class.relname = pg_views.viewname
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
AND pg_namespace.nspname = pg_views.schemaname
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY schemaname, viewname;Any view without security_invoker=true in its options column that queries an RLS-protected table is a potential bypass.
Practical Example: Multi-Tenant App Before and After
Before PostgreSQL 15 (Vulnerable)
-- Schema setup (owned by 'admin' role)
CREATE TABLE documents (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
tenant_id text NOT NULL,
title text,
body text,
created_at timestamptz DEFAULT now()
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON documents
USING (tenant_id = current_setting('app.tenant_id', true));
-- View created by admin
CREATE VIEW public_documents AS
SELECT id, tenant_id, title, created_at
FROM documents;
GRANT SELECT ON public_documents TO web_user;
-- Later, web_user queries:
SET app.tenant_id = 'acme';
SELECT * FROM public_documents;
-- Returns documents for ALL tenants — RLS bypassedAfter PostgreSQL 15 (Fixed)
-- Same table and policy as above
-- Recreate the view with security_invoker
CREATE OR REPLACE VIEW public_documents
WITH (security_invoker = true)
AS
SELECT id, tenant_id, title, created_at
FROM documents;
-- web_user now needs direct table access for RLS to apply
GRANT SELECT ON documents TO web_user;
GRANT SELECT ON public_documents TO web_user;
-- Now web_user queries:
SET app.tenant_id = 'acme';
SELECT * FROM public_documents;
-- Returns ONLY documents where tenant_id = 'acme' — RLS enforcedTesting the Fix
-- Insert test data across two tenants
INSERT INTO documents (tenant_id, title, body)
VALUES
('acme', 'Acme Q1 Report', 'Confidential'),
('acme', 'Acme Roadmap', 'Internal'),
('globex', 'Globex Strategy', 'Confidential');
-- Simulate tenant A's session
SET ROLE web_user;
SET app.tenant_id = 'acme';
SELECT id, tenant_id, title FROM public_documents;
-- id | tenant_id | title
-- ---|-----------|-------
-- 1 | acme | Acme Q1 Report
-- 2 | acme | Acme Roadmap
-- (2 rows) — Globex data not visible. Correct.Migration Considerations and Compatibility
Privilege Requirements Change
The most significant operational impact of enabling security_invoker is that querying users now need SELECT on the underlying tables. If your current design intentionally hides table structure from end users — exposing only the view — enabling security_invoker requires you to grant direct table access, which may expose more than intended. In such cases, consider whether the view can be redesigned as a security definer function that explicitly enforces tenant isolation, rather than relying on RLS through a view.
Nested Views
If a security_invoker view references another view, the inner view still uses its own security context (definer or invoker, depending on how it is configured). For consistent RLS enforcement across view hierarchies, all views in the chain should be set to security_invoker = true.
PostgreSQL Version Compatibility
The security_invoker view option was introduced in PostgreSQL 15. If your application must support PostgreSQL 14 or earlier, the correct approach is to use security definer functions with explicit SET ROLE logic, combined with FORCE ROW LEVEL SECURITY on tables and careful role separation to ensure view owners are not superusers or table owners. Migrating to PostgreSQL 15 specifically for this feature is a reasonable argument to make to engineering leadership — the security surface reduction is significant.
-- Verify your PostgreSQL version before using security_invoker
SELECT version();
-- PostgreSQL 15.x or higher required
-- Check if a view has security_invoker set
SELECT relname, reloptions
FROM pg_class
WHERE relkind = 'v'
AND relname = 'public_documents';- Prior to PostgreSQL 15, all views executed with the view owner's privileges, bypassing RLS policies on underlying tables — this was default behavior, not a bug.
- PostgreSQL 15 introduced
security_invoker = trueas a view option, making views execute in the calling user's security context and preserving RLS enforcement. security_invokeris not the new default — existing views are unaffected by upgrading to PostgreSQL 15 and must be explicitly altered.- Enabling
security_invokerrequires that querying users have directSELECTprivileges on underlying tables, not just on the view. - All views in a view hierarchy must have
security_invoker = truefor consistent RLS enforcement across nested views. - Audit all views in production schemas that query RLS-protected tables — any view without
security_invoker = trueis a potential data isolation bypass. - For pre-PostgreSQL 15 deployments, use
FORCE ROW LEVEL SECURITYand ensure view owners are not superusers or table owners as a partial mitigation.
Working with JusDB on PostgreSQL Security
JusDB audits and secures PostgreSQL deployments for engineering teams — RLS policies, view security, role design, and audit logging. Our DBAs have found and fixed RLS bypasses in multi-tenant production applications before they became data breaches.
Explore JusDB PostgreSQL Management → | Talk to a DBA
Related reading:
