Supabase Security Checklist for AI-Built Apps


Abstract geometric graphic in Secvura navy and teal.

Supabase is a good product. It is also, by design, a Postgres database exposed directly to the public internet. Your browser talks to that database over a REST API using a key that ships inside your JavaScript bundle. Anyone can read that key. It is meant to be read. Which means the only thing standing between a stranger and your users table is Row Level Security, and if that is not configured correctly, there is nothing else behind it.

Why AI-built applications drift into the same problems

When an experienced backend developer starts on Supabase, they usually internalise this within a day. When an AI coding tool builds on Supabase, working from a prompt describing a feature, it optimises for the feature working. A signup flow that stores a row is a signup flow that works. Whether a different signed-in user can read that row is not a question the prompt asked, so it is not a question the tool answers.

This is the same pattern we have written about in what security gaps AI coding tools miss, applied to one specific stack. Below are six checks, in rough order of how much damage the underlying problem causes, each with a way to verify it yourself.

1. Row Level Security is off, or on with a policy that allows everything

There are three states here and only one of them is safe.

If Row Level Security is disabled on a table, anyone holding your anon key can read and write everything in it. If it is enabled with no policies, the table denies everything. That is safe in itself, but it usually means someone worked around the restriction elsewhere, which is check 2.

The third state is the dangerous one: enabled, with a permissive policy written to make the feature work.

-- A policy that grants access to everyone.
create policy "Enable read access for all users"
on public.profiles for select
using (true);

using (true) means "yes, to anyone". It is not an access rule. It is Row Level Security switched on so the dashboard stops warning about it.

Check it. Run this in the Supabase SQL editor:

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

Any table returning rowsecurity = false is readable by anyone with your anon key. Then read what the policies actually say:

select tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename;

Read the qual column on every row. If it says true, that table is public. If it references auth.uid() you are probably fine, but confirm it compares against the right column. A policy of using (auth.uid() is not null) means "any logged-in user", not "the owner of this row". In a multi-tenant application that is the difference between a working product and a data breach. Supabase's own Row Level Security documentation covers the policy syntax in full.

2. The service_role key has escaped to the client

Supabase issues two keys. The anon key is public and is constrained by Row Level Security. The service_role key bypasses Row Level Security entirely and is meant to live only on a server you control.

The failure is mundane. Something did not work because of a policy, and the quickest fix was to swap in the key that ignores policies. In a Next.js application that means prefixing the environment variable with NEXT_PUBLIC_, at which point it is compiled into a JavaScript file that anyone can download.

Check it. Build the application and search the output:

npm run build
grep -ri "service_role\|eyJ" .next/static/ 2>/dev/null | head

Supabase keys are JSON Web Tokens, so they begin with eyJ. You expect to find your anon key in there. You expect to find nothing else. Also review your deployment platform's environment variables for anything that is both secret and prefixed NEXT_PUBLIC_, VITE_, or PUBLIC_. That prefix is an instruction to publish the value.

If a service_role key has ever appeared in client code or a public repository, rotate it. Treat it as compromised, because the value remains in your Git history even after the line is deleted.

3. Tables added after launch, with Row Level Security never switched on

This is a process failure rather than a coding one, and it is why a clean review does not stay clean.

In week one, someone configures Row Level Security properly on the four tables that exist. In month three a new feature needs a support_tickets table, and it is created through the dashboard or a quick migration. Row Level Security defaults to off. Nobody notices, because the feature works.

Check it. Run the pg_tables query from check 1 on a schedule rather than once. It is a short continuous integration job: if a table appears in the public schema with rowsecurity = false, fail the build. Supabase now also flags tables with Row Level Security disabled in the Table Editor and emails project owners about them, so make sure those alerts reach someone who will act on them rather than an unmonitored inbox.

4. Storage buckets left public because public was easier

Supabase Storage buckets have their own access model, separate from table policies. A public bucket serves every object in it to anyone holding the URL, and object URLs are more guessable than people assume.

