# Supabase Row Level Security (RLS) Checklist

DreamLabs is an AI-powered app development agency in California — a fractional product team for mobile and web apps, from strategy to launch.

Supabase gives your frontend a direct line to Postgres, which is a big part of why teams building in Lovable, in their own repositories with AI coding tools, or by hand get to a working product in days. The key that makes it possible sits in every visitor's browser by design, so Row Level Security is what decides who sees which row. When your policies say exactly that, you can open sign-ups, answer a security questionnaire with evidence, and keep shipping without wondering what a stranger with your key can do. This checklist covers 23 checks across RLS, grants, keys, functions, Storage and Realtime, including the Lovable-era exposure pattern behind CVE-2025-48757. Each one says what to look for, why it matters, how to test it with SQL, curl or the Dashboard, and which Supabase documentation backs it. Run the tests only against projects you own, ideally a staging branch.

Updated October 7, 2026. 23 checks in 7 sections. Free and ungated.

## Start here: what can a stranger reach?

Your project URL and publishable (or legacy anon) key sit in every visitor's browser by design. Supabase's own analogy: the publishable key is taped to the front door, and it only opens the lobby. These four checks confirm the lobby is all it opens.

- [ ] **Row Level Security is enabled on every table in every schema the Data API exposes (usually `public`).**

  Why it matters: Supabase's guide opens with it: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Tables created in the Dashboard get RLS by default, but tables created in the SQL Editor, by a migration or by an AI coding tool need it switched on explicitly.

  How to test: Run the query below in the SQL Editor. Any row showing `rowsecurity = false` is a finding. Fix it in a migration with `alter table public.<table> enable row level security;`, then write policies, because once RLS is on the table returns nothing to the public key until a policy allows it.

  ```sql
  select schemaname, tablename, rowsecurity
  from pg_tables
  where schemaname = 'public'
  order by rowsecurity, tablename;
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)

- [ ] **Table grants for `anon` and `authenticated` match what each role should actually do. Signed-out visitors hold no insert, update or delete grants they don't need.**

  Why it matters: Postgres checks grants before it looks at policies. On projects created under the old defaults, every new `public` table got select, insert, update and delete for `anon`, `authenticated` and `service_role`, and adding policies doesn't take those grants back. Supabase is making exposure opt-in: the new default began rolling out gradually to new projects on May 30, 2026, and is scheduled for existing projects on October 30, 2026, but in existing projects, tables that already exist keep their current grants.

  How to test: List the grants with the query below and compare each line with what your app needs. Revoke what's extra, for example `revoke insert, update, delete on table public.<table> from anon;`, in the same migration as the table's RLS and policies.

  ```sql
  select table_name, grantee,
         string_agg(privilege_type, ', ' order by privilege_type) as privileges
  from information_schema.role_table_grants
  where table_schema = 'public' and grantee in ('anon', 'authenticated')
  group by table_name, grantee
  order by table_name, grantee;
  ```

  Source: [Supabase: Securing your API](https://supabase.com/docs/guides/api/securing-your-api#default-privileges)

- [ ] **A request carrying only your public key gets nothing private back from any table.**

  Why it matters: This is the view from outside: copy the URL and key from the page, then call the REST API directly. If a private table answers your curl with rows, it answers anyone's.

  How to test: Find the project URL and key in your own app (browser network tab or built bundle). Request each table that holds user data with the first command. An empty array `[]` or a permission error (code 42501) passes; rows are a finding. Then sign in as a second test user, copy their access token from the `Authorization` header in the network tab, and repeat with the second command to check that one user can't see another's rows.

  ```shell
  # As a signed-out visitor
  curl 'https://PROJECT_REF.supabase.co/rest/v1/profiles?select=*&limit=5' \
    -H "apikey: PUBLISHABLE_OR_ANON_KEY"

  # As signed-in test user B (their access token, not the key, goes in Authorization)
  curl 'https://PROJECT_REF.supabase.co/rest/v1/profiles?select=*&limit=5' \
    -H "apikey: PUBLISHABLE_OR_ANON_KEY" \
    -H "Authorization: Bearer USER_B_ACCESS_TOKEN"
  ```

  Source: [Supabase: Build an API route in less than 2 minutes](https://supabase.com/docs/guides/api/quickstart)

- [ ] **Security Advisor runs clean, or every remaining finding is a deliberate, written-down decision.**

  Why it matters: Advisors are deterministic checks that ship with the platform, and they catch most of what this checklist covers: tables without RLS (lint 0013), always-true policies (0024), policies that read user_metadata (0015), views that bypass RLS (0010), user data exposed through a view (0002), sensitive columns exposed (0023), public buckets that allow listing (0025), and SECURITY DEFINER functions callable by signed-out or signed-in users (0028, 0029).

  How to test: Open Security Advisor in Studio, or run `supabase db advisors` from the CLI. Treat ERROR-level findings as release blockers and re-run the advisor after every schema change. Supabase itself notes some findings may be intentional, so check each one against how your app is supposed to work before changing anything.

  ```shell
  supabase db advisors
  ```

  Source: [Supabase: Advisors](https://supabase.com/docs/guides/database/database-advisors)

## The Lovable-era exposure pattern

AI app builders made a powerful architecture mainstream: a React frontend that queries Supabase straight from the browser with the public key. Nothing sits between a visitor and the database except your policies. When a table ships without RLS, or with a policy written to make a feature work rather than to decide who may see it, anyone who copies the key from the page can read, and sometimes write, that table. In May 2025 the pattern got a CVE record for Lovable-generated sites, CVE-2025-48757: 'An insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites.' NVD lists a CVSS 3.1 base score of 9.3 (Critical) from MITRE. Lovable disputes the record, on the grounds that each customer of the platform accepts responsibility for protecting their application's data. Either way the fix lives in your project, and the checks below apply to any Supabase app whose frontend talks to the database directly, whichever tool wrote it.

- [ ] **No policy on private data is always true: no `using (true)` or `with check (true)` except on tables that are meant to be public.**

  Why it matters: An always-true policy makes RLS look switched on while letting the named role reach every row. Supabase spells it out: a policy for `anon` with `using (true)` grants every unauthenticated visitor read access to every row the role can reach through its grants. Security Advisor flags these as lint 0024.

  How to test: List every policy with the query below. Look for `qual` or `with_check` equal to `true`, insert and update policies with an empty `with_check`, and policies whose `roles` are `{public}` or `{anon}` on tables that hold private data.

  ```sql
  select tablename, policyname, cmd, roles, qual, with_check
  from pg_policies
  where schemaname in ('public', 'storage')
  order by tablename, cmd;
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#authenticated-and-unauthenticated-roles)

