
A test plan for engineers using PostgreSQL row-level security for tenant isolation on RDS, Aurora, Azure, Cloud SQL, AlloyDB or Supabase, based on PostgreSQL 18 documentation, security advisories and pooler documentation reviewed October 10, 2026. It covers bypass roles, fail-closed policies, views, transaction-local context under PgBouncer and RDS Proxy, the advisory patch floor and a two-tenant test matrix with SQL and Python examples.
At a glance
Key findings
- Superusers,
BYPASSRLSroles and table owners withoutFORCE ROW LEVEL SECURITYskip every policy, so a suite that connects as the migration or administrator identity can pass while no policy applies. [1][8] - With
row_securityset to off, any query a policy would filter fails with an error, so an application role that can still read a protected table afterSET LOCAL row_security = offis not subject to that table's policies. [7] - PgBouncer's transaction mode runs no reset query, and session
SETchanges to parameters PostgreSQL does not report, such as a custom tenant setting, leak to later clients on the same server connection; Azure's built-in PgBouncer and Cloud SQL Managed Connection Pooling default to transaction mode. [17][20][21] - CVE-2026-14666, fixed on August 13, 2026 in 18.6, 17.11, 16.15, 15.19 and 14.24 with a CVSS 3.0 score of 4.2, let reused plans keep applying row security policies after role membership, role attribute or database ownership changes. [16]
- Views apply the view owner's policies unless
security_invokeris true, an option added in PostgreSQL 15; PostgreSQL 14 reaches its final release on November 12, 2026. [3][9][10]
Why a superuser test proves nothing
Row-level security holds only for a query that PostgreSQL actually subjects to policies, and that depends on who runs the query rather than on what the policy says. Superusers and roles with the BYPASSRLS attribute skip every policy, and a table's owner skips them too unless the table is marked FORCE ROW LEVEL SECURITY. [1] A suite that connects as the migration user, the provider's administrator account or any role that owns the tables can pass while measuring nothing: queries return whatever their WHERE clause asks for, and a missing policy looks exactly like a working one.
A meaningful test fixes four conditions. It connects as the login role the application uses in production. It goes through the same pooler endpoint and pool mode, because transaction pooling decides how long a tenant setting lives. It sets tenant context the way the request path does, inside the transaction with set_config(..., true). And it runs on a minor release that carries the row security fixes, which on October 10, 2026 means at least 18.6, 17.11, 16.15, 15.19 or 14.24 for the major in use. [5][10][16]
The first condition can be checked with one statement. The row_security setting does not switch policies off: set to off, it makes any query that a policy would filter fail with an error, and it has no effect on superusers or BYPASSRLS roles. [7] It follows that if the application role runs SET LOCAL row_security = off and can still read a protected table, no policy is being applied to it: the role is a superuser, has BYPASSRLS or owns a table that is not forced, or row security is not enabled on that table. Fix that before running anything else. Every SQL example below is written for PostgreSQL 17 and 18.
Roles that skip every policy
The documentation names three exemptions. Superusers and BYPASSRLS roles bypass row security on every table; owners bypass it on their own tables unless row security is forced; and only the owner can enable row security or add policies at all. [1] NOBYPASSRLS is the default for a new role, and only a superuser or a role that already has BYPASSRLS can give the attribute to another. [8] Commands that act on the whole table, such as TRUNCATE, sit outside row security entirely, so a tenant-scoped role should not hold that privilege. [1]
Ownership is the exemption that catches teams. Migrations often run as the administrator account the provider created, so that account owns every table it creates, and an application that connects with the same credentials is exempt on precisely the tables the policies protect. The policies exist, pg_policies lists them, and none of them applies. Treat a member of the owning role the same way until a test shows otherwise. Keep three identities apart: a NOLOGIN role that owns the schema objects, the deployment identity that switches to it for migrations, and an application login role that is neither the owner nor a member of it.
Settings do not create exemptions. Turning row_security off produces errors rather than unfiltered rows, which is why pg_dump turns it off by default: a backup that fails is better than one that silently drops rows. [1][7] The catalog queries below list every role that skips policies, whether the application role can act as each table's owner and which views carry security_invoker. Run them in every environment, because ownership and role membership drift between staging and production.
-- 1. Roles that skip every policy, and whether the application role
-- is a member of any of them.
SELECT r.rolname, r.rolsuper, r.rolbypassrls,
pg_has_role('app_runtime', r.oid, 'MEMBER') AS app_runtime_is_member
FROM pg_roles AS r
WHERE r.rolsuper OR r.rolbypassrls;
-- 2. Tenant tables: owner, enabled and forced flags, and whether the
-- application role is the owner or a member of the owning role.
SELECT c.oid::regclass AS table_name,
pg_get_userbyid(c.relowner) AS owner,
c.relrowsecurity AS rls_enabled,
c.relforcerowsecurity AS rls_forced,
pg_has_role('app_runtime', c.relowner, 'MEMBER') AS app_runtime_owns
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
AND n.nspname = 'app';
-- 3. Views and their security_invoker option. NULL means the view
-- applies its owner's privileges and policies.
SELECT c.oid::regclass AS view_name,
pg_get_userbyid(c.relowner) AS owner,
(SELECT o.option_value
FROM pg_options_to_table(c.reloptions) AS o
WHERE o.option_name = 'security_invoker') AS security_invoker
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE c.relkind = 'v'
AND n.nspname = 'app';Does row security apply to this query
Policies apply only when row security is enabled and the role that counts (the view owner, for a view without security_invoker) is not a superuser, lacks BYPASSRLS and does not own an unforced table. [1][2][3]

