Home  /  Blog  /  Supabase RLS Security Audit: 7 Critical Mistakes We Found

● Database Security

Supabase RLS Security Audit: 7 Critical Mistakes We Found

Supabase makes row-level security easy to switch on, and that is exactly the trap. In a recent audit of a multi-tenant SaaS platform, RLS was enabled on all 113 tables and every table had policies, yet we still found 15 issues, two of them High risk. This anonymised case study walks through the seven most important Supabase RLS mistakes we found, why each one is dangerous, and the exact pattern that fixes it.

Published 7 November 2026 13 min read Codesecure Application Security Database Security

Key Takeaways

  • Enabling RLS is not the same as being secure. The platform we audited had RLS on all 113 tables and still had two High-risk issues.
  • RLS filters rows, not columns. Without column grants and a WITH CHECK clause, a user who can update their own row can change any column in it, including billing and privilege flags.
  • SECURITY DEFINER functions run as their owner and ignore RLS. Supabase publishes them at /rest/v1/rpc/, and PostgreSQL grants EXECUTE to PUBLIC by default, so an unguarded function is a direct bypass.
  • Anonymous policies with WITH CHECK (true), and policies that target the public role instead of authenticated, are the most common and most dangerous defaults we see.
  • A proper RLS audit reads every policy, every RPC and every grant. Most tools only check whether RLS is on, which is the one thing that is usually fine.

The Setup: RLS Was On Everywhere, and Still Not Safe

We recently completed a database security audit of a multi-tenant SaaS platform built on Supabase, where each tenant is a separate business and the whole system runs on one shared PostgreSQL schema. On paper the row-level security posture looked excellent: RLS was enabled on all 113 tables, every table had policies, and the core tenant tables used a layered model with a permissive owner policy plus a RESTRICTIVE tenant guard on every command. That is already better than most Supabase projects we see.

And yet the review surfaced 15 findings, two of them High risk, concentrated in the places teams rarely check. The lesson is the one that matters most for anyone building on Supabase: turning RLS on is the easy 20 percent; the dangerous 80 percent is what your policies actually allow. Below are the seven mistakes that mattered, each written as a pattern you can check in your own project tonight.

Mistake 1: RLS Protects Rows, Not Columns

The highest-impact finding was also the simplest. A policy let owners update their own record: FOR UPDATE USING (auth.uid() = user_id), with the authenticated role holding table-wide UPDATE. Because RLS decides which rows a user can touch but never which columns, a signed-in owner could send a REST PATCH to their own row and change anything in it, including their subscription plan, a wallet balance used for paid features, and a self-set verified flag. There was no WITH CHECK clause either, so new values were never validated.

This is the single most misunderstood thing about Postgres RLS. A policy that looks like "users can edit their own profile" quietly becomes "users can edit every billing and privilege column on their own profile" unless you add column-level control. The impact here was direct revenue and integrity loss for the platform operator: free subscriptions, self-credited balances, and self-granted verification.

Worried Your Supabase RLS Has These Gaps?

Codesecure audits Supabase and PostgreSQL row-level security the way an attacker tests it: every policy, every SECURITY DEFINER function, every anon and authenticated grant. We deliver a prioritised findings report with exact remediation SQL. ISO/IEC 27001:2022 certified delivery, named OSCP and CEH consultants, fixed-price proposals, for businesses across India, Singapore, UAE and Malaysia.

See Database Security Services →

The Fix: Column Grants Plus WITH CHECK

Revoke the blanket table privilege and grant UPDATE only on the columns a client is allowed to edit, for example grant update (name, phone, address) on ... to authenticated. Handle privileged fields (plan, wallet, verification, lock state) only through service-role code or a SECURITY DEFINER function that checks the caller is actually an admin. Finally, add a WITH CHECK clause to every UPDATE and INSERT policy so the new row is validated, not just the old one, and scope the policy TO authenticated rather than leaving it open to all roles.

Mistake 2: SECURITY DEFINER Functions Silently Bypass RLS

The platform had 95 SECURITY DEFINER functions. A SECURITY DEFINER function runs with the privileges of the user who owns it, not the user who calls it, which means it ignores RLS entirely. Supabase then publishes every function in the public schema as a callable endpoint at /rest/v1/rpc/<name>, and PostgreSQL grants EXECUTE to the PUBLIC role by default. Put those three facts together and an unguarded function is a direct, documented way around row-level security.

The risk is concrete. If a reporting or admin function does not re-check the caller inside its body, any authenticated user, or even an anonymous visitor holding the public anon key, who can guess or enumerate a tenant ID can call the RPC and read or change another tenant's data. Functions with names like "get finance data" or "delete members" are exactly the ones you do not want reachable by PUBLIC.

The Fix: Revoke EXECUTE, Then Authorise Inside

Revoke EXECUTE on all functions in the schema from public and anon, and change the default privileges so new functions are not exposed automatically. Grant EXECUTE back only where a client genuinely needs it. Then, inside every function that returns or changes tenant data, authorise the caller explicitly: check auth.uid() against the tenant or owner id the function was handed, before doing any work. For read-only reports, prefer SECURITY INVOKER so RLS applies normally and you get defence in depth for free.