- [ ] **Every insert policy has a `with check`, and every update policy has both `using` and `with check`, each tied to the caller.**

  Why it matters: `with check` is what stops a user creating a row that belongs to someone else, and on update it stops them reassigning `user_id` to another account. Supabase also notes that an update needs a matching select policy to behave as expected.

  How to test: In the policy listing, confirm each INSERT and UPDATE row has a `with_check` that references `auth.uid()` (or your tenant check). Then, as test user B, insert a row carrying user A's `user_id`, and update one of B's own rows to set `user_id` to A. Both should fail with 42501.

  ```sql
  create policy "Users update own profile"
  on public.profiles for update
  to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#update-policies)

- [ ] **Users can't change the columns that grant power: `role`, `is_admin`, `plan`, `credits`, `verified` and the like.**

  Why it matters: An 'update your own profile' policy authorizes the row, not the column. If permission or billing data lives on a row its owner can update, the owner can upgrade themselves. Supabase recommends keeping roles in a dedicated table, and column-level privileges can limit which columns a role may update.

  How to test: For each table users can update, list the columns that affect permissions or billing. As an ordinary signed-in user, try the request below; it should fail or change nothing. Fix it by moving those columns to a table users can't write to, or with column grants: `revoke update on table public.profiles from authenticated; grant update (display_name, avatar_url) on table public.profiles to authenticated;`.

  ```shell
  curl -X PATCH 'https://PROJECT_REF.supabase.co/rest/v1/profiles?id=eq.YOUR_USER_ID' \
    -H "apikey: PUBLISHABLE_OR_ANON_KEY" \
    -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Prefer: return=representation" \
    -d '{"role":"admin"}'
  ```

  Source: [Supabase: Column Level Security](https://supabase.com/docs/guides/database/postgres/column-level-security#privileges-at-the-column-level)

- [ ] **If users sign in through a different auth provider, Supabase is set up to trust that provider's tokens. RLS is never switched off, and the secret key is never used, just to make queries return data.**

  Why it matters: When users sign in somewhere Supabase doesn't know about, the database never receives a user token, so policies that check the user match nothing and every query comes back empty. The tempting fixes, disabling RLS or querying with the secret key, make the data public. Supabase supports Clerk, Firebase Auth, Auth0, AWS Cognito and WorkOS as third-party auth providers, so RLS can see the signed-in user.

  How to test: Check whether the app signs users in with a non-Supabase library. If it does, confirm a Third-Party Auth integration exists in the Dashboard, that RLS is still enabled on user tables (first check), and that no client code creates a Supabase client with a secret or service_role key (see the key check below).

  Source: [Supabase: Third-party auth](https://supabase.com/docs/guides/auth/third-party/overview#how-does-it-work)

- [ ] **Policies don't treat 'signed in' as 'trusted'. Sign-up, email confirmation and anonymous sign-ins are configured on purpose.**

  Why it matters: While 'Allow new users to sign up' is on, anyone can create an account with your public key, so `to authenticated using (true)` is close to public. Anonymous users also get the `authenticated` role; Supabase says to check the `is_anonymous` claim, using restrictive policies so the check always applies.

  How to test: Review the Auth settings for Allow new users to sign up, Confirm email and Allow anonymous sign-ins. Search your policies for `to authenticated` paired with `using (true)` on private tables. If anonymous sign-ins are on, add a restrictive policy to writes that need a real account, like the one below.

  ```sql
  create policy "Only permanent users can post"
  on public.posts as restrictive for insert
  to authenticated
  with check ( (select (auth.jwt()->>'is_anonymous')::boolean) is false );
  ```

  Source: [Supabase: Anonymous Sign-Ins](https://supabase.com/docs/guides/auth/auth-anonymous#access-control)

## Policies that say who, not just whether

- [ ] **Every policy names its role with `to`, and user checks don't depend on `auth.uid()` quietly failing for signed-out visitors.**

  Why it matters: Supabase recommends always naming the role, so a policy stops at `to authenticated` for anonymous requests. `auth.uid()` returns null when nobody is signed in, and `null = user_id` is false, which denies access by accident rather than by design. Explicit policies are also easier for the next person, or the next AI prompt, to edit safely.

  How to test: In the policy listing, flag rows whose `roles` are `{public}`. Rewrite them in the form below.

  ```sql
  create policy "Users read own todos"
  on public.todos for select
  to authenticated
  using ( (select auth.uid()) = user_id );
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#specify-roles-in-your-policies)