Source. Conceptual decision aid based on the PostgreSQL 18 row security, CREATE POLICY and CREATE VIEW documentation. [1][2][3]
Method. Conceptual ordering of documented rules. Each question is answered for the role named by the row above it. Referential integrity checks and TRUNCATE are outside the tree because row security never applies to them.
Accessible table and figure data
| Question | Yes, then | No, then |
|---|---|---|
| Does the query read the table through a view without security_invoker? | answer the rest for the view owner | answer the rest for the current role |
| Is that role a superuser or does it have BYPASSRLS? | no policy applies, and row_security off changes nothing | ask whether it owns the table |
| Does that role own the table? | no policy applies unless row security is forced | ask whether row security is enabled |
| Is row security enabled on the table? | ask whether a permissive policy applies | every row the GRANTs allow is visible |
| Does a permissive policy apply to this role and command? | a row needs one permissive pass and every restrictive pass | default deny applies and no rows are visible |
| Question | Yes, then | No, then |
|---|---|---|
| Does the query read the table through a view without security_invoker? | answer the rest for the view owner | answer the rest for the current role |
| Is that role a superuser or does it have BYPASSRLS? | no policy applies, and row_security off changes nothing | ask whether it owns the table |
| Does that role own the table? | no policy applies unless row security is forced | ask whether row security is enabled |
| Is row security enabled on the table? | ask whether a permissive policy applies | every row the GRANTs allow is visible |
| Does a permissive policy apply to this role and command? | a row needs one permissive pass and every restrictive pass | default deny applies and no rows are visible |
Write policies that fail closed
A tenant policy should return no rows whenever context is missing or malformed, because that is the failure a pooled application will eventually produce. current_setting('app.tenant_id', true) returns NULL instead of raising an error when the setting does not exist, and a USING expression that evaluates to NULL hides the row just as false does. [2][5] The documentation does not say whether a custom setting reads as NULL or as an empty string after a transaction-local value expires, so the example wraps it in NULLIF(..., '') and treats both as missing. A value that is not a valid UUID fails the cast and errors the statement, which is also closed.
Reads and writes fail differently. Rows that fail USING disappear without an error; rows that fail WITH CHECK raise one and abort the command. For ALL and UPDATE policies, an omitted WITH CHECK means the USING expression is applied to new rows too, so a tenant still cannot insert a row for another tenant or move one across. [1][2] Writing it out keeps the write rule visible in review and in pg_policies.with_check. Two command rules surprise test authors: a RETURNING clause requires each new row to pass the SELECT policies, and an INSERT ... ON CONFLICT DO UPDATE whose existing row fails the UPDATE policy raises an error instead of skipping it. Both are errors rather than leaks. [2]
Permissive policies combine with OR and restrictive ones with AND, and at least one permissive policy must pass before a restrictive one can narrow anything; with only restrictive policies, nothing is visible. [2] That arithmetic explains the second policy in the example. It changes nothing today. It matters on the day someone adds a permissive FOR SELECT policy for a reporting feature, which would otherwise be ORed with the tenant rule and widen every read for the role.
Forcing row security has a cost the owner will notice. Once a table is forced, the owner's own queries need a matching policy, so a backfill run as the owner meets default deny and updates zero rows without an error. [1] Choose which identity performs cross-tenant data changes, give it a deliberate and logged path, and record the decision, rather than removing FORCE to get a migration through.
-- Run by the deployment identity, which must be able to SET ROLE to
-- app_owner. Objects created after SET ROLE are owned by app_owner.
SET ROLE app_owner;
CREATE TABLE app.invoices (
tenant_id uuid NOT NULL,
invoice_id bigint GENERATED ALWAYS AS IDENTITY,
amount_cents bigint NOT NULL,
PRIMARY KEY (tenant_id, invoice_id)
);
ALTER TABLE app.invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE app.invoices FORCE ROW LEVEL SECURITY;
-- Grants the tenant's rows. Missing or empty context matches nothing.
CREATE POLICY tenant_access ON app.invoices
AS PERMISSIVE FOR ALL TO app_runtime
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
-- Redundant today. Keeps the tenant predicate mandatory if a permissive
-- policy for this role is added later, because permissive policies are ORed.
CREATE POLICY tenant_guard ON app.invoices
AS RESTRICTIVE FOR ALL TO app_runtime
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
GRANT USAGE ON SCHEMA app TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON app.invoices TO app_runtime;
RESET ROLE;Views, definer functions and leaky operators
A view reads its base tables with the view owner's privileges and, when those tables have row security, applies the policies that match the view owner rather than the caller. [3] If the view owner also owns a table that is not forced, the view applies no policy and returns every tenant's rows to anyone allowed to select from it. Setting security_invoker = true, for example with ALTER VIEW app.invoice_totals SET (security_invoker = true), makes the view use the caller's permissions and policies, as though the query named the base tables directly. [3] The option arrived in PostgreSQL 15, so on 14 a view needs an owner that does not bypass row security and is itself covered by the tenant policy; 14 reaches its final release on November 12, 2026. [9][10]
Functions follow a different rule. Policy expressions run with the privileges of the user executing the query, but a SECURITY DEFINER function runs as its owner and can read rows the caller cannot. [1] Supabase states the consequence for its platform: a definer function created by a role such as postgres carries that role's ability to bypass row security. [27] Treat each definer function that touches a tenant table as an interface with its own tenant check and its own test cases. Supabase also describes ordinary views as security definer; PostgreSQL says a view without security_invoker is not equivalent to a definer function, although its effect on policies is the one described above. The PostgreSQL wording is the precise one. [3][27]
Some leaks never return a row. The planner applies policy conditions before conditions from the query, except functions and operators marked LEAKPROOF, so a user-supplied function in a WHERE clause cannot see hidden rows. [2] The rule has a performance edge: on a table with row security, an index scan cannot be used when the operator in the condition maps to a function that is not leakproof. [4] Referential integrity checks bypass row security, so a duplicate-key error can confirm that a value exists; the documentation suggests surrogate keys and policies that stop users writing values they cannot see. [2] It follows that a composite key beginning with tenant_id, as in the example, keeps uniqueness checks within one tenant. Plans from EXPLAIN, run times and optimizer statistics remain covert channels, and CVE-2025-8713 showed statistics exposing sampled data that a policy hid in partitioned and inherited tables. [4][15]
Set tenant context inside the transaction
Most tenant policies read a custom setting such as app.tenant_id. PostgreSQL accepts a value for any two-part parameter name and keeps it as a placeholder, which makes the pattern convenient and also means any session that can run SQL can set it to anything. [6] The setting carries a decision the application has already made from authenticated input; it is not itself an authorization check. A SQL injection flaw in a tenant-scoped request is therefore a tenant boundary failure, not only an integrity bug.
Scope is the property that matters. set_config(name, value, true) applies the value only to the current transaction, while false keeps it for the rest of the session, as plain SET does. [5] On a dedicated connection a session value survives until something resets it. Under transaction pooling the next transaction on that server connection may belong to another client. Make set_config with true the first statement of each request's transaction, and pass the tenant ID as a bound parameter, which works because set_config is an ordinary function call in a SELECT.
Autocommit deserves a deliberate check. When a driver runs each statement in its own implicit transaction, a transaction-local setting ends with the statement that set it and the next query runs without context. With a fail-closed policy that returns zero rows, a visible bug rather than a leak, and the test suite includes that case on purpose. The same discipline applies in application memory, where a tenant identifier cached beside a reusable client outlives its request in the same way.
-- The driver sends each statement separately and binds $1 and $2 from
-- the authenticated request. Never build these strings by concatenation.
BEGIN;
-- Transaction-local: the value ends at COMMIT or ROLLBACK.
SELECT set_config('app.tenant_id', $1, true);
SELECT invoice_id, amount_cents
FROM app.invoices
WHERE invoice_id = $2;
COMMIT;
-- Wrong under any pooler that shares server connections: session scope
-- survives COMMIT and stays on the connection for the next client.
-- SELECT set_config('app.tenant_id', $1, false);
-- SET app.tenant_id = '...';What each pooler does with session state
PgBouncer in transaction mode returns the server connection to the pool when a transaction ends and skips server_reset_query, whose default is DISCARD ALL, because clients in that mode are expected not to rely on session state. [17] It tracks the parameters PostgreSQL reports to clients, plus any named in track_extra_parameters, and restores each client's values. A custom tenant setting is not a reported parameter, and the configuration reference says that SET changes to such parameters made after connecting leak to other clients sharing the server connection, even when they are listed for tracking, unless server_reset_query_always is on. [17] PgBouncer 1.26.0, released September 23, 2026, added search_path on PostgreSQL 18 and default_transaction_read_only to the tracked list, and its changelog records that until then a SET search_path in transaction mode affected later clients on the same connection. [18]
Managed poolers start in transaction mode. The built-in PgBouncer on Azure Database for PostgreSQL flexible server listens on port 6432, runs version 1.25.2 and sets pgbouncer.pool_mode to transaction. [20] Cloud SQL Managed Connection Pooling, which requires an Enterprise Plus instance, also defaults to transaction mode and lists SET and RESET among the features it does not support there. [21] Moving an application behind either one changes the lifetime of every session setting it makes, so the move needs the pooler test cases even when no code changed.
RDS Proxy behaves differently for PostgreSQL. It reuses a database connection at the end of each transaction, but when it detects SET or set_config it pins the client to that connection until the client disconnects, which keeps state with its owner at the cost of reuse. [19] AWS does not say whether a transaction-local set_config pins, so watch the DatabaseConnectionsCurrentlySessionPinned metric during the suite rather than assume. Two documented details matter more for isolation. Stored procedure and function calls do not cause pinning, and the proxy does not detect session state they change, so a helper function that calls set_config(..., false) would plausibly leave a value on a connection the proxy still shares. And the proxy's initialization query runs for every new database connection, which makes it the wrong place for anything tenant specific. [19]
| Pooler | When a server connection is reused | Session SET | Test to add |
|---|---|---|---|
| PgBouncer, transaction mode | After each transaction; no reset query | Untracked parameters leak to later clients | Pool size 1, two clients, second without context |
| Azure flexible server built-in PgBouncer | Transaction mode by default, version 1.25.2 | Same as PgBouncer 1.25.2 | Same case through port 6432 |
| Cloud SQL Managed Connection Pooling | Transaction mode by default | Not supported in transaction mode | Same case through the pooler |
| RDS Proxy for PostgreSQL | After each transaction unless pinned | SET and set_config pin the client | Record the pinned connection metric |
A transaction-local setting ends before the next client arrives
With set_config(..., true), the tenant setting ends at COMMIT, so the next client on the same server connection sees no context and no rows. [5][17]

