Supabase provides developers with an instant REST and GraphQL API directly over PostgreSQL. However, with great convenience comes serious security responsibility: if your Row Level Security (RLS) policies are misconfigured, your entire database is publicly readable and writable via the browser console.
In this article, we analyze the 7 most common RLS vulnerabilities found in production audits and provide copy-paste SQL defenses.
Vulnerability 1: Forgetting to Enable RLS
The most common flaw is creating a table and writing policies without explicitly toggling RLS on. When RLS is not enabled, PostgreSQL ignores all policies and allows public read/write access.
-- β INSECURE: Policies exist, but RLS is NOT active!
CREATE TABLE customer_invoices (...);
CREATE POLICY "Users can view own invoices" ON customer_invoices ...;
-- β
REMEDIATION: Always run this explicitly:
ALTER TABLE customer_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer_invoices FORCE ROW LEVEL SECURITY;
Vulnerability 2: Mass Assignment via Unchecked UPDATE Policies
Many developers write UPDATE policies that verify user identity using USING (auth.uid() = user_id). However, without a corresponding WITH CHECK clause or trigger guards, a user can modify protected columns like is_admin, balance, or subscription_tier.
-- β INSECURE: User can update their own row and set is_admin = true
CREATE POLICY "Users can update profile" ON public.profiles
FOR UPDATE USING (auth.uid() = id);
-- β
REMEDIATION: Add a BEFORE UPDATE trigger that locks protected columns
CREATE OR REPLACE FUNCTION protect_profile_fields()
RETURNS TRIGGER AS $
BEGIN
IF NEW.is_admin IS DISTINCT FROM OLD.is_admin THEN
RAISE EXCEPTION 'Unauthorized privilege escalation attempt.';
END IF;
RETURN NEW;
END;
$ LANGUAGE plpgsql SECURITY DEFINER;
Vulnerability 3: The SECURITY DEFINER View Trap
When creating a PostgreSQL View to aggregate data, developers often write functions with SECURITY DEFINER. This runs the query with the permissions of the database owner, completely bypassing RLS for whoever reads the view!
-- β INSECURE: Bypasses RLS on underlying sensitive tables
CREATE VIEW public.all_user_stats AS
SELECT * FROM public.private_transactions;
-- β
REMEDIATION: Force the view to respect the caller's RLS
ALTER VIEW public.all_user_stats SET (security_invoker = true);
Vulnerability 4: Trusting auth.jwt() Claims Without Verification
Custom claims stored in auth.jwt() can be stale if a user's role changes in the database while their JWT access token remains valid for another hour.
For high-privilege operations, always cross-reference the live profile record:
-- β
ZERO-TRUST CHECK: Live database verification
CREATE POLICY "Admins can delete posts" ON public.posts
FOR DELETE USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid() AND is_admin = true
)
);
Vulnerability 5: Unprotected INSERT Policies Allowing Record Spoofing
If an INSERT policy lacks a check on user_id, malicious actors can create records attributed to another user or administrator:
-- β INSECURE: Anyone can insert records under any author_id
CREATE POLICY "Allow inserts" ON public.posts FOR INSERT WITH CHECK (true);
-- β
REMEDIATION: Enforce that author_id matches the authenticated token
CREATE POLICY "Enforce authorship" ON public.posts
FOR INSERT WITH CHECK (auth.uid() = author_id);
Security Audit Summary Checklist
- Verify
SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; - Enforce
WITH CHECKconstraints on allINSERTandUPDATEpolicies. - Lock down sensitive numeric and boolean fields using PostgreSQL triggers.
- Set
security_invoker = trueon all public database views.