A common pattern: avatars need to be publicly readable, so the bucket is made public. Later the same bucket starts holding uploaded invoices, identity documents, or data exports, because it is the bucket that already works.

Check it.

select id, name, public from storage.buckets;

For every bucket returning public = true, ask what is in it today rather than what it was created for. Anything holding user-uploaded documents should be private and served through signed URLs, not public with obscure filenames.

5. SECURITY DEFINER functions that quietly bypass everything

Postgres functions declared SECURITY DEFINER run with the permissions of the user who created them, typically a superuser, which means they ignore Row Level Security. They are a legitimate tool. They are also a hole punched through your access control, and they are what an AI tool reaches for when a policy is blocking something.

-- Callable by anyone. Returns every row. Policies do not apply.
create function get_all_users()
returns setof profiles
language sql
security definer
as $$ select * from profiles $$;

Check it.

select p.proname, p.prosecdef, n.nspname
from pg_proc p
join pg_namespace n on p.pronamespace = n.oid
where n.nspname in ('public')
and p.prosecdef = true;

Every function returned needs a justification. If you cannot explain in one sentence why it has to bypass Row Level Security, it should not. These functions should also set an explicit search_path, or they are open to search path manipulation. The Security Advisor in the Supabase dashboard flags both problems automatically, and running it costs nothing.

6. Server code that trusts a user ID sent by the client

Once you have an API route or Edge Function using the service_role key, Row Level Security is no longer protecting you. Your own code is. The most common way that code fails is by believing what the client tells it.

// Broken. The client decides whose data is returned.
const { userId } = await req.json();
const { data } = await supabaseAdmin
  .from('invoices')
  .select('*')
  .eq('user_id', userId);

The user's identity is in the token, not the request body, and anyone can change the request body.

// Correct. The verified token decides.
const { data: { user } } = await supabase.auth.getUser(token);
if (!user) return new Response('Unauthorized', { status: 401 });

const { data } = await supabaseAdmin
  .from('invoices')
  .select('*')
  .eq('user_id', user.id);

Check it. For every server-side route, trace where the user identifier comes from. If it arrives from the client rather than from a verified token, that is broken access control, the category that sits at the top of the OWASP Top 10. Supabase's guidance on securing the Data API sets out how the grants and policies layer underneath this.

A thirty-minute version of all of this

If you do nothing else this week, do these six things in order.

  • Run the pg_tables query. Note every table returning rowsecurity = false.
  • Run the pg_policies query. Read every qual value and flag every true.
  • Search your build output for eyJ. Count the keys you find and confirm each one is the anon key.
  • List your storage buckets. Check what is actually stored in the public ones today.
  • List your SECURITY DEFINER functions. Justify each one in a sentence or remove it.
  • Take your three most sensitive API routes. Trace where the user identifier comes from in each.

Then test it rather than trusting the configuration. Take your anon key from the browser's network tab and try to read a table you should not be able to read:

curl "https://YOUR_PROJECT.supabase.co/rest/v1/profiles?select=*" \
  -H "apikey: YOUR_ANON_KEY"

An empty array is the answer you want. A list of your users is not.

Where a checklist stops being enough

Everything above is mechanical, and you should not need to pay anyone to do it. What a checklist cannot find is the logic flaw: the policy that was correct for the schema you had six months ago and is wrong for the one you have now, the workflow where two individually correct permissions combine into an escalation, the tenant boundary that holds everywhere except the one endpoint added in a hurry. Those findings come out of manual review and not out of a scanner report. If you are still in the early stages, our MVP security checklist covers the ground before this one.

Our approach at Secvura is built for startups and fast-moving teams. We carry out a thorough application security assessment to identify where your application is vulnerable, score every finding by severity and by how much effort it takes to fix, and then either give your developers clear remediation steps or implement the fixes ourselves.

If you would like a security review of your application, you can get in touch through our website.