Source. Conceptual sequence based on PostgreSQL set_config documentation and PgBouncer transaction pooling and parameter tracking documentation. [5][17]
Method. Conceptual hypothetical with one server connection, as in a pool of size one. RDS Proxy would instead pin a client that ran session SET, keeping the value off other clients. [19]
Accessible table and figure data
| From | To | Message |
|---|---|---|
| Client A | Transaction pooler | BEGIN; set_config with is_local true, tenant 1 |
| Transaction pooler | Server connection S1 | Run A's transaction on S1 |
| Server connection S1 | Client A | Tenant 1 rows only |
| Client A | Transaction pooler | COMMIT; the local setting ends |
| Transaction pooler | Transaction pooler | Return S1 to the pool, no reset query |
| Client B | Transaction pooler | BEGIN; query without tenant context |
| Transaction pooler | Server connection S1 | Run B's transaction on S1 |
| Server connection S1 | Client B | Zero rows: policy sees no tenant |
| Server connection S1 | Client B | Had A used session SET: tenant 1 rows |
| From | To | Message |
|---|---|---|
| Client A | Transaction pooler | BEGIN; set_config with is_local true, tenant 1 |
| Transaction pooler | Server connection S1 | Run A's transaction on S1 |
| Server connection S1 | Client A | Tenant 1 rows only |
| Client A | Transaction pooler | COMMIT; the local setting ends |
| Transaction pooler | Transaction pooler | Return S1 to the pool, no reset query |
| Client B | Transaction pooler | BEGIN; query without tenant context |
| Transaction pooler | Server connection S1 | Run B's transaction on S1 |
| Server connection S1 | Client B | Zero rows: policy sees no tenant |
| Server connection S1 | Client B | Had A used session SET: tenant 1 rows |
Advisories that changed row security behavior
Five PostgreSQL advisories since 2023 let data through that a policy should have hidden or rejected, and a sixth affects applications that switch roles. Three of them, CVE-2023-2455, CVE-2024-10976 and CVE-2026-14666, share one cause: a reused plan kept applying the policies of the role or privileges it was planned under. CVE-2023-2455 covered function inlining, and CVE-2024-10976 extended the fix to subqueries, WITH queries, security invoker views and SQL-language functions; both name security definer functions and plans reused across SET ROLE as the trigger. [11][13] CVE-2024-10978, fixed the same day, let a query that reads the current role or user ID behave as though SET ROLE had not run. [14] CVE-2023-39418 was narrower: in PostgreSQL 15 before 15.4, MERGE did not check new rows against UPDATE and SELECT policies. [12]
CVE-2026-14666, fixed on August 13, 2026 with a CVSS 3.0 score of 4.2, is the one to read with a pooler in mind. PostgreSQL did not fully track changes to role membership, role attributes and database ownership, so a reused plan could keep applying policies that the change should have replaced, until another event invalidated the cache or the connection ended. The advisory says this permits reads and modifications that were recently permitted but are now forbidden, and that an attacker must tailor the attack to an application's pattern of privilege removal and role-specific policies. [16] Pooled server connections are long lived by design: PgBouncer's server_lifetime defaults to 3,600 seconds, and while max_prepared_statements is non-zero (its default is 200), identical query text from different clients shares one prepared statement per server connection. [17] A reasonable reading is that on a vulnerable minor, a revocation enforced through role-specific policies can lag for as long as a pooled connection survives.
The fixed minors for that advisory are also the current minors on the community versioning page as of October 10, 2026: 18.6, 17.11, 16.15, 15.19 and 14.24. [10][16] Managed services set their own dates. AWS's release calendar lists all five on RDS for PostgreSQL from August 25, 2026, twelve days after the community release, with standard support for 14.24 ending February 28, 2027. [28] Read the version the instance actually runs with SHOW server_version and record it in every test report, rather than trusting the version a template says it should run.
Row security advisories and the current patch floor
The August 13, 2026 releases fixed CVE-2026-14666 and are the current minors for every supported major as of October 10, 2026. [10][16]

