Introduction: The Headline That Made My Stomach Drop
Almost every week, we see another headline about a major tech company or hot startup suffering an unauthorized access incident with API keys or credentials leaked.
As a solo developer building and running SnappySnag—a visual bug reporting & AI diagnostic tool for Flutter apps—reading these reports always triggers a cold sweat:
"If companies with dedicated security and SecOps teams get breached, is my database on Supabase actually safe?"
One sentence from a recent incident report hit me particularly hard:
"API keys, integration tokens, and credentials were encrypted at rest. However, the decryption key was also read in the same unauthorized access event..."
Wait.
- Doesn't "encrypted at rest in the database" mean our data is secure?
- How on earth did attackers grab both the encrypted data AND the decryption key at the same time?
- And building on Supabase, what false assumptions are we making about our app's security?
That report prompted me to thoroughly audit SnappySnag’s database, functions, and Row-Level Security (RLS) from scratch. Along the way, I uncovered several dangerous traps hidden behind Supabase's developer-friendly convenience.
Here are 5 critical security checkpoints every developer building on Supabase should check against their codebase today.
Check 1: Where Is Your Decryption Key Actually Sitting?
⚠️ The Trap: "I encrypted the column in DB, so we're safe, right?"
There are two fundamentally different ways we protect sensitive data:
1. Hashing (One-Way / Irreversible)
Passwords never need to be converted back to plain text, so we hash them with algorithms like bcrypt or Argon2. There is literally no decryption key in the universe to steal. (Supabase Auth handles this automatically with salted bcrypt).
2. Encryption (Two-Way / Reversible)
The real nightmare begins with integration secrets: GitHub personal tokens, Slack/Discord webhook URLs, or user-provided Gemini/OpenAI API keys.
Your backend has to decrypt these values back into plain text before making outbound API requests, meaning you cannot use one-way hashing.
The fatal mistake is storing the "safe" (encrypted data) and the "safe's key" (decryption key) in the exact same room:
- Trap 1: Storing the decryption secret inside another DB table or metadata column. (A single SQL injection or DB dump grabs both simultaneously).
- Trap 2: Hardcoding the key into public/internal Git repositories or backup scripts.
- Trap 3: Running DB and backend application server on the exact same compromised container/VM.
If an attacker dumps your database, having the encryption key in the same perimeter renders the encryption completely useless.
🛡️ The Fix: AES-256-GCM & Physical Separation of Keys
-
Enforce Strict Physical Separation:
- Encrypted Payloads: Stored in Supabase (PostgreSQL).
-
Decryption Master Key (
APP_ENCRYPTION_KEY): Stored exclusively as an encrypted secret in your application execution runtime (e.g., Vercel environment variables or edge workers)—never anywhere inside PostgreSQL. - If a raw database snapshot ever leaks, the data remains cryptographically unreadable without the runtime server secret.
// Server-side (Node.js / Edge Runtime) AES-256-GCM encryption helper
import crypto from "crypto";
export function encryptSecret(plainText: string, keyHex: string): string {
const iv = crypto.randomBytes(12); // Standard 12-byte IV for GCM
const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(keyHex, "hex"), iv);
let encrypted = cipher.update(plainText, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
// Output format: enc:v1:<iv_hex>:<auth_tag_hex>:<ciphertext_hex>
return `enc:v1:${iv.toString("hex")}:${authTag}:${encrypted}`;
}
-
Never Return Raw Secrets in API Payloads:
- Even when a project owner opens their settings page, display secrets as masked placeholders (
••••••••). - In shared queries (e.g., member or feedback queries), strictly omit sensitive columns from the
SELECTquery so they never travel over browser network websockets/REST responses.
- Even when a project owner opens their settings page, display secrets as masked placeholders (
Check 2: Are Your SECURITY DEFINER Functions Publicly Executable?
Physically separating keys protects against database dumps. But what if attackers don't need a dump because your database exposes functions they can just trigger via public REST API?
This was by far the scariest discovery in our audit.
⚠️ The Trap: Slapping SECURITY DEFINER on a function and leaving it in public
In PostgreSQL (and consequently Supabase), whenever you create a function, PostgreSQL grants EXECUTE privilege to PUBLIC (meaning all users, including unauthenticated anon clients) by default.
If you create a function with SECURITY DEFINER (which runs with postgres superuser/creator privileges, bypassing RLS) in the default public schema, anyone on the internet can call it directly via:
POST https://<project-ref>.supabase.co/rest/v1/rpc/<function_name>
If you have a function designed for an internal cron job or payment webhook (e.g., grant_addon_credits), an unauthenticated stranger with a simple curl command could trigger administrative mutations on your database!
🛡️ The Fix: REVOKE and the private Schema Pattern
1. For Server-Only / Worker Functions: Strip Public Privileges
Immediately revoke execution rights from PUBLIC, anon, and authenticated, granting access solely to service_role:
-- Lock down server-only functions to service_role
REVOKE EXECUTE ON FUNCTION public.grant_addon_credits(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.grant_addon_credits(uuid, integer) TO service_role;
2. For Functions Used Inside RLS Policies: Move to private Schema
We often write helper functions like is_project_member(project_id, user_id) to avoid recursive loops in RLS policies.
If you leave this in the public schema, malicious users can brute-force test random UUIDs through the RPC API to enumerate who belongs to which project. But if you revoke permissions from authenticated, your RLS policies break.
The Solution: The private Schema Pattern.
Supabase's PostgREST API only exposes tables and functions located in the public schema. By moving helper functions to an internal private schema, they become completely invisible to the external REST API while remaining lightning-fast within RLS evaluation:
-- 1. Create a dedicated internal schema
CREATE SCHEMA IF NOT EXISTS private;
GRANT USAGE ON SCHEMA private TO authenticated, service_role;
-- 2. Define the helper function inside 'private'
CREATE OR REPLACE FUNCTION private.is_project_member(proj_id uuid, user_uuid uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
SELECT EXISTS (
SELECT 1 FROM public.project_members
WHERE project_id = proj_id AND user_id = user_uuid
);
$$;
GRANT EXECUTE ON FUNCTION private.is_project_member(uuid, uuid) TO authenticated, service_role;
-- 3. Reference it cleanly in your public table's RLS policy
CREATE POLICY "Project members can view issues"
ON public.issues FOR SELECT
USING (private.is_project_member(project_id, auth.uid()));
Check 3: The Dangerous SELECT USING (true) Trap That Linter Ignores
⚠️ The Trap: "Security Advisor shows 0 warnings, so my RLS is bulletproof!"
Supabase provides an awesome built-in Security Advisor that lints your database for insecure configurations.
However, there is a massive catch.
The Security Advisor will flag INSERT / UPDATE / DELETE policies with WITH CHECK (true) as dangerous (rls_policy_always_true).
However, it intentionally ignores SELECT ... USING (true).
Why? Because many web apps legitimately have public read tables (e.g., public blog posts, pricing tiers). The linter assumes any open SELECT is intentional.
Because of this, temporary starter policies from early prototyping frequently slip into production unnoticed:
- An invite token lookup table left with
FOR SELECT USING (true)(leaking all pending invitation hashes). - A user
profilestable withFOR SELECT USING (true)(exposing usernames or metadata). - Feedback or comment tables left publicly readable.
🛡️ The Fix: Manually Audit Every CREATE POLICY
Do not rely solely on automated linter badges. Search your migration SQL files for USING (true):
-
For private app tables: Always restrict
SELECTtoauth.uid() = user_idor membership checks (private.is_project_member(...)). - For public validation workflows (e.g., Invite Links): Avoid having the frontend query the table directly. Route the request through a backend endpoint (Next.js Route Handler / Edge Function) that validates the token and returns strictly the minimal project metadata needed.
Check 4: Is search_path Fixed on All Functions?
⚠️ The Trap: Leaving out SET search_path in function definitions
When PostgreSQL runs a function, it resolves unqualified table and function names using a priority list called search_path.
If an unprivileged user creates an object in a mutable schema (or pg_temp), a SECURITY DEFINER function might resolve to that malicious object instead, leading to privilege escalation attacks.
🛡️ The Fix: Always Explicitly Pin search_path
Always append SET search_path = public, pg_temp to custom function definitions:
CREATE OR REPLACE FUNCTION public.safe_operation()
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
-- Pin the schema search path explicitly
SET search_path = public, pg_temp
AS $$
BEGIN
-- Safe internal logic
END;
$$;
💡 Pro-tip: An even stricter approach is
SET search_path = ''(empty string). This forces all queries inside the function to use fully qualified schema names (e.g.,public.my_table), completely eliminating any chance of search path hijacking.
Check 5: Project-Level Auth & Extensions Settings
Two low-hanging fruit settings in your Supabase project dashboard you should check right now:
1. Enable Leaked Password Protection
Under Authentication -> Attack Protection -> Leaked Password Protection:
Supabase integrates directly with HaveIBeenPwned. Enabling this automatically checks incoming passwords against billions of previously breached credentials and rejects them. It takes 10 seconds to toggle and prevents automated credential stuffing.
2. Move Extensions Out of public
Older project templates or manual installs sometimes drop extensions like pg_net or http into the public schema. This exposes extension functions directly to the public PostgREST API. Relocate extensions to the dedicated extensions schema:
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
Bonus Check 6: The USING vs WITH CHECK Asymmetry & Storage Policy Leaks
(Special thanks to the Dev.to community for calling out this sneaky PostgreSQL behavior!)
⚠️ Trap 6A: PostgreSQL's USING / WITH CHECK Asymmetry
In PostgreSQL RLS, there are two clauses:
-
USING(...): Determines which existing rows are visible / eligible to be read or modified. -
WITH CHECK(...): Validates whether the newly written row values are permitted.
If you write an UPDATE or ALL policy with USING but omit WITH CHECK, Postgres reuses your USING predicate to validate the new row!
If your USING clause only checks row ownership or team membership:
-- DANGEROUS: Omitting WITH CHECK
CREATE POLICY "Users can update their own profile"
ON public.profiles FOR UPDATE
TO authenticated
USING (auth.uid() = id); -- No WITH CHECK!
A malicious user can submit an update modifying critical columns (e.g., boosting credits: 999999 or altering role: 'admin'). As long as id remains their own, the reused USING check passes, and the privilege escalation write sails right through!
🛡️ The Audit & Fix:
Audit your database right now with this query:
SELECT tablename, policyname, cmd, with_check
FROM pg_policies
WHERE schemaname = 'public'
AND with_check IS NULL
AND cmd IN ('UPDATE', 'ALL');
Any row returned deserves an explicit WITH CHECK or column-level permissions (REVOKE UPDATE (sensitive_col) ON table FROM authenticated;) or a BEFORE UPDATE trigger protecting critical fields.
⚠️ Trap 6B: The Wide-Open storage.objects Trap
A common architectural oversight is locking down the public schema while leaving Supabase Storage wide open.
If your screenshot or attachment bucket has a policy like:
-- DANGEROUS: Any authenticated user can read all assets
CREATE POLICY "Allow authenticated read"
ON storage.objects FOR SELECT
TO authenticated
USING (bucket_id = 'feedback-assets');
A tight public table schema with an open storage policy is just the same data leak wearing a different hat. Any authenticated user can enumerate and download private screenshots belonging to other teams or tenants!
🛡️ The Fix: Tenant-Isolated Storage Policies
Organize object paths by tenant/project ([project_id]/[file_name]) and enforce membership checks directly in the Storage policy:
CREATE POLICY "Tenant isolated read for feedback-assets"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'feedback-assets'
AND (
(storage.foldername(name))[1] ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
AND (
private.is_project_owner(((storage.foldername(name))[1])::uuid, auth.uid())
OR private.is_project_member(((storage.foldername(name))[1])::uuid, auth.uid())
)
)
);
Conclusion: Security Is Architecture, Not a Checklist Tool
Supabase is an incredible platform that supercharges development speed. But no matter how good your BaaS is, how you structure encryption keys, who can execute functions, and where you draw RLS boundaries is an architectural choice that rests entirely on us as engineers.
- Keep encryption keys out of the database.
- Treat every database function as a potential public API endpoint until proven otherwise.
- Never trust a green linter check when it comes to open
SELECTqueries. - Audit
UPDATEpolicies forWITH CHECKomissions and lock downstorage.objects.
If any of these sound uncomfortably familiar in your current project, take 15 minutes during your next deploy to audit your SQL migrations.
Have you caught any sneaky RLS or function privilege gotchas in your own Supabase builds? Let’s swap notes in the comments below!
Resources & Links
- Built with Supabase: SnappySnag - Visual Feedback & AI Diagnostics for Flutter
- Supabase Official Security Guides: Supabase Security & Hardening