- [ ] **No policy authorizes on `user_metadata`. Authorization data lives in `app_metadata` or in your own tables.**

  Why it matters: Supabase: `raw_user_meta_data` can be updated by the signed-in user, so it isn't a good place to store authorization data, while `raw_app_meta_data` can't be updated by the user. A policy that trusts `user_metadata ->> 'role'` lets users hand themselves the role. Security Advisor flags it as lint 0015.

  How to test: Search your policies for `user_metadata` with the query below, and search your SQL functions and migrations for the same string.

  ```sql
  select tablename, policyname
  from pg_policies
  where qual ilike '%user_metadata%' or with_check ilike '%user_metadata%';
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#authjwt)

## Side doors: views, functions and schema visibility

- [ ] **Views in exposed schemas use `security_invoker = true` (Postgres 15 and later), and no exposed view reads from `auth.users`.**

  Why it matters: Views bypass RLS by default because they run with their creator's rights, so a view over a protected table hands out every row its policies were meant to hold back. A public view over `auth.users` exposes your users' personal data (lint 0002), and views that bypass RLS are lint 0010. Materialized views can't apply RLS at all, so keep them out of exposed schemas (lint 0016).

  How to test: List the views and materialized views in `public` with the query below. Any plain view without `security_invoker=true` in its options that reads protected tables is a finding: `alter view public.<view> set (security_invoker = true);`.

  ```sql
  select c.relname as view_name, c.relkind, c.reloptions
  from pg_class c
  join pg_namespace n on n.oid = c.relnamespace
  where n.nspname = 'public' and c.relkind in ('v', 'm');
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#views-and-rls)

