Your Supabase dashboard shows Row Level Security enabled on every table. Your policies passed code review. Then a support ticket lands: a customer can see line items belonging to a company that isn't theirs.
That gap, between "RLS is on" and "RLS is actually enforcing what you assumed it enforces," is where most real production RLS vulnerabilities and anti-patterns live. They rarely show up as a crash or an obviously missing policy. They're structural: a policy that filters reads but never validates writes, a view that quietly runs with owner permissions, a session variable that outlives the request that set it. Every one of them can sit in a schema for months, invisible in testing, until the wrong request lines up with the wrong edge case.
This post assumes your schema already looks something like the one in our complete guide to multi-tenant database design, and that RLS is already live on your tables. It isn't another "how to enable RLS" tutorial. It's an audit: five specific, well-documented PostgreSQL and Supabase RLS anti-patterns, why each one happens at the engine level, and the exact fix for each.
WITH CHECK clauses, pre-Postgres 15 views that bypass RLS through owner permissions, policies that trust the client-writable user_metadata claim, session variables that leak across pooled connections, and writes that fail with a 403 because no SELECT policy exists for the implicit RETURNING. Independent 2026 security scans of Supabase deployments trace roughly 83% of exposed data back to misconfigured or missing RLS, not application code.
Table of Contents
- How Postgres Actually Evaluates an RLS Policy
- Mistake #1: The Asymmetric Mutation Gap
- Mistake #2: The View Owner Inversion Trap
- Mistake #3: Why Trusting user_metadata Is Dangerous
- Mistake #4: The Connection Pool Identity Spill
- Mistake #5: The Mutation Deadlock on RETURNING *
- Running a Fast Self-Audit for These RLS Anti-Patterns
- Quick Answers About RLS Vulnerabilities
- Frequently Asked Questions
How Postgres Actually Evaluates an RLS Policy
Simply put, every query against an RLS-protected table passes through two separate checks before a row comes back: standard Postgres grants, then your policy expression. Miss either one and you get a very different failure mode, which is exactly why these five anti-patterns are so easy to miss in a demo and so expensive in production.
The grant check runs first. If the connecting role lacks the base SELECT, INSERT, UPDATE, or DELETE privilege, Postgres stops immediately and throws 42501 insufficient_privilege. Your RLS policy never even gets evaluated. Once grants pass, the planner rewrites the query, folding your policy's USING expression in as an implicit WHERE condition for reads, and your WITH CHECK expression in as a validation filter for writes. That second half is where mistake #1 lives.
Why Does auth.uid() Slow Down as a Table Grows?
When a policy calls auth.uid() directly inside USING, Postgres re-evaluates that function for every candidate row, which is a performance problem more than a security one. We cover the fix, wrapping the call in a scalar subquery so the planner caches it as an initPlan, in Why Your Supabase RLS Is Slow. This post is about a different failure mode: policies that run fine and still let the wrong data through.
Keep that grants-then-policy pipeline in mind. Every mistake below is a way the policy half technically runs, and technically "works," while quietly not enforcing what you assumed it enforced.
Mistake #1: The Asymmetric Mutation Gap (USING Without WITH CHECK)
The problem, directly: when an UPDATE policy defines a USING expression but skips WITH CHECK, Postgres only filters which existing rows a user can target. It applies zero validation to what they change those rows into.
Picture a typical tenant-scoped policy:
-- Looks correct. Isn't.
CREATE POLICY "tenants_update_own_rows"
ON public.invoices
FOR UPDATE
USING ( (SELECT auth.uid()) = owner_id );
-- No WITH CHECK clause here
This passes every normal QA pass. A user updates their own invoice, it works. A user tries to update someone else's invoice by ID, USING blocks it, RLS "did its job." Then a user runs an update that changes their own row's tenant_id or owner_id to someone else's, and Postgres allows it, because nothing ever checked what the row looked like after the write.
If you already added a tenant_id IN() policy for read performance, this is worth calling out explicitly: that's a read-side filter. It does nothing to stop this write-side gap, because the vulnerability lives in the mutation path, not the query path.
The fix is one clause:
CREATE POLICY "tenants_update_own_rows"
ON public.invoices
FOR UPDATE
USING ( (SELECT auth.uid()) = owner_id )
WITH CHECK ( (SELECT auth.uid()) = owner_id AND tenant_id = (SELECT current_tenant_id()) );
Every UPDATE policy you write should have both clauses unless you have a specific, documented reason to allow the row to change ownership. That's the exception, not the default.
Mistake #2: The View Owner Inversion Trap
Simply put, a standard Postgres view runs with the privileges of whoever created it, not whoever queries it. Build a view over an RLS-protected table without changing that default, and the view quietly bypasses every policy on the underlying tables.
This happens constantly because views are the natural tool for simplifying a messy multi-tenant join. A developer builds a reporting view joining invoices, customers, and line_items to avoid repeating the same three-table join everywhere in application code. The view owner, usually a superuser or schema owner, has full table access. Anyone granted SELECT on that view inherits the owner's visibility into all three tables, tenant boundaries included, regardless of what RLS policies exist underneath.
Postgres 15 added the fix as an opt-in view property, per the official Supabase RLS documentation:
CREATE VIEW public.tenant_invoice_summary
WITH (security_invoker = true) AS
SELECT i.id, i.amount, i.tenant_id, c.name AS customer_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id;
security_invoker = true forces the view to run as the querying role, so the underlying tables' RLS policies apply exactly as if the client had queried them directly. Versions before Postgres 15 have no equivalent flag; the workaround is a SECURITY DEFINER function with its own explicit tenant check inside, which is a big enough topic to get its own dedicated post rather than a paragraph here.
Audit every view sitting on top of an RLS table. If it predates this check, or came from a migration tool that doesn't set the flag by default, assume it's leaking until proven otherwise.
Mistake #3: Why Trusting user_metadata in an RLS Policy Is Dangerous
Direct answer: because user_metadata is writable by the client. Any RLS policy that reads role, tenant ID, or permission level from auth.jwt() -> 'user_metadata' can be defeated by a user calling the standard updateUser() SDK method on their own account.
Supabase Auth stores two separate JSONB fields on every user record. raw_user_meta_data is meant for profile-type data, like a display name or avatar, and any authenticated client can write to it directly. raw_app_meta_data is meant for authorization-relevant data, like role or tenant ID, and can only be written from a trusted server context using the service role key.
The two fields surface in the JWT under near-identical names, user_metadata and app_metadata, which is exactly why this mistake is so easy to make under deadline pressure. Grab the wrong one, and a policy meant to gate admin actions becomes self-service:
-- Vulnerable: reads from the client-writable claim
CREATE POLICY "admins_manage_settings"
ON public.tenant_settings
FOR ALL
USING ( (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' );
Any authenticated user can set their own user_metadata.role to "admin" from the client, and this policy grants full access. The fix is a one-word swap to the server-controlled claim:
CREATE POLICY "admins_manage_settings"
ON public.tenant_settings
FOR ALL
USING ( (auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );
Grep your policy definitions for user_metadata today. Every match tied to a role, tenant, or permission check is a live privilege escalation path, not a hypothetical one.
For the mechanics of building tenant and role claims correctly in the first place, through Supabase's custom access token hook, see our guide to writing RLS policies with custom JWT claims.
Mistake #4: The Connection Pool Identity Spill (SET vs. SET LOCAL)
Simply put, using SET instead of SET LOCAL to pass a tenant ID into a pooled Postgres connection can leave that tenant ID attached to the connection long after the request that set it has finished.
Multi-tenant apps often pass the current tenant into Postgres as a session variable, then reference it inside policies with current_setting(). That works fine on a direct, unpooled connection. It gets dangerous the moment a connection pooler like PgBouncer or Supavisor sits in front of the database in transaction mode, which is the default for most serverless and edge-function setups.
-- Dangerous under a transaction-mode pooler
SET app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT * FROM invoices; -- correctly scoped, for now
A plain SET changes the variable for the entire backend session. Under a transaction-mode pooler, that backend connection gets handed back to the pool the moment the current transaction commits, session variable still attached. The next tenant's request, if it lands on that same recycled connection, inherits the previous tenant's ID until something else overwrites it.
The fix is SET LOCAL inside an explicit transaction, which Postgres guarantees to discard at COMMIT or ROLLBACK no matter what the pooler does with the connection afterward:
BEGIN;
SET LOCAL app.tenant_id = '11111111-1111-1111-1111-111111111111';
SELECT * FROM invoices; -- scoped, and discarded when the transaction ends
COMMIT;
If your ORM or query builder already wraps every request in its own transaction, this is a small change. If it doesn't, this mistake is a strong argument for adding one.
For a connection-pool-safe transaction wrapper you can drop straight into a Node.js or TypeScript API layer, extended to cover user roles and permissions rather than just tenant_id, see our guide to hierarchical Postgres RBAC for B2B SaaS.
Mistake #5: The Mutation Deadlock on RETURNING *
Direct answer: PostgREST and most ORMs append an implicit RETURNING * to every insert or update so they can hand the new row back to the client. If a table has a working INSERT or UPDATE policy but no matching SELECT policy, the write succeeds and the read-back fails, which surfaces to the client as a flat 403 Forbidden.
This is one of the more confusing failure modes to debug, because the data actually gets written. Query the table directly with elevated access and the row is there. From the client's point of view, though, the request just failed, and the natural next step under deadline pressure is disabling RLS entirely or adding a USING (true) policy to make the error go away. Both of those "fixes" remove the protection the policy existed for in the first place.
This shows up constantly on Supabase Storage uploads specifically, which the official Supabase troubleshooting docs address directly: an INSERT policy on storage.objects without a companion SELECT policy throws exactly this error on an otherwise-successful upload.
CREATE POLICY "tenants_select_own_invoices"
ON public.invoices
FOR SELECT
USING ( (SELECT auth.uid()) = owner_id );
Any table with a write policy needs a matching, correctly scoped SELECT policy too, even if your application code never issues a standalone read against it. The RETURNING clause counts as a read.
These five cover mistakes inside your own schema and API layer, the kind you can find and fix today. There's a second, deeper category worth knowing about even though it's out of scope here: Postgres's query planner itself has shipped RLS-adjacent bugs, including CVE-2024-10976, where a cached query plan reused the wrong security context across a role change. That's engine-internals territory. We're covering it, along with SECURITY DEFINER risk more broadly, in a dedicated follow-up.
Running a Fast Self-Audit for These RLS Anti-Patterns
You don't need a security firm to catch most of this. A few queries against Postgres's own catalog tables surface the majority of these RLS anti-patterns in under ten minutes.
- Find tables with RLS off entirely. Run this against
pg_tablesand treat every result as a P0 until reviewed:SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = false; - Grep policy definitions for asymmetric mutations. In the Supabase dashboard's Policies view, or via
pg_policies, check everyUPDATEpolicy for a populatedwith_checkcolumn. ANULLthere is mistake #1. - List views sitting over RLS-protected tables. Check
information_schema.viewsagainst your table list, then confirm each one either has no meaningful cross-tenant join or explicitly setssecurity_invoker = true. - Grep for user_metadata in policy definitions. Any authorization-relevant match is mistake #3, today, not on the backlog.
- Confirm every write policy has a matching SELECT policy. If your ORM or PostgREST config relies on
RETURNING, a missing read policy is a 403 waiting to happen. - Force RLS for table owners too, since owner and superuser roles bypass RLS by default:
ALTER TABLE public.tenant_records FORCE ROW LEVEL SECURITY;
None of these six checks require downtime or a maintenance window. Run them against a staging replica first if you want a paper trail before touching production policies.
Quick Answers About RLS Vulnerabilities
What Causes Silent RLS Vulnerabilities in Supabase?
Simply put, RLS looks like a single security layer but is really several independent checks: grants, the USING clause, the WITH CHECK clause, view ownership, and session state. A gap in any one of them lets data through while the others still look correctly configured. Most of these gaps never throw an error; they just quietly return or accept data they shouldn't, which is what makes them "silent" rather than something you'd catch in staging.
RLS Anti-Patterns at a Glance
| # | Mistake | Silent Symptom | Fix |
|---|---|---|---|
| 1 | Missing WITH CHECK | Read filtering looks fine; a write can still reassign tenant_id or owner_id | Add WITH CHECK matching USING |
| 2 | View owner inversion | View leaks cross-tenant rows even though table RLS is configured correctly | WITH (security_invoker = true) on PG15+ |
| 3 | Trusting user_metadata | A client-set role or tenant value passes an authorization check | Read only from app_metadata |
| 4 | SET instead of SET LOCAL | A later, unrelated request inherits a stale tenant ID from the pool | SET LOCAL inside BEGIN...COMMIT |
| 5 | No SELECT policy on a write-only table | Insert or update succeeds; client still gets a 403 on RETURNING | Add a matching SELECT policy |
When Does This Apply?
This applies to any multi-tenant B2B SaaS app running Postgres RLS in production, especially teams on Supabase using a transaction-mode pooler or custom JWT claims. Solo-tenant prototypes carry less risk on #2 and #4, but #1, #3, and #5 apply as soon as more than one user role exists.
Frequently Asked Questions
What is an RLS anti-pattern?
An RLS anti-pattern is a policy configuration that's technically present and passes basic testing but doesn't actually enforce the access control it appears to. Missing WITH CHECK clauses, views that bypass RLS through owner permissions, and policies keyed to client-writable JWT claims are common examples in Supabase and Postgres.
Why does my Supabase query return an empty array even though the data exists?
This usually means RLS is enabled with no matching policy, or an unauthenticated request is evaluating a check like auth.uid() = user_id against NULL, which resolves to false rather than an error. Check pg_policies for the table and confirm a session exists before assuming the data is missing.
Can an ORM or a direct Postgres connection bypass RLS?
Not on their own. RLS applies to the connecting database role, not the client library. A direct connection bypasses RLS only if it authenticates as a role with BYPASSRLS, such as the Postgres superuser or Supabase's service_role key, which is why that key should never reach the browser.
Is enabling RLS on a table enough to secure it by itself?
No. RLS enforces row visibility, not column-level access, and table owners bypass RLS by default unless you run FORCE ROW LEVEL SECURITY. Column-sensitive data still needs a separate table or column privileges, and every policy needs both USING and WITH CHECK where writes are involved.
What does FORCE ROW LEVEL SECURITY actually change?
By default, Postgres exempts table owners and superuser roles from RLS entirely, even with policies in place. FORCE ROW LEVEL SECURITY applies your policies to the owner role too, which matters for any table a migration tool or admin script writes to over an elevated connection.
Why did my Supabase Storage upload fail with a 403 RLS error even though INSERT looked correct?
Storage inserts return the new object through an implicit SELECT, so an INSERT policy alone isn't enough. Without a matching SELECT policy on storage.objects, the write succeeds but the read-back for the response fails, which the client sees as a 403 on an otherwise successful upload.
Should RLS policies read from user_metadata or app_metadata?
Always app_metadata for anything authorization-related. user_metadata is writable by the authenticated client through the standard update-profile SDK call, so any policy checking role or tenant against it can be defeated by a user editing their own profile.
Do these RLS anti-patterns apply outside of Supabase, on plain Postgres?
Mostly yes. The WITH CHECK gap, view owner inversion, and the SET versus SET LOCAL pooling issue are Postgres-level behaviors that apply to any RLS setup. The JWT claim and Storage-specific issues are Supabase Auth and PostgREST specifics that a self-hosted Postgres RLS setup won't hit the same way.
None of these five RLS anti-patterns show up as a crash. That's what makes them dangerous in a production multi-tenant system: policies exist, tests pass, and the gap only surfaces when a specific write, a specific pooled connection, or a specific edited JWT claim lines up with it. In a B2B SaaS context serving UK or EU customers, that gap isn't just an engineering embarrassment, it's the kind of cross-tenant leak that turns into a GDPR breach notification. For a US-based SOC 2 engagement, it's a control failure an auditor will flag immediately.
The fix for all five is cheap compared to the alternative: a WITH CHECK clause, a security_invoker flag, one metadata field swapped for another, SET LOCAL instead of SET, a SELECT policy next to your INSERT policy. None of it requires a redesign. Run the six-step self-audit above against your own schema this week, not after the first support ticket forces the question.
We're covering the deeper, execution-level risks in this space, including how SECURITY DEFINER functions and Postgres's own plan-caching behavior can leak access across role boundaries, in a dedicated follow-up. For now, closing these five silent RLS mistakes closes the gaps behind most real-world RLS vulnerabilities in production.