Database security
Supabase security checklist for vibe-coded apps
Supabase security checklist for Lovable, Bolt, Cursor or Claude apps: RLS, table grants, publishable vs secret keys, storage, edge functions and tests.
Supabase lets the browser talk to the database with very little code. That design is only safe when Row Level Security is correct, each table is granted to the right roles, and privileged keys never reach the browser.
A working login screen proves authentication. It does not prove that one user cannot read or update another user's rows. Use this Supabase security checklist on a project you own, in the order below.
Enable RLS on every exposed table
Supabase says Row Level Security must be enabled on tables in an exposed schema such as public. Tables created in the dashboard get RLS by default. Tables created in raw SQL migrations, which is how most AI tools create them, need an explicit ALTER TABLE statement, so read the migrations as well as the dashboard.
Supabase also documents event triggers that enable RLS automatically on every new table, which catches the table an AI tool adds without telling you.
One table. Three different authority levels.
Anon / publishable key
Example table
No rows under this policy
Signed-in user A
Example table
Only permitted A rows
Secret / service role
Example table
Privileged access to all rows
Illustrative owner-only SELECT policy with table grants configured: an anonymous request reads no rows, signed-in user A reads only A rows, and a server-only secret/service-role request bypasses RLS. Actual results depend on grants, policies and the authorization context.
- Enable RLS before production data is written.
- Create separate policies for select, insert, update, and delete when their rules differ.
- Test with only the publishable key (no session), as user A, as user B in another tenant, and as a member against admin-only rows.
- Apply the same ownership model to storage objects and database functions.
Related:Scan migrations with the GitHub repo scannerLovable security scanner
Grant table access explicitly (required since October 30, 2026)
Supabase changed how new tables reach the Data API. New tables in the public schema are no longer exposed automatically. This became the default for new projects on May 30, 2026 and was applied to all existing projects on October 30, 2026. A request to a new table without a GRANT fails with error 42501, permission denied for table.
Put the grant, the RLS statement and the policies in the same migration, and grant only what each role needs. A table that only signed-in users read should not be granted to anon.
- grant select on public.your_table to anon; (only for public data)
- grant select, insert, update, delete on public.your_table to authenticated;
- alter table public.your_table enable row level security;
- Then create one policy per operation that needs it.
A policy must match the tenant model
A check such as user_id = auth.uid() works for rows owned directly by one user. Team SaaS products usually need membership joins, organization IDs, role checks, and rules for invitations or suspended accounts. Copying a single-user policy into a multi-tenant app can either expose data or block legitimate work.
Write tests that create two tenants and attempt every read and write across the boundary; the denied cases are the ones to keep in CI.
- Wrap auth.uid() as (select auth.uid()) in policies so Postgres evaluates it once per statement.
- UPDATE needs a matching SELECT policy, and the WITH CHECK clause decides what the updated row may look like, so a user cannot move a row to another tenant.
Related:SaaS launch security checklist
Keep privileged keys on the server
Supabase publishable keys (sb_publishable_…) and legacy anon keys are meant for public clients when RLS is correct. Secret keys (sb_secret_…) and legacy service_role keys bypass RLS and belong only in controlled backend code. Never give them a NEXT_PUBLIC_, VITE_, or REACT_APP_ prefix.
Supabase is deprecating the legacy anon and service_role keys by the end of 2026. Creating publishable and secret keys does not disable the legacy ones: they stay valid until you turn them off in Settings > API Keys. Replace the anon key in client code and the service_role key in backend code, Database Webhooks and CI, confirm nothing still uses the legacy keys, then deactivate them. If a service_role key was ever committed, the leak is only closed once that legacy key is deactivated.
Supabase rejects a secret key sent from a browser with 401. A leaked secret key still works from curl or a script, so rotate it anyway.
- Only the publishable or anon key appears in browser code.
- The secret or service_role key is read from a server-side environment variable.
- Legacy keys are deactivated once the new keys are live.
Review server routes and edge functions too
A server route or edge function that uses a privileged Supabase client is its own authorization boundary. Verify the signed-in user from the JWT, check the tenant and role, validate the input, and only then run the administrative query. A user ID or role sent in the request body proves nothing, and the secret key cannot make up for a missing check.
- Verify the request's access token on the server with supabase.auth.getClaims() (verified locally when the project uses asymmetric JWT signing keys) or getUser(), and take the user ID from the verified claims, never from the request body.
- For Edge Functions called with the new API keys, read them from SUPABASE_SECRET_KEYS or SUPABASE_PUBLISHABLE_KEYS, send them only in the apikey header, set verify_jwt = false and authorize the request in your own code.
- Return 401 for no session and 403 for the wrong tenant or role before touching data.
- Keep functions that need the secret key small, and log what they change.
Lock down storage buckets
Storage objects are rows in storage.objects and follow the same RLS model. A public bucket serves every file to anyone with the URL. For user uploads such as documents, invoices or avatars that should stay private, use a private bucket with policies that tie the object path to the owner: for example a policy on storage.objects with bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.jwt()->>'sub'), so each user can only write inside a folder named after their user ID.
- Mark a bucket public only when every file in it is meant to be public.
- Add select, insert, update and delete policies on storage.objects scoped to the bucket and owner.
- Use short-lived signed URLs to share private files.
Check database functions and views
Functions declared SECURITY DEFINER run with the privileges of their owner and can bypass RLS. Views in an exposed schema can also expose rows from tables that have RLS, depending on how they are created. Review both whenever an AI tool adds a migration.
- Prefer SECURITY INVOKER functions. When DEFINER is required, set search_path = '' and schema-qualify every name, and check auth.uid() inside.
- On Postgres 15 and later, create views with security_invoker = true so the caller's RLS applies; otherwise keep the view out of exposed schemas.
- Revoke execute on sensitive functions from public, anon and authenticated when they are not meant to be called over the Data API.
Test it like an attacker would
The publishable (or legacy anon) key and project URL are in your JavaScript, so assume anyone can call the Data API directly. Using a project you own, send requests with only that key, then as user A against user B's rows, and confirm the denied cases. Run Supabase's Security Advisor in the dashboard to catch tables without RLS and risky functions, and scan the repository so new migrations are checked before they ship.
From a terminal: curl 'https://YOUR_PROJECT.supabase.co/rest/v1/YOUR_TABLE?select=*' -H 'apikey: YOUR_PUBLISHABLE_KEY'. For a private table the result must be an empty array or a 42501 permission error. Repeat with a user's access token in an Authorization: Bearer header to test user A against user B's rows.
- Publishable-key request to a private table: an empty array or a 42501 permission error, never rows.
- User A updating user B's row: zero rows changed.
- Security Advisor shows no RLS-disabled tables in exposed schemas.
- Repository scan shows no migration that creates a table without enabling RLS and no Supabase secret or service_role key committed. The scan reads a bounded set of files and does not judge policy logic or grants.
Sources
Check your repository
See which of these issues are visible now.
ScanMySaaS reads a bounded set of security-relevant files from a public repository, or a private one on Starter and Pro through a read-only GitHub App. It never runs repository code or reveals matched secret values.
Scan a repository