Guide
Supabase Row Level Security Best Practices
In Supabase, the public key (anon key or publishable key) is meant to ship in your frontend. Anyone who opens your app can read it and call the database API directly. What they can then read or change is decided by Row Level Security (RLS) alone: if RLS is off on a table, or a policy lets everything through, that data is open to anyone. Supabase row level security best practice therefore comes down to three things: enable RLS on every exposed table, write one policy per operation that checks auth.uid(), and verify the result with a second account.
- The public Supabase key is not a secret. According to the Supabase docs, it only reaches what RLS allows. Your data is protected by your policies, not by the key.
- The secret key (service_role or secret key) bypasses every policy. It never belongs in a browser, a shipped app or a repository.
- The most common mistakes: RLS not enabled, policies set to
true, overly broad write access, public storage buckets, and views or functions that bypass RLS. - Apps built with AI tools often work perfectly for their creator even when policies are missing. The gap only shows when someone queries with a different account or no account at all.
How Supabase protects data
Public key, roles, policies.
Go to sectionCommon mistakes
From missing RLS to open buckets.
Go to sectionAI-built apps
Why the gap stays invisible.
Go to sectionPolicy examples
Minimal, correct, ready to adapt.
Go to sectionTest it yourself
Security Advisor and a second account.
Go to sectionChecklist
Everything to tick off before launch.
Go to sectionHow Supabase protects your data
Supabase exposes your Postgres database through an auto-generated API at https://<project_ref>.supabase.co/rest/v1/. Your app talks to that API straight from the browser, with no server of your own in between. To do so it needs a key, and that key inevitably ends up in the JavaScript you ship.
That is by design. The Supabase documentation on API keys describes the publishable key as safe to expose online, for example on a web page or in source code, and explains why: anyone can read it, so it only reaches what Row Level Security allows. The older anon key is, per the docs, the legacy version of the publishable key. Supabase has announced that it is replacing the legacy anon and service_role keys with publishable and secret keys.
Every request runs under a Postgres role: anon for visitors who are not signed in, authenticated for signed-in users. Two checks then decide what happens:
- Grants decide whether a role may run an operation on a table at all: read, insert, update or delete.
- Policies decide which rows that operation applies to. They only take effect when RLS is enabled on the table.
The docs point out that on existing projects, a new table in the public schema starts with every privilege granted to anon and authenticated. Without RLS, that means anyone who knows the project URL and the public key can read, edit and delete.
Once RLS is enabled and no policy exists yet, the opposite applies: nothing is visible through the API until you add policies, because Postgres falls back to default deny. According to the docs, tables created in the Supabase Dashboard have RLS enabled already. Tables created in the SQL Editor, through migrations or with other tools need RLS switched on by you.
The most common RLS mistakes
None of these are weaknesses in Supabase. They are configurations that behave exactly as written. Most of them are flagged by the Security Advisor.
1. RLS is not enabled
The most serious case: RLS is off, the grants are in place, and the public key alone gives full access. A typical cause is a table created through SQL or a migration, where RLS is not switched on automatically. A related trap: policies exist, but RLS is disabled. The policies look like protection and do nothing.
2. Policies that allow everything
A policy with using (true) or with check (true) lets every row through. That is fine for genuinely public content such as published blog posts, not for customer data. Supabase's lint calls this a common misconfiguration and lists the usual causes: development placeholders that were never replaced, copy-paste from examples, and the assumption that other policies will restrict access.
That assumption does not hold. In Postgres, multiple policies for the same operation are combined with OR by default, so a single overly broad policy overrides every narrower one. Supabase also recommends a separate policy for each of select, insert, update and delete.
3. The secret key in the frontend
The service_role key, called the secret key in the new format, maps to the service_role role, which is allowed to bypass RLS. The docs leave no room for doubt: a secret key bypasses every policy and must never go into a browser, a shipped application or source control. New secret keys are rejected when they recognisably come from a browser, which Supabase detects via the User-Agent header. From any other tool, a leaked key still works. If one has ever been in your frontend or a public repository, replace it and disable or delete the old one.
4. Policies that only cover reads
A good select policy protects reads. Writes need their own rules, and that is where the subtler mistakes live:
- Inserts without an owner check. Without
with check ((select auth.uid()) = user_id)oninsert, a user can create rows on behalf of someone else. - Updates without a limit on the result. For
update,usingdecides which rows may be changed andwith checkdecides what the row may look like afterwards. Without that limit, a user can hand their own row over to another account. - Too many writable columns. RLS works on rows, not columns. A user who may update their row may, in principle, update every column in it, including fields like
role,is_adminor a payment status. Such fields belong in a separate table users cannot write to.
One detail that often leads to trial and error: per the docs, update only works as expected if the table also has a matching select policy. Anyone who then adds a broad policy just to "make it work" usually opens up more than intended.
5. A missing or wrong auth.uid() check
auth.uid() returns the ID of the signed-in user. A policy that only checks whether someone is signed in (to authenticated using (true)) gives every account every row, including other people's. With open sign-up, that is effectively public. Every policy on personal data needs the comparison with the owner column. Two more points from the docs:
- Without a session,
auth.uid()returnsnull, soauth.uid() = user_idis never true, which is what you want. Supabase still recommends stating the intent explicitly and always naming the role withto. - Anonymous sign-ins in Supabase Auth use the
authenticatedrole, notanon. If you enable them, distinguish via theis_anonymousJWT claim where it matters.
6. Permissions based on user_metadata
According to the docs, signed-in users can edit their own raw_user_meta_data. A policy that reads roles or team membership from user_metadata can therefore be bypassed by the user. Use app_metadata, which users cannot change, or a table of your own.
7. Public storage buckets
Supabase Storage has public and private buckets. In a public bucket, per the docs, retrieving files bypasses access control: anyone with the URL can download the file. Uploading, deleting, moving and copying stay protected. A public bucket suits profile pictures, not invoices, ID documents or contracts. Add a broad select policy on storage.objects to a public bucket and its contents can also be listed, which has its own Security Advisor check.
8. Views and functions that bypass RLS
Views bypass RLS by default because they are usually created by the postgres user and run with its rights. A view over a protected table then returns every row. From Postgres 15 onwards you can change that with security_invoker = true. Functions declared security definer behave similarly: they run with the rights of their creator. If anon or authenticated can execute one, it is callable through /rest/v1/rpc/<name> and may return data RLS would otherwise withhold.
Why AI-built apps are especially exposed
Tools such as Lovable, Bolt or Cursor turn a description into a working app in very little time. Lovable describes Supabase as the backend its apps connect to, and other tools can generate Supabase code too. Lovable's own documentation states that misconfigured RLS rules are a common cause of data leaks. The risk lies less in the generated code than in how it gets tested. A missing or overly broad policy is invisible in normal use:
- The creator tests with their own account. They see their own data and everything works, even if they could see everyone else's too.
- Errors appear when RLS is too strict, not when it is missing. Paste an error back into the tool and you get a fix that makes the app work. Whether that fix opens access wider than necessary is something someone has to check deliberately.
- The Dashboard SQL Editor shows every row, because the table owner and roles with
BYPASSRLSare not subject to policies. Testing there tells you nothing about RLS.
The publicly documented case: CVE-2025-48757
CVE-2025-48757 was published in May 2025. The record describes insufficient Row-Level Security policies in Lovable through 15 April 2025 that allowed remote unauthenticated attackers to read or write arbitrary database tables of generated sites. The record is tagged as disputed: the supplier contests it on the grounds that each customer of the platform accepts responsibility for protecting the data of their application.
In a blog post from June 2025, Lovable itself explains that apps connected to Supabase rely on RLS and that missing or overly broad policies can expose data. Lovable says it scans apps automatically before they are published. The Lovable documentation also states that these tools cannot guarantee complete security, that you are responsible for ensuring your app meets the security requirements appropriate for its use case, and recommends an additional professional security review for apps handling sensitive data.
Policy examples: minimal and correct
The pattern below covers the most common case: each user sees and edits only their own records. It follows the structure of the Supabase RLS documentation. The notes table has a user_id column of type uuid.
-- Enable RLS and grant only what the app needs
alter table public.notes enable row level security;
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
-- Read: own rows only
create policy "notes_select_own"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
-- Insert: only with your own user_id
create policy "notes_insert_own"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- Update: own rows only, and they stay yours
create policy "notes_update_own"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
-- Delete: own rows only
create policy "notes_delete_own"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );
-- Index the column the policies filter on
create index on public.notes (user_id);
Three details are worth noting. Wrapping the call as (select auth.uid()) is a performance recommendation from the docs. to authenticated means the policy is not even evaluated for signed-out visitors. And the grants restrict anon as well, because adding policies does not take back privileges that were already granted.
Files follow the same principle on the storage.objects table. This example, adapted from the Storage docs, only allows uploads into a folder named after the user's own ID in a private bucket:
create policy "uploads_own_folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
Reading, updating and deleting each need their own policies. For upserts, which overwrite existing files, Supabase additionally requires select and update.
How to test your own app
Only test projects you own or have permission to test. For your own app, you do not need special tooling.
Step 1: Review the Security Advisor
In the Supabase Dashboard, the Security Advisor sits under "Advisors". The same checks are available from the command line with supabase db advisors. According to the docs, these are the checks that matter most for RLS:
| Check | Meaning |
|---|---|
0013_rls_disabled_in_public | Table in the public schema without RLS, publicly readable and writable |
0007_policy_exists_rls_disabled | Policies exist, but RLS is disabled |
0024_permissive_rls_policy | Policy with an always-true condition such as using (true) |
0015_rls_references_user_metadata | Policy relies on data the user can edit |
0010_security_definer_view | View that bypasses RLS |
0028 / 0029 | Security definer function executable by anon or authenticated |
0025_public_bucket_allows_listing | Public bucket whose contents can be listed |
0023_sensitive_columns_exposed | Table with likely sensitive columns reachable through the API without access restrictions |
Not every finding is a bug; some data is meant to be public. Decide on each item deliberately.
Step 2: Query someone else's rows with a second account
The most telling test is simple. Create two test accounts, A and B, and add a few records as A. Then sign in as B and try to reach A's data, for example from your app's browser console with the Supabase client:
// signed in as account B
const { data } = await supabase.from('notes').select('*');
// expected: only B's rows
const { data: changed } = await supabase
.from('notes')
.update({ title: 'changed by B' })
.eq('id', 'ID-OF-A-ROW-OWNED-BY-A')
.select();
// expected: empty result, A's row unchanged
Read the result carefully: when the using clause blocks an update, Postgres raises no error, it simply matches zero rows. Always inspect what comes back, then sign in as A and confirm the row is unchanged. Repeat the test signed out, try to insert a row as B with A's user_id, and open file URLs in a private browser window.
Step 3: Keep the tests in your project
Supabase recommends a test file under supabase/tests/ for every protected table that asserts allowed and denied access for anon and authenticated across all four operations, run with supabase test db. That way you notice when a later change quietly loosens a policy.
Pre-launch checklist
| Area | Action |
|---|---|
| RLS | Enabled on every table in an API-exposed schema, including tables created via SQL or migrations |
| Grants | anon and authenticated hold only the operations the app needs |
| Policies | One policy per operation, with a to role and a check on (select auth.uid()) |
| Writes | with check on insert and update; roles, permissions and payment status not user-editable |
| Open policies | using (true) only for genuinely public data |
| Keys | Only the publishable or anon key in the frontend; secret or service_role key server-side only |
| Metadata | No permissions derived from user_metadata |
| Storage | Sensitive files only in private buckets, with policies on storage.objects |
| Views and functions | Views with security_invoker = true; security definer functions not executable by anon |
| Verification | No unexplained Security Advisor findings; second-account and signed-out tests passed |
What an advisor scan cannot find
The Security Advisor does not know that in your app only members of the same team may see an invoice, that users must not upgrade their own plan, or that an Edge Function skips a check. Flaws like these in your access logic only surface when someone deliberately attacks the app with several roles. If personal data is actually accessed as a result, that may trigger a notification duty under Article 33 GDPR. Whether it does in a given case is a legal question. This article is not legal advice.
Is your Supabase app really locked down?
We test apps built on Supabase, including those built with AI tools, within a scope agreed in writing: RLS and grants on every table, write permissions and columns that carry privileges, storage buckets, views, functions and Edge Functions, each with several test accounts and without a session. Every finding comes with reproducible evidence and a concrete fix. Learn more about SaaS penetration testing.
Request an app security checkCyberSec42 is an independent technical security assessment, not an accredited certification body, and does not provide legal advice. CyberSec42 is not affiliated with Supabase or Lovable.
Frequently asked questions
Is it safe to put the Supabase anon key in the frontend?
Yes. According to Supabase, the anon key and its successor, the publishable key, are meant to be public. They only reach what Row Level Security allows. The precondition is that RLS is enabled on every API-exposed table and the policies are correct.
What happens if the service_role key leaks?
The service_role key, now the secret key, bypasses every RLS policy. Whoever holds it can read and change all data. Replace it with a new key immediately, disable or delete the old one, and check your logs for unexpected access.
Is RLS enabled automatically on new Supabase tables?
For tables created in the Supabase Dashboard, yes. For tables created in the SQL Editor, through migrations or with other tools, the docs say you have to enable RLS yourself.
Is the Security Advisor enough?
It is a good first step and catches missing RLS, obviously open policies and risky views or functions. It does not check whether your policies match your app's logic, such as who may change which row. That takes tests with several accounts.
Are apps built with Lovable insecure?
Not inherently. Lovable says it scans apps for common problems, including missing RLS, before they are published. According to Lovable, responsibility for correct access rules lies with whoever runs the app, so review your policies yourself as described in this article.
Further reading
- Vibe coding security checklist
- Lovable Security Issues: What Is Documented and How to Fix Them
- Firebase Security Rules Best Practices
- Penetration Testing: Process, Duration and What It Costs
- Vulnerability Assessment: what it is and when you need one
- AI Agent Security: A Practical Overview
- Service: SaaS penetration testing
Sources
- Supabase Docs: Row Level Security
- Supabase Docs: Understanding API keys
- Supabase Docs: Securing your API
- Supabase Docs: Tables and Data
- Supabase Docs: Database Advisors
- Supabase Docs: Storage Buckets
- Supabase Docs: Storage Access Control
- PostgreSQL Docs: Row Security Policies
- NVD: CVE-2025-48757
- CVE.org: CVE-2025-48757
- Lovable Blog: Secure vibe coding
- Lovable Docs: Security
- EUR-Lex: General Data Protection Regulation (EU) 2016/679