Security Education

8 min

Supabase security: everything beyond RLS that still needs your attention

Supabase security: everything beyond RLS that still needs your attention

RLS is just one layer. This covers the rest of the Supabase security surface: service_role key exposure, storage bucket permissions, auth configuration, and database function access.

RLS is just one layer. This covers the rest of the Supabase security surface: service_role key exposure, storage bucket permissions, auth configuration, and database function access.

The post I wrote on RLS misconfiguration covers what is consistently the most critical finding in Supabase based apps, and if you haven’t read it, that’s the right place to start. That said, RLS is one layer of a larger picture, and there are several other areas in a Supabase setup that show up as findings in my audits with enough regularity that they’re worth covering properly in their own right.

The anon key vs the service_role key

This distinction trips up a lot of founders and is worth being completely clear on before anything else. Supabase gives every project two primary keys. The anon key (or publishable key in the newer key format Supabase rolled out in 2025) is designed to be public. It’s intended to appear in your frontend code. The entire security model is built around the assumption that it will be exposed, which is why RLS exists as the control layer on top of it.

The service_role key is an entirely different matter. It bypasses RLS completely, on every table, regardless of whatever policies you’ve configured. There is no policy that stops it. If that key is in your frontend bundle, RLS is irrelevant, because anything with the service_role key ignores it entirely.

The common leak paths for the service_role key are worth knowing: pasted into a .env.local file that then ships in the Next.js bundle via a NEXT_PUBLIC_ prefix (a genuinely easy mistake to make), committed to a public repository during development and never removed, logged at server boot in an Edge Function, or returned in an error response. Any of these routes exposes a key that gives whoever holds it unrestricted admin access to the entire database.

If you suspect the service_role key has been exposed at any point, rotate it immediately in the Supabase dashboard and then audit your query logs for requests that wouldn’t have come from your own application.


Storage bucket permissions

Supabase Storage is one of the more consistently overlooked areas in the apps I audit, probably because most security guidance focuses on database tables and doesn’t go into what sits alongside them. Storage buckets have their own access control layer, separate from database RLS, and the default behavior is worth understanding properly.

A public bucket means anyone can read every object in it. No authentication required, no policies checked, just an open URL. This is appropriate for genuinely public assets like marketing images, but it becomes a problem when founders set buckets to public for convenience during development and never change it before launch. User profile photos, uploaded documents, private files of any kind, if the bucket is public those are readable by anyone who knows or can guess the path.

Private buckets with no policies are the other failure mode. By default, Storage does not allow any uploads without RLS policies configured on the storage.objects table. A bucket marked private with no policies denies everything, which sounds safe but often means the storage simply doesn’t work as intended and the founder hasn’t noticed yet.

The check here is straightforward: try accessing a storage URL without any authentication. If you get the file back, the bucket is public. For any bucket that shouldn’t be, configure RLS policies on storage.objects scoped to authenticated users and ownership, the same principle as your database tables.


RLS policies that look right but aren’t

Enabling RLS is the first step. The policy you write on top of it is where things can still go wrong, and this is worth its own mention because a misconfigured policy is significantly harder to spot than a missing one.

The most common mistake is a policy written as USING (true), which grants access to every row for every user. It looks like a real policy because the syntax is correct and RLS shows as enabled on the table, but functionally it provides no protection at all. It’s roughly equivalent to enabling RLS and then immediately disabling it again.

The correct pattern for a user-scoped table is USING (auth.uid() = user_id), which restricts each authenticated user to rows that belong to them. Worth noting: Supabase recommends writing this as USING ((select auth.uid()) = user_id) rather than USING (auth.uid() = user_id) directly, because the select wrapper evaluates the function once per query rather than once per row, which has meaningful performance implications on larger tables.

The Security Advisor in your Supabase dashboard (under Database) runs automated checks for both tables with RLS disabled and overly permissive policies. Worth running before launch and periodically after.


Database functions called without authentication

Supabase allows you to create database functions that can be called via RPC from the frontend. What isn’t immediately obvious is that these functions, by default, run with elevated privileges and can bypass RLS entirely depending on how they’re defined. A function set to SECURITY DEFINER runs with the permissions of the function owner rather than the calling user, which means it can access data that the calling user’s RLS policies would otherwise block.

The fix is to add an authentication check at the start of any function that touches sensitive data: if auth.uid() is null, raise an exception before doing anything else. This is a simple addition that prevents unauthenticated callers from invoking the function at all, regardless of how it’s configured.

AI-generated Supabase code has a fairly consistent habit of creating RPC functions without these checks in place, so it’s worth auditing any functions in your setup that were generated rather than hand-written.


Auth configuration

Supabase Auth ships with a handful of settings that are worth checking rather than assuming. Email confirmation is disabled by default on new projects, which means users can sign up and immediately access your application without verifying they own the email address they registered with. For most production apps that’s worth enabling, particularly if email is how you identify users across your RLS policies.

Rate limiting on auth endpoints is something Supabase handles at the platform level, but it’s worth verifying your configuration hasn’t inadvertently overridden it. The test is straightforward: spam your login flow with incorrect credentials and observe whether anything stops you after repeated attempts. If nothing does, that’s worth investigating in your auth settings.

Also worth checking: the password reset flow. If it returns a distinct error message for email addresses that don’t exist versus ones that do, that’s email enumeration, and it allows an attacker to confirm whether a specific email is registered on your platform before targeting it. The correct behavior is a generic response regardless of whether the address exists.


The new API key format and what it means for your setup

In 2025 Supabase introduced a new API key format: sb_publishable_xxx replaces the anon key and sb_secret_xxx replaces the service_role key. The naming convention is deliberately clearer about intent, publishable keys are safe to expose, secret keys are not. Legacy keys remain available during migration but are scheduled to be removed in late 2026.

If your project was created before this change, you’re still on the old key format. The underlying security model is the same, but migrating to the new format is worth doing for the cleaner key management workflow and automatic revocation support. Supabase now automatically revokes secret keys detected in public GitHub repositories and notifies the project owner, which is a genuinely useful safeguard.

That said, automatic revocation on GitHub detection is a last line of defense, not a security posture. The key should never reach a public repository in the first place.


All of that being said, if you want a clearer perspective on how your Supabase setup holds up across all of these areas, a HollowByte pre-launch (or post-launch) audit covers all of the above (and then some), verified through direct testing rather than a configuration review alone.

Back to blog

(function() { function applyMainRole() { var hero = document.getElementById('hero'); if (!hero) return false; var node = hero; while (node.parentElement && node.parentElement !== document.body) { node = node.parentElement; } if (node && node.parentElement === document.body) { node.setAttribute('role', 'main'); return true; } return false; } if (applyMainRole()) return; var attempts = 0; var interval = setInterval(function() { attempts++; if (applyMainRole() || attempts > 20) { clearInterval(interval); } }, 250); })();