PostgreSQL RLS Has an Owner Bypass—FORCE Closes It

|Author: QUASA Editorial Team|5 min read| 1
PostgreSQL RLS Has an Owner Bypass—FORCE Closes It

PostgreSQL table owners normally bypass row-level security (RLS), even when it is enabled. PostgreSQL’s row-security documentation explains that FORCE ROW LEVEL SECURITY subjects the owner to the table’s policies, while superusers and roles with BYPASSRLS still bypass them.

For a multi-tenant application, identify the database role that executes each request. A policy can isolate tenant roles correctly while an application connection using the table-owner role still sees every tenant’s rows. FORCE closes that exception for ordinary owner-run queries, provided the table has a policy for the access the owner should retain.

Give the owner and tenants distinct roles

Consider an illustrative invoices table with id, tenant_id and amount columns. Provision three non-superuser login roles named demo_owner, tenant_alpha and tenant_beta. They should have no BYPASSRLS attribute, and demo_owner should not inherit either tenant role’s privileges. In the chosen schema, give demo_owner CREATE privilege and give all three roles USAGE so they can address the table.

Connect as demo_owner and create the table: CREATE TABLE invoices (id integer PRIMARY KEY, tenant_id text NOT NULL, amount integer NOT NULL); Seed it before enabling RLS: INSERT INTO invoices VALUES (1, 'tenant_alpha', 100), (2, 'tenant_beta', 200); The names, IDs and amounts are example data.

Grant the tenant roles only the table operations needed for this walkthrough: GRANT SELECT, INSERT, UPDATE ON invoices TO tenant_alpha, tenant_beta; Neither tenant role owns the table. In this small model, each database role has the same name as its tenant_id value, so current_user can identify the permitted rows. If every tenant connects through one shared database role, current_user has the same value for every request; that design needs a separate, trustworthy tenant identity in the policy.

Enable RLS and check both sides of a write

The invoices policy admits an existing tenant row through USING but rejects a proposed cross-tenant row through WITH CHECK.

The CREATE POLICY reference distinguishes USING, which checks existing rows, from WITH CHECK, which checks rows proposed by INSERT or UPDATE. Creating a policy does not activate it: RLS must also be enabled on the table. As demo_owner, run these statements:

CREATE POLICY tenant_rows ON invoices FOR ALL TO tenant_alpha, tenant_beta USING (tenant_id = current_user) WITH CHECK (tenant_id = current_user);

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

The FOR ALL policy covers reads and the granted write operations. For tenant_alpha, USING makes the existing tenant_beta row unavailable to SELECT or UPDATE. WITH CHECK permits a new or updated row only when its proposed tenant_id is tenant_alpha. The GRANT remains necessary: a policy limits row access but does not supply the underlying table privilege.

When RLS is enabled and no applicable policy allows access, PostgreSQL uses default deny. Here, the policy names the tenant roles but not demo_owner. That distinction becomes visible once the owner is made subject to RLS.

Force the owner through the policy

Before FORCE, a SELECT executed as demo_owner can return both seeded invoice rows despite the enabled policy. The ALTER TABLE reference specifies that FORCE ROW LEVEL SECURITY applies the table’s policies to owner queries; without FORCE, owner queries bypass them by default. As demo_owner, run: ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

Afterward, the same owner SELECT returns no rows in this example because no policy applies to demo_owner. An owner UPDATE finds no eligible rows, while an attempted owner INSERT fails its policy check. The tenant roles continue to see and write rows within their own scope: FORCE changes how the owner is treated, not the tenant policy’s condition.

FORCE is useful when ordinary queries might run under the owner role, but it does not make that role safe to hand to untrusted application traffic. The owner can change policies or disable RLS on its table. Keep ownership separate from tenant request roles so a connection carrying tenant traffic cannot change the rules that protect the rows.

Test reads and writes under the actual roles

A tenant_alpha read returns its own invoices row, while a cross-tenant write is rejected.

Connect separately as tenant_alpha, tenant_beta and demo_owner, and run SELECT current_user in each session before checking invoice access. Perform the following write attempts separately; an expected policy error inside a transaction would otherwise leave later statements in an aborted transaction. These are expected outcomes for the stated example, not reported test results.

  1. As tenant_alpha, SELECT id, tenant_id FROM invoices ORDER BY id returns the seeded row with id 1 and omits id 2. As tenant_beta, the same query returns id 2 and omits id 1.
  2. As tenant_alpha, UPDATE invoices SET amount = 300 WHERE id = 2 affects no rows. The existing tenant_beta row fails USING, so that UPDATE cannot target it.
  3. As tenant_alpha, INSERT INTO invoices VALUES (3, 'tenant_alpha', 300) succeeds. An INSERT that proposes tenant_beta as tenant_id fails WITH CHECK.
  4. As tenant_alpha, UPDATE invoices SET tenant_id = 'tenant_beta' WHERE id = 1 fails WITH CHECK. USING permits access to the existing row, but the proposed replacement belongs to another tenant.
  5. As demo_owner after FORCE, SELECT id FROM invoices returns no rows. Run this owner check as well as the tenant checks: tenant-only tests cannot reveal the owner exception.

The different outcomes matter. A row hidden by USING usually produces no matching result or update target; a proposed row rejected by WITH CHECK produces an error. Checking only SELECT would miss whether an INSERT or UPDATE can store a cross-tenant value.

Choose where owner access belongs

Use FORCE when owner-run queries should obey row policies, such as when a background job or application connection currently uses the table owner. In this example, applying FORCE without an owner policy deliberately leaves demo_owner unable to read or write invoice rows through ordinary queries. If owner-run work needs scoped access, define that scope explicitly and check it under the role that executes the work.

A tenant request path can instead use non-owner roles with the narrow table privileges it needs. Review any shared connection role, role switching, security-definer function or view used by the application as part of that path: the role and context in which a query executes determine which access rules apply. Treat superuser and BYPASSRLS roles as separately controlled administrative identities, outside the tenant policy boundary.

Also read:

Share:

Subscribe to our newsletter

Get the latest Web3, AI, and crypto news delivered straight to your inbox.

0