Source. PostgreSQL security advisory pages, PostgreSQL versioning policy and the RDS for PostgreSQL release calendar, reviewed October 10, 2026. [10][11][12][13][14][15][16][28]
Method. Dates, CVSS 3.0 scores and fixed versions copied from each advisory's Version Information and CVSS tables; Versions lists fixed minors for branches still supported on the review date. Ordinal layout; spacing does not represent elapsed time.
Accessible table and figure data
| Date | Event | Versions |
|---|---|---|
| May 11, 2023 | CVE-2023-2455, CVSS 4.2: inlined functions kept the planning role's policies | 15.3, 14.8 |
| August 10, 2023 | CVE-2023-39418, CVSS 3.1: MERGE skipped UPDATE and SELECT policy checks | 15.4 |
| November 14, 2024 | CVE-2024-10976 and CVE-2024-10978, CVSS 4.2: old role's policies below subqueries; SET ROLE reset | 17.1, 16.5, 15.9, 14.14 |
| August 14, 2025 | CVE-2025-8713, CVSS 3.1: statistics exposed sampled data that policies hid in partitions | 17.6, 16.10, 15.14, 14.19 |
| August 13, 2026 | CVE-2026-14666, CVSS 4.2: cached plans ignored role and ownership changes | 18.6, 17.11, 16.15, 15.19, 14.24 |
| August 25, 2026 | RDS for PostgreSQL lists the August 13 minors as available | 18.6, 17.11, 16.15, 15.19, 14.24 |
| November 12, 2026 | PostgreSQL 14 final release | 15 and later remain supported |
| Date | Event | Versions |
|---|---|---|
| May 11, 2023 | CVE-2023-2455, CVSS 4.2: inlined functions kept the planning role's policies | 15.3, 14.8 |
| August 10, 2023 | CVE-2023-39418, CVSS 3.1: MERGE skipped UPDATE and SELECT policy checks | 15.4 |
| November 14, 2024 | CVE-2024-10976 and CVE-2024-10978, CVSS 4.2: old role's policies below subqueries; SET ROLE reset | 17.1, 16.5, 15.9, 14.14 |
| August 14, 2025 | CVE-2025-8713, CVSS 3.1: statistics exposed sampled data that policies hid in partitions | 17.6, 16.10, 15.14, 14.19 |
| August 13, 2026 | CVE-2026-14666, CVSS 4.2: cached plans ignored role and ownership changes | 18.6, 17.11, 16.15, 15.19, 14.24 |
| August 25, 2026 | RDS for PostgreSQL lists the August 13 minors as available | 18.6, 17.11, 16.15, 15.19, 14.24 |
| November 12, 2026 | PostgreSQL 14 final release | 15 and later remain supported |
Managed service administrator roles
The RDS, Azure and Cloud SQL pages say customers do not get a PostgreSQL superuser, AlloyDB describes alloydbsuperuser as granting superuser privileges, and none of the pages reviewed says whether the administrator role carries BYPASSRLS. [22][24][25][26] What they do document is an administrator with CREATEROLE and CREATEDB. On Aurora PostgreSQL, the first database user is a member of rds_superuser whatever name it is given. [23] Whichever role runs the migrations owns what it creates, which brings back the owner exemption, so run the catalog queries rather than reasoning from role names.
Two defaults deserve attention. Cloud SQL says that users created through Cloud SQL, rather than through IAM, are created as part of cloudsqlsuperuser, while a user created with psql can be given a different role. [25] The AlloyDB console assigns alloydbsuperuser to a new built-in user unless it is removed. [26] An application role created through either path begins as a member of the provider's administrator role. Create the application role in SQL with LOGIN and only the grants it needs, then confirm it is not a member of any role that owns tenant tables or skips policies.
Supabase is explicit about its own roles. Its service_role bypasses row security and must stay on the server; views created by postgres hand out rows their tables' policies would withhold unless security_invoker = true is set on PostgreSQL 15 or later; and its tooling runs policy tests with supabase test db and pgTAP. [27] Tests that run inside one database session are good at checking policy logic. They cannot show what a pooler does with settings across clients, so they complement the integration suite rather than replace it.
| Platform | Documented administrator | What to verify |
|---|---|---|
| Amazon RDS and Aurora PostgreSQL | Default postgres user in rds_superuser; RDS lists NOSUPERUSER, CREATEROLE, CREATEDB | Table ownership; rolbypassrls on rds_superuser |
| Azure Database for PostgreSQL flexible server | Server admin in azure_pg_admin; azuresu kept by Microsoft | Which role created and owns tenant tables |
| Cloud SQL for PostgreSQL | postgres in cloudsqlsuperuser; CREATEROLE, CREATEDB, LOGIN | Users created through Cloud SQL join cloudsqlsuperuser |
| AlloyDB for PostgreSQL | alloydbsuperuser grants superuser privileges | Console adds alloydbsuperuser to new users by default |
| Supabase | service_role bypasses row security by design | Keep service_role server side; test as the client role |
A two-tenant suite that runs as the application
Start with a fixture that holds at least one row for each of two tenants, and a control that confirms both rows exist. Without that control, a tenant query that returns zero rows proves nothing about isolation. With FORCE set and policies written only for the application role, the owner and any administrator without BYPASSRLS meet default deny and can neither load nor count the fixture, so do both as the application role with each tenant's context in turn, or as a role that bypasses row security where the platform allows one. [1][2] Every other case connects as the application's login role through the production pooler endpoint, opens its own transaction and sets context the way requests do. Drive the cases through the application's own data-access layer where you can; a helper that reimplements set_config mainly tests the helper.
Three cases need setup. For the shared-connection case, give the test pooler a pool size of one for the database, so two clients must use the same server connection and a leaked setting has nowhere to hide. For the bypass canary, run SET LOCAL row_security = off before a read as the application role and expect an error. [7] The revocation case applies only when policies name specific roles, and it needs the plan reuse the advisory describes: grant the application a role that a policy targets, run a query as a prepared statement on a pooled connection, revoke the membership and execute the same prepared statement again on that connection. On 18.6, 17.11, 16.15, 15.19, 14.24 or later the repeat should no longer return rows that only the revoked role's policy allowed; on earlier minors the advisory says the stale policy can persist until another event invalidates the cache or the connection ends. [16]
Run the suite in CI against every major version you operate, and again after any pooler, driver or provider upgrade, since each can change how sessions are shared. Store each result with the server minor version, pooler version and pool mode it ran against. The same two-principal structure carries over to state outside the database.
# Example pytest fragment for psycopg 3. Every test connects as the
# application login role through the production pooler endpoint. Values come
# from the environment; the fixture holds one invoice for each of two tenants.
import os
import psycopg
import pytest
APP_DSN = os.environ["APP_DSN"] # app_runtime through the pooler port
TENANT_A = os.environ["TENANT_A_ID"]
TENANT_B = os.environ["TENANT_B_ID"]
B_INVOICE = int(os.environ["TENANT_B_INVOICE_ID"])
def connect():
return psycopg.connect(APP_DSN, autocommit=True)
def count_rows(conn, context, tenant):
# Replace with the application's own data-access call where possible.
cur = conn.cursor()
with conn.transaction():
if context is not None:
cur.execute("SELECT set_config('app.tenant_id', %s, true)", (context,))
cur.execute(
"SELECT count(*) FROM app.invoices WHERE tenant_id = %s::uuid", (tenant,)
)
return cur.fetchone()[0]
def test_bypass_canary():
# Expect the row security error (SQLSTATE 42501), not any error: a missing
# table or a pooler that rejects SET would otherwise pass this test.
with connect() as conn:
cur = conn.cursor()
with pytest.raises(
psycopg.errors.InsufficientPrivilege, match="row-level security"
):
with conn.transaction():
cur.execute("SET LOCAL row_security = off")
cur.execute("SELECT count(*) FROM app.invoices")
def test_other_tenant_is_invisible():
with connect() as conn:
assert count_rows(conn, TENANT_A, TENANT_A) > 0
assert count_rows(conn, TENANT_A, TENANT_B) == 0
def test_context_ends_with_transaction():
with connect() as conn:
assert count_rows(conn, TENANT_A, TENANT_A) > 0
assert count_rows(conn, None, TENANT_A) == 0
def test_next_client_on_shared_server_connection():
# Run with the test pooler limited to one server connection.
with connect() as first, connect() as second:
assert count_rows(first, TENANT_A, TENANT_A) > 0
assert count_rows(second, None, TENANT_A) == 0
def test_other_tenant_update_changes_nothing():
with connect() as conn:
cur = conn.cursor()
with conn.transaction():
cur.execute("SELECT set_config('app.tenant_id', %s, true)", (TENANT_A,))
cur.execute(
"UPDATE app.invoices SET amount_cents = 0 WHERE invoice_id = %s",
(B_INVOICE,),
)
assert cur.rowcount == 0
Twelve cases, all but one run as the application
Every case except the fixture control runs as the application login role through the production pooler; the control may need a role that bypasses row security. [1][2][7][16]

