DEV Community

Cover image for Wait, How Did Hackers Steal the Encryption Key Too?" 5 Supabase Security Traps I Fixed in Production
UK
UK

Posted on AI-assisted

Wait, How Did Hackers Steal the Encryption Key Too?" 5 Supabase Security Traps I Fixed in Production

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

  1. 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}`;
}
Enter fullscreen mode Exit fullscreen mode
  1. 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 SELECT query so they never travel over browser network websockets/REST responses.

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;
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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 profiles table with FOR 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 SELECT to auth.uid() = user_id or 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;
$$;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 SELECT queries.

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

Top comments (2)

Collapse
 
elijahbrown profile image
Elijah Brown •

The grant_addon_credits example is the one I'd put first in any free-tier audit. Once EXECUTE is on anon, that isn't a theoretical privilege issue, it's a public credit faucet. I'd also grep for any SECURITY DEFINER that writes to a claims, credits or trial table and treat those the same way, even when they were never meant for the client.

Collapse
 
utokazu profile image
UK •

"Public credit faucet"—that phrase captures the horror of it perfectly!

You make an incredible point about grepping for anything touching claims, credits, or trial tables. It's so easy during early prototyping to create a helper function that bypasses RLS, forget to revoke EXECUTE, and accidentally leave the front door wide open for abuse.

Treating any function mutating billing/entitlement state with that level of paranoia is definitely the right mindset. Thanks so much for sharing that practical tip—really appreciate you reading and adding to the conversation!