Skip to content

Guide

Supabase Row Level Security Best Practices

By Lucas Weber, CyberSec42 · Updated 28 Oct 2026

CyberSec42

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.

Key points.

How Supabase protects data

Public key, roles, policies.

Go to section

Common mistakes

From missing RLS to open buckets.

Go to section

AI-built apps

Why the gap stays invisible.

Go to section

Policy examples

Minimal, correct, ready to adapt.

Go to section

Test it yourself

Security Advisor and a second account.

Go to section

Checklist

Everything to tick off before launch.

Go to section

How 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:

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:

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:

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 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:

CheckMeaning
0013_rls_disabled_in_publicTable in the public schema without RLS, publicly readable and writable
0007_policy_exists_rls_disabledPolicies exist, but RLS is disabled
0024_permissive_rls_policyPolicy with an always-true condition such as using (true)
0015_rls_references_user_metadataPolicy relies on data the user can edit
0010_security_definer_viewView that bypasses RLS
0028 / 0029Security definer function executable by anon or authenticated
0025_public_bucket_allows_listingPublic bucket whose contents can be listed
0023_sensitive_columns_exposedTable 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

AreaAction
RLSEnabled on every table in an API-exposed schema, including tables created via SQL or migrations
Grantsanon and authenticated hold only the operations the app needs
PoliciesOne policy per operation, with a to role and a check on (select auth.uid())
Writeswith check on insert and update; roles, permissions and payment status not user-editable
Open policiesusing (true) only for genuinely public data
KeysOnly the publishable or anon key in the frontend; secret or service_role key server-side only
MetadataNo permissions derived from user_metadata
StorageSensitive files only in private buckets, with policies on storage.objects
Views and functionsViews with security_invoker = true; security definer functions not executable by anon
VerificationNo 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 check

CyberSec42 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

Sources