- [ ] **No SECURITY DEFINER function in an exposed schema can be run by `anon` or `authenticated` unless that's intended, and each one pins `search_path = ''`.**

  Why it matters: RLS doesn't apply to functions. A SECURITY DEFINER function runs with its owner's privileges, and on Supabase the owner is usually `postgres`, which bypasses RLS. Any function in an exposed schema that a role can execute is callable at `/rest/v1/rpc/<name>`. Supabase's advice is never to create one in an exposed schema, and to pin `search_path` on every one.

  How to test: Run the query below. Every `true` under `anon_can_execute` or `auth_can_execute` needs a decision. To close one, revoke execute (`revoke execute on function public.<fn>(<args>) from anon, authenticated, public;`), move the function to a schema the API doesn't expose, or switch it to SECURITY INVOKER. Advisor lints 0028 and 0029 flag these too.

  ```sql
  select p.proname as function_name,
         pg_get_function_identity_arguments(p.oid) as args,
         has_function_privilege('anon', p.oid, 'execute') as anon_can_execute,
         has_function_privilege('authenticated', p.oid, 'execute') as auth_can_execute,
         p.proconfig as settings
  from pg_proc p
  join pg_namespace n on n.oid = p.pronamespace
  where n.nspname = 'public' and p.prosecdef;
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions)

- [ ] **Your table and column names aren't advertised to signed-out visitors: the REST API's schema description refuses the public key, GraphQL is off if you don't use it, and introspection is off if you don't need it.**

  Why it matters: A list of tables and columns takes the guesswork out of an attack. Supabase has been closing this by default: under its published schedule, the OpenAPI spec at `/rest/v1/` has answered only secret (and legacy service_role) keys since March 11, 2026 for new projects and April 8, 2026 for existing ones. pg_graphql is no longer enabled automatically on new projects, and pg_graphql 1.6.0 turns introspection off by default for projects created from June 29, 2026. Older projects keep their GraphQL behaviour until they change it, and pg_graphql shows any table a role can select from, whatever your RLS says (lints 0026 and 0027).

  How to test: Request the OpenAPI spec with only the public key (first command). It should return an 'Access to schema is forbidden' error, not a description of your tables. Then check Database > Extensions for pg_graphql and disable it if your app doesn't use GraphQL. If it does, run the second command with only the public key. On a project that doesn't need introspection, it should return an error, not a list of your tables.

  ```shell
  # REST schema: expect "Access to schema is forbidden"
  curl 'https://PROJECT_REF.supabase.co/rest/v1/' \
    -H "apikey: PUBLISHABLE_OR_ANON_KEY"

  # GraphQL introspection: expect an error, not your type names
  curl -X POST 'https://PROJECT_REF.supabase.co/graphql/v1' \
    -H "apikey: PUBLISHABLE_OR_ANON_KEY" \
    -H 'Content-Type: application/json' \
    --data-raw '{"query":"{ __schema { types { name } } }"}'
  ```

  Source: [Supabase changelog: Breaking change in pg_graphql 1.6.0, GraphQL introspection disabled by default](https://supabase.com/changelog/46320-breaking-change-in-pg-graphql-1-6-0-graphql-introspection-disabled-by-default)

## Keys and server code

- [ ] **No secret key (`sb_secret_...`) or legacy `service_role` key appears in client code, the built bundle, a mobile app or the repository.**

  Why it matters: A secret key bypasses every Row Level Security policy, so a leaked one exposes all of your project's data. New secret keys refuse browser requests (Supabase checks the User-Agent and returns 401), but an attacker can still use a leaked key from any other tool. Legacy keys are long JWTs starting with `eyJ`, and the `role` inside tells you which one you're looking at.

  How to test: Search your build output and repository with the first command, and check git history too. For any `eyJ...` key you find, decode its middle segment in the browser console with the second snippet: `anon` is expected, `service_role` is a critical finding. If a secret key ever shipped, delete or rotate it in Settings > API Keys immediately.

  ```shell
  grep -rnE "sb_secret_|service_role" dist/ build/ .next/static/ src/ 2>/dev/null

  // Browser console: read the role inside a legacy JWT key
  JSON.parse(atob('PASTE_MIDDLE_SEGMENT'.replace(/-/g, '+').replace(/_/g, '/'))).role
  ```

  Source: [Supabase: API keys](https://supabase.com/docs/guides/api/api-keys#secret-keys-and-elevated-access)

- [ ] **Server code that holds the secret key scopes every query to the caller.**

  Why it matters: Code using the secret key runs as `service_role`, which bypasses RLS, so any query it makes for a user has to filter by that user itself. Supabase's Edge Functions guide warns that a handler which queries a shared table with the admin client, without filtering by the caller's ID, returns every user's rows.

  How to test: Find every place a secret-key client is created (search for `SUPABASE_SECRET_KEY`, `SERVICE_ROLE` and `supabaseAdmin`). For each query, confirm the user ID comes from the verified token, never from the request body. Where the work is on the caller's behalf, prefer a user-scoped client so RLS applies.

  Source: [Supabase: Securing Edge Functions](https://supabase.com/docs/guides/functions/auth#authenticated-user-calls)

- [ ] **Every Edge Function authenticates its callers. User-facing functions keep `verify_jwt` on, and any function with it off checks a signature, a secret key or the user in its own code.**

  Why it matters: `verify_jwt` is on by default and rejects requests without a valid user JWT. Turning it off to clear a 401 during development removes that check. With the new API keys, the platform check also accepts publishable and secret keys, and Supabase says the check alone doesn't authenticate a caller that sends only an API key, so authorization belongs in your code.

  How to test: Search `supabase/config.toml` for `verify_jwt = false` and review each function it names. Call every function with no credentials using the second command; anything other than a 401 or a deliberately public response is a finding. Then call it with only the publishable key in the `apikey` header, as the third command does. `verify_jwt` lets that request through, so unless the function is deliberately public, your handler has to answer 401 or 403 itself. Webhook endpoints should reject an unsigned request.

  ```shell
  grep -n -B1 "verify_jwt = false" supabase/config.toml

  # No credentials: expect 401
  curl -i -X POST 'https://PROJECT_REF.supabase.co/functions/v1/FUNCTION_NAME' \
    -H 'Content-Type: application/json' -d '{}'

  # Publishable key only: passes verify_jwt, so your handler must reject it
  curl -i -X POST 'https://PROJECT_REF.supabase.co/functions/v1/FUNCTION_NAME' \
    -H "apikey: PUBLISHABLE_KEY" \
    -H 'Content-Type: application/json' -d '{}'
  ```

  Source: [Supabase: Authorization headers (Edge Functions)](https://supabase.com/docs/guides/functions/auth-headers#the-verifyjwt-platform-check)

## Storage and Realtime

- [ ] **Buckets that hold user files are private, and public buckets have no broad SELECT policy that lets clients list their contents.**

  Why it matters: A public bucket skips access control for reading: anyone with a file's URL can fetch it. That suits avatars and blog images, not IDs, invoices or exports. A broad SELECT policy on a public bucket also lets clients enumerate every file name in it (lint 0025).

  How to test: Run the query below and confirm every public bucket only holds files you'd be happy to see on your homepage. Then review `storage` rows in the policy listing from the previous section.

  ```sql
  select id, public from storage.buckets order by public desc, id;
  ```

  Source: [Supabase: Storage Buckets](https://supabase.com/docs/guides/storage/buckets/fundamentals#public-buckets)

- [ ] **Policies on `storage.objects` are scoped to a bucket and to the user's own folder.**

  Why it matters: Storage permissions are RLS policies on `storage.objects`. A policy that only checks `bucket_id` hands every signed-in user every file in that bucket. Supabase's pattern ties the first folder in the path to the user: `(storage.foldername(name))[1] = (select auth.jwt()->>'sub')`.

  How to test: Sign in as test user B and try to list and download user A's folder with the snippet below. Expect an empty list and an error.

  ```javascript
  const { data: files } = await supabase.storage.from('documents').list('USER_A_ID')
  // expect []
  const { error } = await supabase.storage.from('documents').download('USER_A_ID/statement.pdf')
  // expect an error
  ```

  Source: [Supabase: Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control#policy-examples)

- [ ] **Realtime Broadcast and Presence use private channels backed by RLS on `realtime.messages`, with 'Allow public access' turned off.**

  Why it matters: Supabase only enforces private channels once the public access setting is disabled. A private channel's access policies are checked when a client joins and then cached for the connection, so a user whose access you revoke keeps receiving Broadcast and Presence messages until their token expires or a new one is sent; Supabase advises keeping the JWT expiry short. Postgres Changes is checked separately: it sends a row only to clients whose RLS policies on that table allow them to read it.

  How to test: Check Realtime Settings for 'Allow public access'. Confirm clients join with `{ config: { private: true } }` and that the policy listing includes policies on `realtime.messages`. Then join a private topic as a user who shouldn't have access and confirm you receive nothing.

  ```javascript
  const channel = supabase.channel('room:123', { config: { private: true } })
  ```

  Source: [Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization)

## Keep it closed

- [ ] **Every table with RLS has a pgTAP test that proves allow and deny for `anon` and `authenticated`, run with `supabase test db` in CI.**

  Why it matters: In Supabase's words, until the suite passes you don't know whether the policies do what you intended. A denial by a `using` clause raises no error, it just matches zero rows, so tests need `returning` and a follow-up read to prove a denied write left the row intact.

  How to test: Create a test file per table with `supabase test new <table>_rls.test`, switch identity with `set local role` and `set local request.jwt.claim.sub`, and run `supabase test db` locally and in CI. Studio's user impersonation and the RLS tester preview are handy for quick manual checks, but they don't replace the suite.

  ```sql
  -- supabase/tests/profiles_rls.test.sql
  begin;
  select plan(2);

  insert into auth.users (id, email) values
    ('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
    ('22222222-2222-2222-2222-222222222222', 'other@example.com');

  -- Seed the owner's row while still running as postgres, so 'reads none' proves something
  insert into profiles (id, user_id, avatar_url)
  values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'owner.png');

  set local role authenticated;
  set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';

  select is_empty(
    $$ select * from profiles where user_id = '11111111-1111-1111-1111-111111111111' $$,
    'another user reads none of the owner''s profile'
  );
  select throws_ok(
    $$ insert into profiles (id, user_id, avatar_url)
       values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'x.png') $$,
    '42501', null,
    'another user cannot create a profile for the owner'
  );

  select * from finish();
  rollback;
  ```

  Source: [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security#policy-tests)

- [ ] **Grants, `enable row level security` and policies ship together in versioned migrations, and your AI coding tool is told that's the rule.**

  Why it matters: Supabase recommends bundling grants with the RLS setup in the same migration: grants control which roles reach a table, RLS and policies control which rows. For AI tools that create tables, Supabase suggests updating the tool's system prompt or adopting its agent skill, which includes the grants step. A policy that only exists in the Dashboard isn't in code review, isn't in your other environments and isn't covered by tests.

  How to test: Confirm `supabase/migrations/` contains the RLS, grant and policy statements for every table. Run the policy listing query on staging and production and compare the two.

  ```sql
  -- One migration: RLS, grants and policies together
  alter table public.invoices enable row level security;
  revoke all on table public.invoices from anon, authenticated;
  grant select, insert, update on table public.invoices to authenticated;

  create policy "Owners read invoices"
  on public.invoices for select
  to authenticated
  using ( (select auth.uid()) = user_id );
  ```

  Source: [Supabase: Securing your API](https://supabase.com/docs/guides/api/securing-your-api#grant-access-explicitly)

- [ ] **The rest of Supabase's production security checklist is done: SSL enforcement, network restrictions, MFA on your Supabase account, email confirmations, and CAPTCHA on auth endpoints.**

  Why it matters: RLS protects data from your app's users. These settings protect the database connection and your own account, and slow down automated sign-up abuse.

  How to test: Work through the Security section of Supabase's Production Checklist: SSL enforcement and network restrictions under Database settings, MFA for your Supabase account and organization, email confirmations in the Auth provider settings, and Auth CAPTCHA.

  Source: [Supabase: Production Checklist](https://supabase.com/docs/guides/deployment/going-into-prod#security)

## Frequently asked questions

### Is it safe for my Supabase key to be in my frontend code?

The publishable key (or the legacy anon key) is designed to be public: Supabase says anyone can read it, so it only reaches what Row Level Security allows. The secret key (or legacy service_role key) is the opposite, because it bypasses RLS and must never leave your servers. Supabase is deprecating the legacy anon and service_role keys by the end of 2026, so this is also a good moment to move to publishable and secret keys.

### What is CVE-2025-48757, and does it affect my app?

It's a CVE record published in May 2025 describing insufficient Row-Level Security in Lovable-generated sites through 2025-04-15, which let unauthenticated visitors read or write database tables. Lovable disputes it, saying each customer is responsible for protecting their own app's data. Whether or not your app was built in Lovable, if your frontend talks to Supabase directly, the first two sections of this checklist are where to start: they test for exactly that exposure.

### Supabase changed its defaults in 2026. Am I already covered?

Partly. New tables in new projects are no longer exposed to the Data API automatically (rolling out to new projects since May 30, 2026), and the same change is scheduled for existing projects on October 30, 2026, but tables that already exist keep their current grants. Under Supabase's schedule, the OpenAPI schema endpoint has answered only secret keys since April 8, 2026, and pg_graphql is now opt-in on new projects. None of this changes how RLS behaves, so an existing table with RLS off or an always-true policy is exactly as open as it was.

### Lovable already runs a security scan when I publish. Isn't that enough?

It's a good first line. Lovable's Quick scan runs on every publish and flags things like tables without row-level security and access rules that let everyone through, and its Deep scan reviews application code. Lovable's own documentation also says these tools don't replace a thorough security review, and suggests an additional professional review for apps that handle sensitive data or critical functions.

### Why does my query return an empty array instead of an error?

Because that's how a policy says no. A missing grant raises a permission error (42501) before any policy runs, but a policy that matches no rows just returns an empty result. If the app shows no data after you enable RLS, check grants first, then policies, and resist switching RLS off or reaching for the secret key to make the screen load.

### I only have an hour. What should I do first?

Run the RLS-enabled query, the outside-in curl test and the always-true policy query, then search your bundle for a secret key. Those four checks cover the exposure behind CVE-2025-48757 and the mistakes that need no skill to find.

## Want proof your policies hold, not just a hunch?

Start with our free surface check: we look at what your published app exposes to an ordinary visitor and send you the findings whether or not you hire us. If you want the whole project read, our AI-generated code audit covers tables and policies, functions and their authentication, storage, and key exposure, with a reproduction for every finding, and most audits run one to two weeks. Ready to take a Lovable or AI-built app all the way to production instead? Every migration starts with a two-week strategy sprint for a flat $4,000: a full features table, wireframes for your core flows and the scope of your first release, refunded in full if we don't move forward together. Builds are then quoted from the sprint's scope.

- [Book a code audit](https://www.dreamlabs.pro/services/ai-generated-code-audit)
- [See the strategy sprint](https://www.dreamlabs.pro/offerings)

## Sources

1. [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
2. [Supabase: Securing your API](https://supabase.com/docs/guides/api/securing-your-api): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
3. [Supabase changelog: Breaking Change: Tables not exposed to Data and GraphQL API automatically (Apr 28, 2026)](https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically): Supabase (docs), Published 2026-04-28, accessed 2026-10-07
4. [Supabase: Build an API route in less than 2 minutes](https://supabase.com/docs/guides/api/quickstart): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
5. [Supabase: Advisors](https://supabase.com/docs/guides/database/database-advisors): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
6. [Supabase splinter (official lint definitions): 0024 permissive_rls_policy, 0002 auth_users_exposed, 0028 anon_security_definer_function_executable, 0026 pg_graphql_anon_table_exposed](https://github.com/supabase/splinter/tree/main/docs): Supabase (GitHub, official repo), Fetched from main branch on access date, accessed 2026-10-07
7. [NVD: CVE-2025-48757](https://nvd.nist.gov/vuln/detail/CVE-2025-48757): NIST National Vulnerability Database, Last modified 2026-06-17, accessed 2026-10-07
8. [CVE.org: CVE-2025-48757](https://www.cve.org/CVERecord?id=CVE-2025-48757): CVE Program, Record updated 2025-08-21, accessed 2026-10-07
9. [Lovable: Security overview](https://docs.lovable.dev/features/security): Lovable (vendor docs), Undated docs page, current as of access date, accessed 2026-10-07
10. [Supabase: Column Level Security](https://supabase.com/docs/guides/database/postgres/column-level-security): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
11. [Supabase: Third-party auth](https://supabase.com/docs/guides/auth/third-party/overview): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
12. [Supabase: Anonymous Sign-Ins](https://supabase.com/docs/guides/auth/auth-anonymous): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
13. [Supabase: General configuration (Auth)](https://supabase.com/docs/guides/auth/general-configuration): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
14. [Supabase changelog: Breaking Change: Removing access to OpenAPI spec via the anon key (Feb 17, 2026)](https://supabase.com/changelog/42949-breaking-change-removing-access-to-openapi-spec-via-the-anon-key): Supabase (docs), Published 2026-02-17, accessed 2026-10-07
15. [Supabase changelog: Breaking Change: pg_graphql no longer enabled automatically (Jan 26, 2026)](https://supabase.com/changelog/42180-breaking-change-pg-graphql-no-longer-enabled-automatically-within-approx-3-weeks-from-today): Supabase (docs), Published 2026-01-26, accessed 2026-10-07
16. [Supabase changelog: Breaking change in pg_graphql 1.6.0, GraphQL introspection disabled by default](https://supabase.com/changelog/46320-breaking-change-in-pg-graphql-1-6-0-graphql-introspection-disabled-by-default): Supabase (docs), Published 2026-05-25, edited 2026-06-13, accessed 2026-10-07
17. [Supabase: API keys](https://supabase.com/docs/guides/api/api-keys): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
18. [Supabase: Securing Edge Functions](https://supabase.com/docs/guides/functions/auth): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
19. [Supabase: Authorization headers (Edge Functions)](https://supabase.com/docs/guides/functions/auth-headers): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
20. [Supabase: Storage Buckets](https://supabase.com/docs/guides/storage/buckets/fundamentals): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
21. [Supabase: Storage Access Control](https://supabase.com/docs/guides/storage/security/access-control): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
22. [Supabase: Realtime Authorization](https://supabase.com/docs/guides/realtime/authorization): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
23. [Supabase: Testing Your Database](https://supabase.com/docs/guides/database/testing): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
24. [Supabase changelog: Feature Preview: RLS Tester (Apr 24, 2026)](https://supabase.com/changelog/45233-feature-preview-rls-tester): Supabase (docs), Published 2026-04-24, accessed 2026-10-07
25. [Supabase: Production Checklist](https://supabase.com/docs/guides/deployment/going-into-prod): Supabase (docs), Undated docs page, current as of access date, accessed 2026-10-07
26. [PostgreSQL: pg_policies view](https://www.postgresql.org/docs/current/view-pg-policies.html): PostgreSQL Global Development Group, Current docs, accessed 2026-10-07
27. [PostgreSQL: pg_tables view](https://www.postgresql.org/docs/current/view-pg-tables.html): PostgreSQL Global Development Group, Current docs, accessed 2026-10-07

## Links

- Full page: https://www.dreamlabs.pro/services/supabase-rls-checklist
- Code audit: https://www.dreamlabs.pro/services/ai-generated-code-audit
- Offerings & pricing: https://www.dreamlabs.pro/offerings
- Contact: https://www.dreamlabs.pro/contact