Not Sure If Your Policies Are Safe?

Send us your Supabase project and we will scope a row-level security review on a free 30-minute call. We assess RLS coverage, column exposure, RPC bypass paths and tenant isolation, then hand you the fixes.

Book a Free Scoping Call →

Mistake 3: Anonymous INSERT With WITH CHECK (true)

Several business tables allowed unauthenticated inserts with no constraint at all: policies of the form INSERT TO anon WITH CHECK (true). Because WITH CHECK (true) accepts anything, the anonymous caller controlled every column on the inserted row, including the tenant id, a payment status, and amounts. In practice that meant anyone with the public anon key could create records marked as paid, inject arbitrary contact details and phone numbers, or flood the tables to exhaust storage.

Public write paths are sometimes necessary, a public sign-up form or a contact submission, but "public" must never mean "the client sets every field". An unconstrained anonymous insert is spam, fraud and log poisoning waiting to happen, and it corrupts the very revenue and activity reports the business relies on.

The Fix: Constrain the Insert, or Move It Server-Side

The strongest fix is to replace the open policy with an Edge Function or RPC that validates the tenant, forces server-controlled fields (set payment_status to pending, compute amounts on the server), and applies rate limiting or a CAPTCHA. If you must keep a direct insert policy, constrain its WITH CHECK so the client cannot set privileged columns and cannot reference a tenant that does not permit public writes. Never leave WITH CHECK (true) on a table that feeds business logic.

Mistake 4: Sensitive Data Readable by the Anon Role

Two read policies granted SELECT to the anon role with USING (true) on a settings table that held, among other things, a payment handle (such as a UPI ID) for every tenant. A restrictive tenant guard existed, but it only applied to the authenticated role, so it did nothing to limit anonymous reads. The result: anyone with the public anon key could enumerate every tenant and scrape their payment handles, which enables targeted payment fraud and phishing, and lets competitors harvest pricing.

A related finding let any signed-in user read every column of every active tenant record, exposing owner identifiers, tax numbers and internal flags. Broad SELECT policies like these are how attackers collect the tenant identifiers they then feed into the RPC attacks from Mistake 2.

Mistake 5: UPDATE and ALL Policies Without WITH CHECK

Beyond the billing table, nine permissive UPDATE and ALL policies across the schema had no WITH CHECK clause. When a policy has a USING expression but no WITH CHECK, PostgreSQL reuses the USING predicate to validate the new row, which almost never does what you want and protects no columns. The clearest example was a "members can cancel their own bookings" policy that, in reality, let a member change any column of their booking, including status, class and fees, not just cancel it.

This is the same root cause as Mistake 1, seen across the whole schema: business rules enforced only in the application UI, with the database happily accepting whatever the client sends. Anyone who calls the REST or RPC endpoint directly, bypassing your frontend, gets to rewrite those columns.

The Fix: Explicit WITH CHECK and State-Transition RPCs

Add an explicit WITH CHECK to every UPDATE and ALL policy that pins the columns a user may change and the values they may set. For genuine state transitions, such as cancelling a booking, grant UPDATE on just the status column, or expose a narrow RPC that performs the transition and nothing else. The database, not the browser, has to be the thing that enforces the rule.

Mistake 6: Policies Targeting {public} and Over-Broad Role Grants

119 permissive policies across 62 tables targeted the public role rather than authenticated. Today they happen to deny anonymous access because auth.uid() returns NULL for the anon role, but that is an accident of expression evaluation, not an explicit boundary. One future edit, or one helper function that returns true for a NULL input, silently opens those tables to the public anon key. Access control should rest on an explicit role boundary, not a side effect.

We saw the same "too permissive" theme in role management: a policy let tenant managers insert and update team members with no restriction on the role column, so a manager could promote themselves or a colleague to admin. And a notifications table let any owner send an in-app notification to any user across tenants, a ready-made in-app phishing channel. Multi-tenant RLS is not just about isolating rows by tenant, it is about constraining which values each role may write.

Mistake 7: Plaintext Secrets and Blanket Default Grants

Two hardening failures rounded out the picture. First, per-tenant SMTP passwords were stored in a plain-text column and returned to the client on read. Even though RLS limited reads to the owner and super admins, a secret that is sent to the browser and written into every database backup is a secret you have lost control of. Secrets belong in Supabase Vault or an encrypted, write-only field, never in a readable column.

Second, every one of the 113 tables granted the full set of privileges, including TRUNCATE, REFERENCES and TRIGGER, to both anon and authenticated. RLS is the only barrier in front of that, so a single dropped policy or an accidental DISABLE ROW LEVEL SECURITY would immediately expose full create, read, update and delete to the anonymous key. That is the opposite of defence in depth. Revoke the privileges no client needs and set least-privilege defaults so new tables are not exposed the moment they are created.

What the Platform Got Right (Audits Are Not Just Bad News)

It is worth saying what this team did well, because it shows where the real risk lives. RLS was on for every table. The core tenant tables used a layered permissive-plus-restrictive model, and 556 of 801 policies were RESTRICTIVE. Role checks read from a dedicated roles table rather than trusting self-asserted user metadata, member self-access used the verified JWT phone claim, and all 95 functions pinned search_path, which closes the classic search-path hijack. Payroll and salary tables were correctly locked to admin roles.

