---
title: "Supabase row level security not working? Six fixes | Zeroic"
description: "Supabase row level security not working? Six causes explain almost every case - three hide your rows, three leak them. Find yours by symptom and fix it."
url: https://zeroic.in/blog/supabase-row-level-security-not-working
---

[The Zeroic Journal](https://zeroic.in/blog)

[Software engineering](https://zeroic.in/blog/topic/software-engineering) October 5, 2026 9 min read

# Supabase row level security not working: six causes and fixes

Empty arrays, insert errors and rows anyone can read all trace back to six causes. Here's the symptom, the check and the fix for each.

By Prashant Abbi Partner @ Zeroic

On this page

1. [Supabase row level security not working: triage first](#supabase-row-level-security-not-working-triage-first)
2. [Three causes that hide your rows](#three-causes-that-hide-your-rows)
3. [Three causes that leak your rows](#three-causes-that-leak-your-rows)
4. [Fix order: a checklist by symptom](#fix-order-a-checklist-by-symptom)
5. [100+ audits, from Sam’s List to VoiceDrop](#100-audits-from-sams-list-to-voicedrop)

On this page 5 sections

1. [Supabase row level security not working: triage first](#supabase-row-level-security-not-working-triage-first)
2. [Three causes that hide your rows](#three-causes-that-hide-your-rows)
3. [Three causes that leak your rows](#three-causes-that-leak-your-rows)
4. [Fix order: a checklist by symptom](#fix-order-a-checklist-by-symptom)
5. [100+ audits, from Sam’s List to VoiceDrop](#100-audits-from-sams-list-to-voicedrop)

Supabase row level security not working almost always comes down to six causes. Three hide rows you should see: a missing policy for the operation, a null `auth.uid()`, and a policy that joins through a locked table. Three leak rows you meant to protect: RLS switched off, a secret key in the client, and a view running as its owner.

The two groups fail in opposite directions, and only one of them gets reported. A hidden row produces a bug ticket the same day. A leaked row produces nothing until someone outside your company finds it, which is how the Lovable apps behind CVE-2025-48757 ended up in our write-up of [five production failure modes in vibe-coded MVPs](https://zeroic.in/blog/vibe-coded-mvp-production). The timing matters too: Supabase is [removing the legacy anon and service_role keys in late 2026](https://supabase.com/blog/supabase-security-2025-retro), so plenty of teams are touching their key setup this quarter, which is exactly when a secret key slips into a client bundle.

## Supabase row level security not working: triage first

Before you rewrite a single policy, answer one question: are you seeing too little data, or too much?

**Too little** looks like an empty array where you know rows exist, an insert that throws `new row violates row-level security policy`, or an update that reports success and changes nothing. Postgres is doing what it was told. Its [CREATE POLICY documentation](https://www.postgresql.org/docs/current/sql-createpolicy.html) spells out the rule: when RLS is on and no policy applies, a “default deny” policy is assumed, so no rows are visible or updatable. No error, just nothing.

**Too much** looks like a logged-out request, or a second test account, reading rows that belong to someone else. Nothing errors here either.

Two checks split the “too little” cases in a couple of minutes, both from [Supabase’s own troubleshooting guide for empty selects](https://supabase.com/docs/guides/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx):

1. On a development copy, disable RLS on the table and rerun the query. If rows come back, the problem is your policies, not your data or your filters.
2. Temporarily add a policy of `true` for the `anon` role. If that returns rows, your request isn’t carrying a signed-in user’s JWT, and `auth.uid()` is null.

One trap to rule out first: don’t test policies in the dashboard’s SQL editor and trust the result. It runs as a privileged role, so a query that works there [says nothing about what your app’s requests can see](https://www.guardlayer.io/blog/supabase-auth-uid-null-rls). Test through the client library with a signed-in test user, or impersonate a role in SQL.

## Three causes that hide your rows

### The policy doesn’t cover the operation

Policies are per command: SELECT, INSERT, UPDATE, DELETE. A table with a careful SELECT policy and nothing else rejects every write. The less obvious failures come from commands that quietly need a second policy:

- **INSERT needs SELECT when you ask for the row back.** Postgres requires any row read by a `RETURNING` clause to pass the table’s SELECT policies. The [supabase-js insert reference](https://supabase.com/docs/reference/javascript/insert) notes that inserted rows only come back when you chain `.select()`, and that request is what triggers the check. If the row passes the INSERT check but fails the SELECT one, you get the RLS error even though the INSERT policy is correct. Add a SELECT policy that matches, or drop the `.select()`.
- **UPDATE needs SELECT to find the row.** The [Supabase RLS guide](https://supabase.com/docs/guides/database/postgres/row-level-security) is blunt about it: an UPDATE without a corresponding SELECT policy won’t work as expected. Postgres has to read the row before it can change it, so an update-only policy matches zero rows and returns success.
- **UPDATE has two expressions.** `USING` decides which existing rows you can target; `WITH CHECK` decides what the row may look like afterward. Leave `WITH CHECK` out and Postgres applies `USING` to the new row too, which is usually what you want. Write a looser one, like `with check (true)`, and a user can reassign their own row to someone else’s `user_id`.

**Fix:** for every table, list the four commands and confirm each one has a policy for the role that runs it, or a deliberate decision that it shouldn’t.

### auth.uid() is null

`auth.uid()` reads the user id from the JWT on the request. When no JWT arrives, Supabase’s docs say it returns null, and `null = user_id` is never true, so every row is filtered out without an error. The usual ways the JWT goes missing:

- **A server-side client created without the user’s cookies.** In Next.js and other SSR frameworks, the [server client needs cookie handlers](https://supabase.com/docs/guides/auth/server-side/creating-a-client) to read the session. Skip them and the client sends requests as `anon`.
- **A shared client instance on the server.** One client reused across requests can carry the wrong session, or none.
- **Background jobs, cron and webhooks.** There’s no user, so there’s no JWT. These jobs need their own authorization path rather than a user policy.
- **An expired session** that the client never refreshed.

**Fix:** log the role your request runs as (the troubleshooting guide includes a small SQL function for it). Then write policies as `to authenticated` with `(select auth.uid()) = user_id`. The Supabase guide recommends the `to authenticated` clause so the check never runs for anonymous users, and an explicit `auth.uid() is not null` makes the intent obvious to the next reader.

### The policy joins through a locked table

Team and organization access usually lives in a policy like this:

```
create policy "members read projects" on projects for select to authenticated using ( team_id in ( select team_id from team_members where user_id = (select auth.uid()) ) );
```

The subquery runs as the calling user, so RLS applies to `team_members` too. If `team_members` has no SELECT policy that lets a user see their own memberships, the subquery returns nothing and every project disappears. It looks like a bug in the `projects` policy. It isn’t.

Supabase’s [RLS performance guide](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv) notes that join tables inside a policy run their own RLS unless you route the lookup through a `security definer` function, and the same change is a large speed win: in its test, an admin check that joined another table dropped from 11,000ms to 7ms when wrapped that way. Indexing the policy column helps as much. Their `user_id` example on a 100,000-row table went from 171ms to under 0.1ms.

**Fix:** give the membership table its own SELECT policy, or move the lookup into a `security definer` function. Put that function in a schema that isn’t exposed through the API, because the RLS guide warns that one in an exposed schema is callable over the Data API with its creator’s privileges.

## Three causes that leak your rows

### RLS is off on the table

A table in an exposed schema without RLS is readable and writable by any role with a grant on it, and the publishable (anon) key is public by design. Supabase now enables RLS by default for tables created in the dashboard, but tables created through raw SQL, ORMs and migration tools don’t get that default. Their [2025 security retro](https://supabase.com/blog/supabase-security-2025-retro) suggests a Postgres event trigger to enforce it, and the Security Advisor flags tables without it.

**Fix:** run `alter table ... enable row level security` on every table in `public` and any other exposed schema, then add an event trigger so the next migration can’t undo it.

### A secret key reached the client

Secret keys and the legacy `service_role` key run as the `service_role` Postgres role, which has `BYPASSRLS`. The [Supabase API keys guide](https://supabase.com/docs/guides/api/api-keys) puts it plainly: a secret key bypasses every RLS policy you have. Teams reach for it when policies block them in development, it works, and it ships.

The new `sb_secret_` keys return HTTP 401 when called from a browser, and Supabase revokes ones it detects in public GitHub repositories. Neither protection covers a legacy `service_role` JWT still sitting in a mobile app bundle or an environment variable your build exposes to the browser.

**Fix:** search your client code and built bundles for the key. If it’s there, rotate it, move the call into a server route or Edge Function with its own authorization check, and migrate to publishable and secret keys before the legacy ones are removed.

### A view runs as the table owner

A Postgres view checks the underlying tables with the view owner’s permissions, and the [CREATE VIEW documentation](https://www.postgresql.org/docs/current/sql-createview.html) confirms the owner’s RLS policies apply by default. In Supabase the owner is usually `postgres`, which owns your tables and skips their policies. So a convenience view like `orders_with_customer` can expose every order to anyone with the publishable key, while the `orders` table itself is locked down.

**Fix:** on Postgres 15 and later, recreate the view `with (security_invoker = true)` so it uses the caller’s policies. On older versions, revoke access to the view from `anon` and `authenticated` or move it to a schema the API doesn’t expose.

## Fix order: a checklist by symptom

SymptomLikely causeFix

Empty array, rows existNo SELECT policy, or `auth.uid()` is nullAdd a SELECT policy `to authenticated`; check the request’s role

Insert errors with “violates row-level security”INSERT `WITH CHECK` fails, or returned row fails SELECTFix the check, add a SELECT policy, or drop `.select()`

Update succeeds, nothing changesNo SELECT policy on the tableAdd a SELECT policy matching the UPDATE `USING`

Team data missing, own data finePolicy joins a table with no policy for that userPolicy on the join table, or a `security definer` helper

Logged-out user can read rowsRLS off, or a view as ownerEnable RLS; `security_invoker = true` on views

Every policy ignoredSecret or service_role key in the clientRotate the key, move the call server-side

Work it in this order:

- **Leaks before blocks.** A blocked row costs you a support ticket; a leaked row can cost you the company. Check RLS status on every exposed table, then your views, then your client bundle for keys.
- **Then policies, command by command.** Four commands per table, one role each.
- **Then speed.** Index every column a policy filters on and wrap `auth.uid()` in `select` so Postgres evaluates it once per statement instead of once per row.

If your app came out of Lovable, Bolt or a similar builder, assume all six apply until you’ve checked. Our [comparison of Lovable, Bolt and Cursor](https://zeroic.in/blog/lovable-vs-bolt-vs-cursor) covers why RLS is the first thing to break in those apps.

## 100+ audits, from Sam’s List to VoiceDrop

Zeroic’s [security and performance audits](https://zeroic.in/audits), for clients including Sam’s List, VoiceDrop and AppSheet Training, are specialized in Bubble and Supabase, and the count is past 100 applications. On a Supabase project we start where this article ends: every table in an exposed schema, every policy per command, every view, and every place a key could leave the server. We test access by hand with signed-in and signed-out users, because a policy that reads correctly can still return the wrong rows.

Each finding comes back verified, ranked by severity, and with repro steps and a fix, inside 1-2 weeks. When the fixes run deeper than policies - auth flows rebuilt, keys moved behind server routes, tests added so the next migration can’t reopen a hole - that work happens in a [Code Rescue engagement](https://zeroic.in/services/code-rescue), which starts with a 3-day review and runs 2-4 weeks.

## Frequently asked questions

**Why does my Supabase query return an empty array when the table has data?**

Row level security is enabled and no policy lets the current request see those rows. Postgres treats a table with RLS on and no matching policy as default deny, so the query succeeds and returns nothing instead of raising an error. Check that a SELECT policy exists for the right role and that the request carries a signed-in user's JWT.

**Why does a Supabase insert fail with new row violates row-level security policy?**

Either the INSERT policy's WITH CHECK expression rejected the row, or you asked for the row back and it failed your SELECT policy. In supabase-js, chaining select() onto an insert returns the new row, and Postgres applies SELECT policies to anything a RETURNING clause reads. Add a matching SELECT policy or stop asking for the row back.

**Does the Supabase service role key bypass row level security?**

Yes. Secret keys and the legacy service_role key authorize requests as the service_role Postgres role, which has the BYPASSRLS attribute and skips every policy. Use them only in server code that does its own authorization checks, and never ship one in a browser or mobile app bundle, where anyone can extract it.

**Why is auth.uid() null in my Supabase RLS policy?**

No user JWT reached Postgres with that request. The usual causes are a server-side client created without the user's cookies, a client created before sign-in finished, an expired session, or a test run in the SQL editor. Since null never equals a user id, the policy quietly hides every row instead of throwing an error.

**Do Supabase views respect row level security?**

Not by default. A view runs with its owner's permissions, and in a Supabase project the owner is usually the postgres role, which also owns your tables and skips their policies. On Postgres 15 or later, create the view with security_invoker set to true so it checks the calling user's policies instead.

## References

1. [Supabase - Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
2. [Supabase - Why is my select returning an empty data array and I have data in the table?](https://supabase.com/docs/guides/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx)
3. [Supabase - RLS performance and best practices](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv)
4. [Supabase - Understanding API keys](https://supabase.com/docs/guides/api/api-keys)
5. [Supabase - Creating a Supabase client for SSR](https://supabase.com/docs/guides/auth/server-side/creating-a-client)
6. [Supabase - Security retro: 2025](https://supabase.com/blog/supabase-security-2025-retro)
7. [Supabase - JavaScript insert reference](https://supabase.com/docs/reference/javascript/insert)
8. [PostgreSQL - CREATE POLICY](https://www.postgresql.org/docs/current/sql-createpolicy.html)
9. [PostgreSQL - CREATE VIEW](https://www.postgresql.org/docs/current/sql-createview.html)
10. [GuardLayer - Why auth.uid() returns null in Supabase RLS](https://www.guardlayer.io/blog/supabase-auth-uid-null-rls)

#supabase#rls#postgres#security#auth

More from the journal

[ Software engineering

## [Lovable app security issues: an afternoon checklist](https://zeroic.in/blog/lovable-app-security-issues)

Lovable's built-in scan reports only critical findings. These are the six settings behind most leaks, and how to check each one yourself.

Khushi Arora Oct 14, 2026 · 8 min read

[ SaaS

## [Supabase vs Firebase for SaaS: cost at 1k, 10k and 100k users](https://zeroic.in/blog/supabase-vs-firebase-cost)

Firebase is cheaper while you're small. Past 10,000 users, per-read billing and auth fees flip it, and leaving Firestore costs far more than leaving Postgres.

Prashant Abbi Oct 13, 2026 · 6 min read

[ No-code

## [How to migrate Bubble app to Supabase without password resets](https://zeroic.in/blog/migrate-bubble-app-supabase)

A step-by-step Bubble exit for the data layer: exports, Postgres tables, row level security, files, and moving every user without a reset email.

Khushi Arora Oct 9, 2026 · 9 min read

## Hear from Prashant within 24 hours.

**Prashant Abbi** Partner @ Zeroic

Not sure which of your tables are leaking? A Zeroic security audit checks every table, policy, view and key in your Supabase project by hand and ranks what to fix first. Fixed price, 1-2 weeks.

[Book a security audit](https://zeroic.in/services/ai-audit), or [book a 30-minute call](https://zeroic.in/book)

Pages [Services](https://zeroic.in/services) [Work](https://zeroic.in/work) [Blog](https://zeroic.in/blog) [About](https://zeroic.in/about) [Guide for AI](https://zeroic.in/llms.txt)

Services [Audits](https://zeroic.in/services/ai-audit) [MVP Sprint](https://zeroic.in/services/mvp-development) [Retainer](https://zeroic.in/services/senior-developers) [Code Rescue](https://zeroic.in/services/code-rescue) [Bubble to code](https://zeroic.in/bubble-to-code) [AI development](https://zeroic.in/ai-development-company)

Industries [Marketplaces](https://zeroic.in/marketplace-app-development) [B2B SaaS](https://zeroic.in/saas-mvp-development) [FinTech](https://zeroic.in/fintech-app-development) [Telehealth](https://zeroic.in/telehealth-app-development) [EdTech](https://zeroic.in/edtech-app-development) [All industries →](https://zeroic.in/industries)

Developers [Bubble](https://zeroic.in/hire-bubble-developers) [React](https://zeroic.in/hire-nextjs-developers) [Python](https://zeroic.in/hire-python-developers) [AI & LLMs](https://zeroic.in/hire-ai-developers) [API integrations](https://zeroic.in/api-integration-services) [All developers →](https://zeroic.in/developers)

Contact [Email](mailto:zeroic.in@gmail.com) [WhatsApp](https://wa.me/919873927225) [LinkedIn](https://www.linkedin.com/company/zeroic/) [X (Twitter)](https://x.com/PrashantAbbi) [Book a call](https://zeroic.in/book)

© 2026 Zeroic [Privacy](https://zeroic.in/privacy) [Terms](https://zeroic.in/terms)

Built in India · Shipping globally

SINCE
