Supabase Security Checklist for Production Apps
Guides · Updated 3 October 2026
Supabase gives every table in the public schema a REST API, and the key that calls it ships in your frontend. That is safe only if the database itself decides who can read and write each row. This checklist covers the settings that most often leave a project open, with SQL you can run to check them.
Turn on RLS for every public table
Row level security (RLS) is what stops the anon key from reading everything. A table in the public schema with RLS off can be read and written by anyone who has that key, as long as the anon or authenticated role has privileges on it. By default on existing projects, new tables get those privileges automatically.
Tables made in the dashboard Table Editor have RLS on by default. Tables made with raw SQL, migrations or an ORM do not. Run this in the SQL Editor to list the tables where it is off:
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT rowsecurity ORDER BY tablename;
Enable RLS on each table in the result, then add policies. With RLS on and no policies the API returns no rows, so write the policies in the same migration.
ALTER TABLE public.<table_name> ENABLE ROW LEVEL SECURITY;
Also look for tables that have policies while RLS is off. The policies are stored but never enforced.
Scope every policy to a role
A policy with no TO clause applies to every role, including anon. Write TO authenticated on policies meant for signed-in users, and TO anon only when you mean to serve visitors who are not signed in.
The other policy to look for is USING (true), which matches every row. It is fine on a SELECT policy for content that is meant to be public, such as blog posts. On INSERT, UPDATE or DELETE it lets the whole role write to the table.
SELECT tablename, policyname, cmd, roles, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
AND (roles = '{public}' OR qual = 'true' OR with_check = 'true')
ORDER BY tablename, policyname;Each row in the result is a policy that applies to all roles, allows every row, or both. Read each one and decide whether you meant it.
Keep the service role key out of the frontend
The anon key is public by design. The service role key is not. It bypasses RLS completely, so anyone who finds it has full access to your data. Newer projects call these the publishable key and the secret key, and the same rule applies.
- Never put it in an environment variable with a public prefix such as NEXT_PUBLIC_, VITE_, REACT_APP_ or EXPO_PUBLIC_.
- Use it only in server code: API routes, server components, Edge Functions.
- Search your repository and your built JavaScript bundle for SERVICE_ROLE.
- Check that no .env file is committed to git.
- If the key has ever been in client code or a public repository, rotate it in the dashboard.
Review SECURITY DEFINER functions
A SECURITY DEFINER function runs with the privileges of the role that created it, not the caller. If that role can bypass RLS, so can the function. Every function in the public schema can be called through the API at /rest/v1/rpc/<function_name>, so a definer function there can hand data to anyone who is allowed to execute it.
SELECT p.proname AS function_name
FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public'
AND p.prosecdef
AND has_function_privilege('anon', p.oid, 'EXECUTE');For each function listed, either move it to a schema that is not exposed through the API, or revoke execute from the roles that should not call it. Also give definer functions a fixed search_path, so they cannot be tricked into using objects from another schema.
REVOKE EXECUTE ON FUNCTION public.<function_name> FROM PUBLIC, anon; ALTER FUNCTION public.<function_name> SET search_path = public;
Make views respect RLS
Views bypass RLS by default, because they run with the privileges of the role that created them. A view over a protected table can expose every row of it. On Postgres 15 and above, set security_invoker so the view applies the policies of whoever is querying it.
ALTER VIEW public.<view_name> SET (security_invoker = true);
On older versions, revoke access to the view from anon and authenticated, or move it to a schema that is not exposed. Materialized views cannot have RLS policies at all, so keep them out of the public schema.
Check storage buckets
Storage has its own access rules. In a public bucket, anyone who has the URL of a file can read it without signing in. Uploads, deletes and moves still go through policies on storage.objects. In a private bucket those policies apply to downloads as well.
Keep a bucket public only if every file in it is meant to be public, such as avatars. For anything else, make the bucket private, write policies on storage.objects, and use signed URLs for temporary access.
Tighten auth settings
- Turn on email confirmation. With it off, anyone can sign up with a fake address and get the authenticated role at once, which satisfies any policy that only checks for a signed-in user.
- Set the minimum password length to at least 8 characters, and turn on leaked password protection if your plan includes it.
- List exact redirect URLs for production. A wildcard can send sign-in tokens to a domain you do not control.
- Turn off anonymous sign-ins if you do not use them. They create users with the authenticated role.
- Store roles and permissions in app_metadata, never in user_metadata. Users can edit their own user_metadata.
Repeat after every schema change
Most of these problems come back quietly. A new migration adds a table without RLS, or a quick fix adds a broad policy. Run the checks again after every schema change and before every launch. Locksoup runs these checks automatically with a read-only role.
Related checks
Check your own project
Locksoup runs these checks on your Supabase database in about a minute, with a read-only role, and gives you the SQL to fix what it finds.