How to Secure Your Supabase App
Supabase gives you a full Postgres database, authentication, and storage out of the box. But these features must be configured securely. Disabled Row Level Security is the number one vulnerability in Supabase apps. This guide covers every layer of Supabase security.
Find security issues automatically before attackers do.
Follow These Steps
Enable RLS on every table
Row Level Security is disabled by default on new tables. Without it, anyone with your Supabase URL and anon key can read and modify your entire database.
-- Enable RLS on all tables
ALTER TABLE "profiles" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "posts" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "comments" ENABLE ROW LEVEL SECURITY;
-- Verify RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';Run the verification query regularly. New tables added by migrations may not have RLS enabled.
Write proper RLS policies for each table
After enabling RLS, create policies that define who can access what. Always use (select auth.uid()) instead of auth.uid() directly for performance.
-- Users can only access their own profile
CREATE POLICY "Users can view own profile" ON "profiles"
FOR SELECT TO authenticated
USING ((select auth.uid()) = user_id);
CREATE POLICY "Users can update own profile" ON "profiles"
FOR UPDATE TO authenticated
USING ((select auth.uid()) = user_id);
-- Posts are readable by all authenticated users
CREATE POLICY "Authenticated users can view posts" ON "posts"
FOR SELECT TO authenticated
USING (true);
CREATE POLICY "Users can create own posts" ON "posts"
FOR INSERT TO authenticated
WITH CHECK ((select auth.uid()) = author_id);
CREATE POLICY "Users can update own posts" ON "posts"
FOR UPDATE TO authenticated
USING ((select auth.uid()) = author_id);Never expose the service_role key
The service_role key bypasses all RLS policies. It must only be used in server-side code like Edge Functions or backend services, never in client-side code.
// WRONG - service_role key in client code
const supabase = createClient(url, 'eyJhbGci...service_role_key')
// CORRECT - anon key in client code
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// service_role only in Edge Functions
// supabase/functions/admin-action/index.ts
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)Secure storage buckets
Configure storage policies to restrict file uploads and downloads. Scope access to authenticated users and their own directories.
-- Create a secure bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', false);
-- Users can upload to their own folder
CREATE POLICY "Users can upload own avatar" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = (select auth.uid())::text
);
-- Users can view their own avatar
CREATE POLICY "Users can view own avatar" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'avatars' AND
(storage.foldername(name))[1] = (select auth.uid())::text
);Configure authentication securely
Go to Authentication > Settings in the Supabase dashboard and configure email confirmation, password requirements, and rate limiting.
-- Supabase dashboard settings to enable:
-- 1. Confirm email: ON
-- 2. Secure email change: ON
-- 3. Rate limit: Email sign-in (per hour) = 5
-- 4. Rate limit: SMS sign-in (per hour) = 5
-- 5. Password minimum length: 8
-- Also configure redirect URLs
-- Site URL: https://yourdomain.com
-- Redirect URLs: https://yourdomain.com/auth/callbackDisable any auth providers you do not use. Each enabled provider is an additional attack surface.
Secure Edge Functions
Edge Functions that perform sensitive operations must verify the JWT token and validate input.
// supabase/functions/process-payment/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
// Verify auth token
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response('Unauthorized', { status: 401 })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) {
return new Response('Unauthorized', { status: 401 })
}
// Process with validated user context
})Monitor and audit database access
Enable pgAudit or use Supabase logs to monitor database queries and detect unusual access patterns.
-- Create an audit log table
CREATE TABLE audit_log (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid REFERENCES auth.users(id),
action text NOT NULL,
table_name text NOT NULL,
record_id uuid,
created_at timestamptz DEFAULT now()
);
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- Only service_role can write audit logs
-- No client-side policy needed (service_role bypasses RLS)Scan your Supabase app
Run a VAS scan against your deployed application to verify RLS is enforced, no service keys are exposed, and all security configurations are correct.
VAS specifically checks for disabled RLS, exposed service_role keys, and Supabase misconfigurations.
What You'll Achieve
Your Supabase app now has RLS enabled on every table with proper policies, secured storage buckets, authentication hardened, Edge Functions protected, and audit logging in place. Your database is locked down against unauthorized access.
Common Mistakes to Avoid
Mistake
Enabling RLS but forgetting to create policies
Fix
RLS without policies blocks ALL access including your own app. Always create at least SELECT, INSERT, UPDATE, and DELETE policies for each table.
Mistake
Using auth.uid() directly instead of (select auth.uid())
Fix
Wrap in a subquery: (select auth.uid()). This evaluates once per query instead of once per row, improving performance and avoiding planner warnings.
Mistake
Creating service_role policies alongside user policies
Fix
The service_role bypasses RLS entirely. Creating policies for it causes multiple_permissive_policies warnings. Only create policies with TO authenticated.
Mistake
Making storage buckets public when they contain user data
Fix
Set public to false on buckets with user data. Use signed URLs or RLS policies to control access instead.
Frequently Asked Questions
Is the Supabase anon key a secret?
No. The anon key is designed to be used in frontend code. It only has the permissions defined by your RLS policies. Security comes from RLS, not from hiding the anon key.
What happens if I enable RLS without policies?
All access is blocked. No one, including your app, can read or write to the table. You must create policies after enabling RLS to define who can access what.
Should I use the service_role key in my frontend?
Never. The service_role key bypasses all RLS policies and has full database access. It should only be used in server-side code like Edge Functions or backend services.
How do I check which tables have RLS disabled?
Run: SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; Any table with rowsecurity = false is exposed to anyone with your anon key.
Ready to Secure Your App?
VAS automatically scans your deployed app for the security issues covered in this guide. Get actionable results in minutes.
Start Security Scan