One Pasword to Leak them All: Supabase’s Shared Database Credentials - 2
If you are concerned about data sovereignty and love Supabase, you may consider self-hosting. However there are insecure defaults you need to be aware of. This blog post covers one of them.
Self-hosted Supabase ships with one database password shared by every service, and the edge functions log in as the database superuser. That means a single leaked password or one buggy function can hand an attacker the entire database. We removed the superuser access, rotated the password, and have a clear plan for the rest.
The Problem
Think of your database like an apartment building with many doors: one for the login service, one for file storage, one for the API layer, one for edge functions, and so on. Good security gives each tenant a key that opens only their own door.
Supabase's self-hosted defaults do the opposite, in two ways:
Everyone shares one key
Every database account (the API role, the auth service, file storage, the connection pooler, edge functions) is set to the same password and that is the POSTGRES_PASSWORD. So if any one service leaks its password, that single value unlocks all the others. There's no containment: one leak = the whole database tier. You also can't change one service's password without changing everyone's. If you have Row Level Security (RLS) set on a table your edge functions as superuser can bypass those rules.
Edge functions log in as admin
By default, edge functions (your custom server-side code) connected to the database as postgres AKA the superuser. A superuser can do anything: read every table, ignore all access rules (Row-Level Security), drop tables, even change other accounts' permissions.
The danger here is that edge functions run your code and its third-party dependencies. If a single npm/Deno package you import turns malicious or is compromised, it inherits that god-mode database access.
Why it Matters
I will assume that experienced security practitioners already have a sense of “cringe”. But for everyone else lets talk about why this is important to resolve.
Security is often about Blast Radius. When something goes wrong, how much can the attacker reach? With these defaults, the blast radius is "everything." One small foothold becomes total compromise of the database.
We want to limit the bad effects when those “unknown unknowns” happen. Otherwise say goodbye to your data and hello to your data on someone else’s system.
What to do about it
Give edge functions a limited database account and not the superuser. It should be able to log in and nothing more, so Row-Level Security still applies and it can't drop tables or change permissions.
Give each service its own distinct password, so a leak from one is contained to that one account and can be rotated independently.
Rotate the shared password so any previously-exposed value is dead.
Additional Surprises
While fixing this, we learned two non-obvious things about self-hosted Supabase that trip people up:
The postgres user was not the real superuser the supabase_admin is. Admin-level changes (like altering certain roles or extensions) silently do nothing if you run them as postgres; you have to run them as supabase_admin. The supabase/postgres:15.8.1.085 image on our container deployment sets postgres as a non-superuser (this image historically restricts it), even though other setups/versions don't.
Database init scripts only run on a brand-new database. Because the data lives on a persistent disk that survives restarts and rebuilds, changes to the setup SQL never reach an existing database. You must apply them manually as a one-off migration.
Finally
These are upstream defaults, so they affect anyone self-hosting the open-source Supabase stack, not just us. If you're standing up self-hosted Supabase, two changes are worth making on day one: give edge functions a least-privilege database role instead of the superuser, and don't let every database account share a single password.