Source. Conceptual test plan based on the PostgreSQL row security, CREATE POLICY and row_security documentation and the CVE-2026-14666 advisory. [1][2][7][16]
Method. Conceptual cases for the example schema with two tenants. Expected results follow the documented policy semantics; they were not executed for this guide.
Accessible table and figure data
| Case | Run as | Expect |
|---|---|---|
| Fixture control | Bypassing role, or app role per tenant | One row for each tenant exists |
| Bypass canary | App role, SET LOCAL row_security = off | Read fails with an error |
| Own rows | App role through the pooler as tenant A | Only tenant A rows |
| Other tenant by key | App role as tenant A, using B's primary key | Zero rows, no error |
| Cross-tenant write | App role as tenant A, insert or move a row to B | Error: policy violation |
| Other tenant's row | App role as tenant A, UPDATE or DELETE on B's key | Zero rows affected |
| Missing context | App role, no set_config | Zero rows; inserts fail |
| Context from earlier transaction | App role: set_config, COMMIT, then query | Zero rows |
| Shared server connection | App role, pool size 1: client A, then client B without context | B sees zero rows |
| View path | App role as tenant A, through each view | Only tenant A rows |
| Upsert and RETURNING | App role as tenant A, ON CONFLICT on B's key | Error, never B's data |
| Revocation on a live connection | App role, same prepared query after a revoke | Denied on fixed minors |
| Case | Run as | Expect |
|---|---|---|
| Fixture control | Bypassing role, or app role per tenant | One row for each tenant exists |
| Bypass canary | App role, SET LOCAL row_security = off | Read fails with an error |
| Own rows | App role through the pooler as tenant A | Only tenant A rows |
| Other tenant by key | App role as tenant A, using B's primary key | Zero rows, no error |
| Cross-tenant write | App role as tenant A, insert or move a row to B | Error: policy violation |
| Other tenant's row | App role as tenant A, UPDATE or DELETE on B's key | Zero rows affected |
| Missing context | App role, no set_config | Zero rows; inserts fail |
| Context from earlier transaction | App role: set_config, COMMIT, then query | Zero rows |
| Shared server connection | App role, pool size 1: client A, then client B without context | B sees zero rows |
| View path | App role as tenant A, through each view | Only tenant A rows |
| Upsert and RETURNING | App role as tenant A, ON CONFLICT on B's key | Error, never B's data |
| Revocation on a live connection | App role, same prepared query after a revoke | Denied on fixed minors |
Where row security stops, and what to check first
Row security filters rows for queries. It does not govern TRUNCATE, referential integrity checks, query plans, timing or statistics, and it trusts whoever can set the tenant parameter. [1][4][6] It also travels nowhere: rows copied into a search index, a cache or a retrieval store leave their policies behind. Authorization of the business action still belongs in the application before the query runs, with row security as the backstop that holds when that code has a bug.
For a new suite, work in this order. Prove with the row_security canary that the application role is subject to policies, because every later result depends on it. Separate table ownership from the login role and force row security on tenant tables. Move tenant context into each transaction with set_config(..., true) and test it through the real pooler with a pool size of one. Set security_invoker on views, or confirm their owners do not bypass. Confirm the server runs a minor at or above the CVE-2026-14666 fix. [7][16]
One rule covers most of it: if the role your application connects as can turn row_security off and still read rows, no policy you have written is being tested.
Method and provenance
Source-led technical analysis of PostgreSQL 18 documentation, PostgreSQL security advisories and versioning policy, PgBouncer documentation, and AWS, Microsoft, Google Cloud and Supabase documentation, with an original test plan, SQL examples and a Python test fragment. Sources were reviewed on October 10, 2026.
No database, pooler or managed service was configured or tested. The SQL and Python examples were checked against the cited PostgreSQL 17 and 18 documentation and the psycopg 3 documentation but were not executed. Provider statements about administrator roles are limited to what each page documents; whether those roles carry BYPASSRLS was not determined and must be queried in each environment.
AI assistance. AI assisted research synthesis, drafting, diagram planning and visual production, with deterministic editorial checks. No personal deployment experience, independent human review or live test is claimed.
Published under the Cloud Security Desk organizational byline. Read the practitioner guide policy.
References
- Row Security Policies (PostgreSQL 18 documentation, section 5.9) PostgreSQL Global Development Group. Accessed .
- CREATE POLICY (PostgreSQL 18 documentation) PostgreSQL Global Development Group. Accessed .
- CREATE VIEW (PostgreSQL 18 documentation) PostgreSQL Global Development Group. Accessed .
- Rules and Privileges (PostgreSQL 18 documentation, section 39.5) PostgreSQL Global Development Group. Accessed .
- System Administration Functions: configuration settings functions (PostgreSQL 18 documentation, section 9.28) PostgreSQL Global Development Group. Accessed .
- Customized Options (PostgreSQL 18 documentation, section 19.16) PostgreSQL Global Development Group. Accessed .
- Client Connection Defaults: row_security (PostgreSQL 18 documentation, section 19.11) PostgreSQL Global Development Group. Accessed .
- CREATE ROLE (PostgreSQL 18 documentation) PostgreSQL Global Development Group. Accessed .
- PostgreSQL 15 release notes PostgreSQL Global Development Group. Published . Accessed .
- Versioning Policy PostgreSQL Global Development Group. Accessed .
- CVE-2023-2455: Row security policies disregard user ID changes after inlining PostgreSQL Global Development Group. Published . Accessed .
- CVE-2023-39418: MERGE fails to enforce UPDATE or SELECT row security policies PostgreSQL Global Development Group. Published . Accessed .
- CVE-2024-10976: PostgreSQL row security below e.g. subqueries disregards user ID changes PostgreSQL Global Development Group. Published . Accessed .
- CVE-2024-10978: PostgreSQL SET ROLE, SET SESSION AUTHORIZATION reset to wrong user ID PostgreSQL Global Development Group. Published . Accessed .
- CVE-2025-8713: PostgreSQL optimizer statistics can expose sampled data within a view, partition, or child table PostgreSQL Global Development Group. Published . Accessed .
- CVE-2026-14666: PostgreSQL row security caching disregards role modifications PostgreSQL Global Development Group. Published . Accessed .
- PgBouncer configuration reference PgBouncer project. Accessed .
- PgBouncer changelog (1.26.0) PgBouncer project. Published . Accessed .
- Avoiding pinning an RDS Proxy Amazon Web Services. Accessed .
- PgBouncer in Azure Database for PostgreSQL Microsoft. Accessed .
- Managed Connection Pooling overview (Cloud SQL for PostgreSQL) Google Cloud. Accessed .
- Understanding the rds_superuser role (Amazon RDS) Amazon Web Services. Accessed .
- Understanding PostgreSQL roles and permissions (Amazon Aurora) Amazon Web Services. Accessed .
- Manage database users in Azure Database for PostgreSQL Microsoft. Accessed .
- About PostgreSQL users and roles (Cloud SQL for PostgreSQL) Google Cloud. Accessed .
- Manage PostgreSQL users with built-in authentication (AlloyDB for PostgreSQL) Google Cloud. Accessed .
- Row Level Security (Supabase documentation) Supabase. Accessed .
- Release calendars for Amazon RDS for PostgreSQL Amazon Web Services. Accessed .