In other words, the foundation was strong, and the dangerous issues were concentrated in a handful of specific, checkable patterns: column exposure, RPC bypass, anonymous writes and reads, and over-broad grants. That is exactly why a manual, line-by-line RLS review finds what dashboards miss. The gaps are never "is RLS on"; they are in what the policies, functions and grants actually permit.

Auditing Your Own RLS, and When to Call an Expert

A quick self-check catches the obvious gaps. For every table, confirm RLS is on and that each UPDATE, INSERT and ALL policy has a WITH CHECK clause and targets authenticated, not public or anon. Confirm your service_role key never reaches the browser. Then sign in as one tenant and try to read and write another tenant's rows through both the REST API and your RPC endpoints.

That five-minute check finds the easy misses. The issues that actually caused the High-risk findings above, column exposure, SECURITY DEFINER bypass and over-broad grants, hide in policy text, function bodies and privilege tables that a quick look never reaches. If you are shipping multi-tenant data on Supabase, the safest move is to have the row-level security reviewed properly: Codesecure reads every policy, function and grant, tests tenant isolation the way an attacker would, and hands you a prioritised report with the exact remediation SQL. That is the difference between "RLS is enabled" and "tenants are actually isolated".

SHARE

Frequently Asked Questions

Does enabling RLS make my Supabase database secure?

No. Enabling row-level security is necessary but not sufficient. RLS controls which rows a role can access, but it does not protect individual columns, it does not apply to SECURITY DEFINER functions, and it sits behind default table grants that may hand anon and authenticated far more than they need. In the audit above, RLS was enabled on all 113 tables and we still found two High-risk issues. Security depends on what your policies, functions and grants actually permit, not on whether RLS is switched on.

Can Supabase RLS protect specific columns?

Not on its own. A row-level security policy decides which rows a user can read or write, but if the user can update a row at all, they can set any column in it unless you also use column-level GRANTs and a WITH CHECK clause. To protect sensitive columns such as billing, wallet or role fields, revoke the table-wide privilege and grant UPDATE only on the columns clients may edit, handling the rest through server-side or admin-only code.

Do SECURITY DEFINER functions bypass RLS in Supabase?

Yes. A SECURITY DEFINER function runs with the privileges of its owner, so it ignores the caller's RLS policies. Supabase exposes functions in the public schema at /rest/v1/rpc/, and PostgreSQL grants EXECUTE to PUBLIC by default, so an unguarded SECURITY DEFINER function is a direct RLS bypass. Revoke EXECUTE from anon and public, grant it back only where needed, and authorise the caller with auth.uid() inside every function that touches tenant data. Prefer SECURITY INVOKER for read-only queries so RLS still applies.

Is it safe to allow anonymous inserts in Supabase?

Only if the insert is tightly constrained. A policy of the form INSERT TO anon WITH CHECK (true) lets the anonymous caller set every column, which enables spam, fake paid records, data poisoning and storage exhaustion. If you need a public write path such as a contact form, route it through an Edge Function or RPC that validates input and sets server-controlled fields, or at minimum constrain the WITH CHECK so clients cannot set privileged columns or arbitrary tenant ids.

How do I audit my Supabase row-level security policies?

Export and review every policy, function and grant, do not rely on the RLS on/off flag alone. Confirm each UPDATE, INSERT and ALL policy has a WITH CHECK clause and targets authenticated rather than public; list SECURITY DEFINER functions and check EXECUTE grants and caller authorisation; review anon and authenticated table grants for least privilege; and test cross-tenant access as a real signed-in user and as the anon role through both REST and RPC. A manual review of all of this is what finds the issues automated checks miss.

Can Codesecure audit our Supabase or PostgreSQL database?

Yes. Codesecure runs manual Supabase and PostgreSQL row-level security audits that read every policy, every SECURITY DEFINER function and every grant, test tenant isolation as an attacker would, and deliver a prioritised findings report with exact remediation SQL. ISO/IEC 27001:2022 certified delivery with named OSCP, CEH and CISSP consultants, for businesses across India, Singapore, UAE and Malaysia. Book a free scoping call to get started.

CS

Codesecure Application Security

OSCP / CEH / CISSP Certified Security Consultants

Codesecure Solutions is ISO/IEC 27001:2022 certified and runs manual application and database security assessments, including Supabase and PostgreSQL row-level security audits, API penetration testing and cloud security reviews, for businesses across India, Singapore, UAE and Malaysia. Named OSCP, CEH and CISSP consultants, fixed-price proposals, reports mapped to OWASP and ISO 27001.

✓ ISO/IEC 27001:2022 Certified

Is Your Supabase RLS Actually Isolating Tenants?

Codesecure audits Supabase and PostgreSQL row-level security the way an attacker tests it, every policy, every RPC, every grant, and hands you a prioritised report with the exact SQL to fix it. ISO/IEC 27001:2022 certified delivery by named OSCP, CEH and CISSP consultants across India, Singapore, UAE and Malaysia.