I Built a Supabase App Without Thinking About Key Management, and I Got Lucky
Adil Sher
Author
Three months into production with a client project, I was debugging why our encrypted API tokens kept failing on refresh. I realized mid-coffee that our decryption key was sitting in a .env file that got committed to git once, and I'd been losing sleep over something I'd already done wrong. That's when I stumbled onto the Supabase security audit article that made me question everything I thought I understood about "encrypted at rest."
The premise hit different: if encryption is supposed to protect data when someone steals your database, why does it matter if they grab the decryption key at the same moment? I think most developers, myself included, treat encryption like a checkbox. We encrypt something, feel good about it, and move on. But there's a massive gap between "encrypted" and "actually secure," and that gap is where my projects were living rent-free in my anxiety.
The Encryption Key Sitting in the Same Room as the Safe
Here's what bothered me most: I understood hashing. Passwords go into bcrypt, they never come out, done. But reversible encryption, the kind you need for API tokens, webhook secrets, user-provided keys, that's a different beast entirely.
The trap is obvious in hindsight but genuinely sneaky in practice. If you store encrypted data in Supabase and your decryption key in the same database (even in a separate table), you've created a false sense of security. A SQL injection, a database dump, or a compromised Supabase instance gives attackers both halves of the puzzle simultaneously. The encryption becomes theater.
What changed my approach: I realized I was storing Gemini API keys and Stripe webhooks in encrypted columns with keys I rotated occasionally. But those keys lived in environment variables on the same Vercel deployment. If someone compromised my deployment, they had everything. It took reading this to understand that physical separation isn't optional, it's foundational.
The Security Definer Footgun I Almost Triggered
The second realization was scarier. PostgreSQL functions with SECURITY DEFINER run with elevated privileges and, by default, are publicly executable. I had a function that processed webhook data, marked SECURITY DEFINER, sitting in the public schema. Technically, anyone on the internet could have called it directly via REST API.
I didn't even realize this was possible until reading about it. Supabase makes building these functions so convenient that you don't think about the permission model. You write the function, test it works, and ship it. The security implications aren't front-and-center until you audit and see functions that should be internal exposing administrative capabilities to anonymous users.
My Take: Convenience Breeds Complacency
Supabase is genuinely developer-friendly, which is why it's popular. But that friendliness comes with hidden assumptions about security that aren't always safe. The framework doesn't scream "you're about to make a dangerous mistake." It just lets you make it.
I respect how the original article breaks down five specific traps with concrete fixes. What I'd emphasize differently: this isn't a Supabase problem specifically, it's a problem with how developers approach databases when the barrier to entry is low. We skip the mental model of "who can access what and from where" because we're moving fast.
My practice now: I treat every Supabase function like it's running on the public internet by default (because it is). I revoke public execute privileges immediately. For anything that handles secrets, I keep decryption keys strictly in runtime environment variables, never in any persistent storage.
The Code Pattern I'm Using Now
Here's what I've started doing for handling third-party API keys:
// Server-side only - never expose this function to client
export async function decryptApiKey(encryptedValue: string) {
const key = process.env.ENCRYPTION_MASTER_KEY; // Vercel secret only
if (!key) throw new Error("ENCRYPTION_MASTER_KEY not configured");
const [version, iv, authTag, ciphertext] = encryptedValue.split(':');
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
Buffer.from(key, 'hex'),
Buffer.from(iv, 'hex')
);
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
The key stays locked in Vercel. The database holds only ciphertext. Never the twain shall meet.
What's Your Audit Status?
I'm curious whether readers are actively auditing their Supabase security, or if most projects are running with these traps intact. If you're building on Supabase in production, have you checked your function permissions recently? Are your secrets actually separated from your encrypted data, or is everything living in the same perimeter?
I'm still hardening my approach, but at least now I'm asking the right questions before something breaks.
Source: This post was inspired by "Wait, How Did Hackers Steal the Encryption Key Too?" 5 Supabase Security Traps I Fixed in Production" by Dev.to. Read the original article