Skip to main content
All posts
supabasesecurityRLSbest-practices2026databasetutorial

Supabase Security Best Practices 2026: The Complete Hardening Guide

April 18, 202616 min read

Supabase is the default database for AI-built apps. Cursor, Lovable, Bolt, and v0 all generate Supabase code out of the box. The problem is what they generate. We have scanned thousands of Supabase-powered applications and found exploitable misconfigurations in more than 60% of them. This guide covers every security surface in Supabase — RLS, auth, API keys, edge functions, storage, and monitoring — with the exact configurations that protect your data.

Row Level Security: The Foundation

RLS is Supabase's primary access control mechanism. Every query made through the Supabase client (including the JavaScript SDK your frontend uses) passes through RLS policies. If RLS is disabled on a table, every row in that table is accessible to anyone with your project URL and anon key — both of which are public.

Rule 1: Enable RLS on Every Table

No exceptions. Every table in the public schema must have RLS enabled. A table without RLS is an open door. Run this query to find unprotected tables:

SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN (
  SELECT tablename FROM pg_tables t
  JOIN pg_class c ON c.relname = t.tablename
  WHERE c.relrowsecurity = true
);

If this returns any rows, those tables are fully exposed.

Rule 2: RLS Enabled With Zero Policies Blocks Everything

This catches developers who enable RLS (good) but forget to add policies (bad). The table becomes inaccessible to all roles except service_role. The app appears to break, so the developer disables RLS to "fix" it. Now the table is fully exposed again.

The correct approach: Enable RLS and immediately add at least one SELECT policy. Then add INSERT, UPDATE, and DELETE policies as needed. Never disable RLS to fix an access problem — the problem is a missing or misconfigured policy.

Rule 3: Never Use USING(true) for Public-Facing Policies

This is the single most dangerous RLS mistake we find. USING(true) means "allow access for all roles" — including anon. Developers often write this intending it to mean "allow access for all authenticated users," but that is not what it does.

-- DANGEROUS: allows anon access
CREATE POLICY "Users can read" ON profiles
FOR SELECT USING (true);

-- CORRECT: restricts to authenticated users reading their own data
CREATE POLICY "Users can read own profile" ON profiles
FOR SELECT TO authenticated
USING (auth.uid() = user_id);

The TO authenticated clause is critical. Without it, the policy applies to all roles. We documented this pattern in detail in our article on the Supabase RLS mistake that could expose your users' data.

Rule 4: UPDATE Policies Must Have WITH CHECK

A USING clause on an UPDATE policy controls which rows a user can see for updating. A WITH CHECK clause controls what values the update can set. Without WITH CHECK, a user can update their own row to set role = 'admin' or plan = 'enterprise'.

-- DANGEROUS: user can escalate privileges
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE TO authenticated
USING (auth.uid() = user_id);

-- CORRECT: restricts what fields can be changed
CREATE POLICY "Users can update own profile" ON profiles
FOR UPDATE TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (
  auth.uid() = user_id
  AND role = (SELECT role FROM profiles WHERE user_id = auth.uid())
  AND plan = (SELECT plan FROM profiles WHERE user_id = auth.uid())
);

The WITH CHECK ensures that role and plan cannot be changed by the user. They remain locked to their current values.

Rule 5: Always Use auth.uid(), Never Client-Provided IDs

RLS policies must derive the user's identity from auth.uid(), not from a column the client provides. If your policy references a user_id passed in the request body instead of auth.uid() from the JWT, an attacker can impersonate any user by sending someone else's ID.

-- DANGEROUS: relies on client-provided data
CREATE POLICY "Read own data" ON orders
FOR SELECT USING (user_id = current_setting('request.claim.user_id')::uuid);

-- CORRECT: uses server-verified identity
CREATE POLICY "Read own data" ON orders
FOR SELECT TO authenticated
USING (user_id = auth.uid());

API Key Security

Understand What Each Key Does

Supabase gives you two keys that look similar but have radically different permissions:

  • anon key: Public. Safe to expose in client-side code. Subject to RLS policies. This is the key your frontend should use.
  • service_role key: Private. Bypasses all RLS. Full read/write access to every table. This key must never appear in client-side code, browser DevTools, Git repositories, or error messages.

We find the service_role key exposed in client bundles on roughly 15% of the AI-built apps we scan. When Cursor or Lovable generates Supabase integration code, it sometimes uses the service_role key because that key "just works" without needing RLS policies. The code runs correctly during development. In production, it means every visitor can read and write every row in your database.

Key Rotation After Exposure

If your service_role key has ever been in client-side code, in a public Git repository, or in a deployed JavaScript bundle, it is compromised. Supabase allows key rotation in the project settings under API. Rotate both keys, update your server-side environment variables, and verify your app still works. Do this immediately — do not wait for evidence of exploitation.

Authentication Configuration

Disable Unused Auth Providers

Every enabled auth provider is an attack surface. If your app only uses email/password login, disable Google, GitHub, and every other OAuth provider in the Supabase dashboard. AI tools often enable multiple providers during development for testing and leave them enabled in production.

Email Confirmation

Enable email confirmation for production. Without it, anyone can create accounts with any email address, including emails they do not own. This enables account squatting and can be used to bypass email-based access controls.

Password Requirements

Supabase's default minimum password length is 6 characters. For production, set it to at least 8, preferably 12. Configure this in the Supabase dashboard under Authentication > Settings.

JWT Expiry

The default JWT expiry in Supabase is 3600 seconds (1 hour). For high-security applications, consider reducing this to 900 seconds (15 minutes) with refresh token rotation enabled. Shorter tokens reduce the window of exploitation if a token is stolen.

Edge Function Security

Always Validate Auth in Code

Supabase Edge Functions have a verify_jwt setting in config.toml. Regardless of this setting, always validate the user's session inside your function code. Extract the JWT from the Authorization header, verify it, and use the authenticated user's ID for any database operations.

import { createClient } from '@supabase/supabase-js'

Deno.serve(async (req) => {
  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 })
  }

  // Now use user.id for all database operations
  const { data } = await supabase
    .from('orders')
    .select('*')
    .eq('user_id', user.id)

  return new Response(JSON.stringify(data))
})

Never Use Service Role in Edge Functions Called by Clients

If your edge function is called by the frontend, it should use the anon key with the user's JWT. The service_role key should only be used in server-to-server operations, cron jobs, or admin functions that are not exposed to clients.

Storage Bucket Security

Supabase Storage uses the same RLS system as database tables. Every storage bucket should have policies that control who can upload, download, and delete files.

Common Mistake: Public Upload Buckets

AI tools often create storage buckets with public access for convenience. This means anyone can upload files to your storage, potentially storing malware, illegal content, or files large enough to exhaust your storage quota and billing.

-- Restrict uploads to authenticated users, own folder only
CREATE POLICY "Users upload to own folder" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = auth.uid()::text);

-- Restrict downloads to authenticated users
CREATE POLICY "Users read own files" ON storage.objects
FOR SELECT TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = auth.uid()::text);

File Type Validation

Supabase does not validate file types by default. Without validation, an attacker can upload executable files, HTML files (for stored XSS), or oversized files. Add MIME type checking in your upload function and enforce file size limits.

Monitoring and Logging

Enable Supabase Logs

Supabase provides query logs, auth logs, and storage logs in the dashboard. Review auth logs regularly for: repeated failed login attempts (brute force), login from unusual locations, rapid account creation (bot attacks), and password reset floods.

Set Up Alerts

Use Supabase's webhook or database triggers to alert on suspicious activity. A trigger on your auth.users table can notify you when a new admin account is created. A trigger on sensitive tables can log all delete operations.

How to Test Your Supabase Security

Do not trust that your configuration is correct. Test it.

Test 1: Anon Key Data Access

Open your browser console on your live site and run:

const { data } = await supabase.from('profiles').select('*')
console.log('Rows visible to anon:', data?.length)

If this returns any rows, your RLS is not protecting that table.

Test 2: Cross-User Data Access

Log in as User A. Try to query data belonging to User B by manipulating the user_id filter. If you can see User B's data, your RLS policies are not scoped correctly.

Test 3: Privilege Escalation

Log in as a regular user. Try to update your own profile to set role = 'admin'. If the update succeeds, your UPDATE policy is missing WITH CHECK.

Test 4: Service Role Key Exposure

Open DevTools on your live site. Go to Sources. Search for service_role, eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 (the standard JWT prefix for Supabase keys), and your project reference ID. If any of these appear in the JavaScript bundle, your service_role key is likely exposed.

Test 5: Automated Scan

Run a VibeArmor scan against your live URL. The scanner tests all of the above automatically plus 116 additional checks covering authentication, injection, infrastructure, and AI-specific vulnerability patterns. You will get a hackability grade and specific fix prompts for every finding.

Quick Reference Checklist

  • RLS enabled on every public table
  • Every table with RLS has at least one policy
  • No policies use USING(true) without role restriction
  • All UPDATE policies have WITH CHECK
  • All policies use auth.uid() not client-provided IDs
  • Service role key is not in any client-side code
  • Unused auth providers are disabled
  • Email confirmation is enabled
  • Minimum password length is 8+
  • Storage buckets have upload policies
  • Edge functions validate auth in code
  • Query and auth logs are reviewed weekly

For a broader security checklist covering all aspects of AI-built apps (not just Supabase), see our 15-item vibe coding security checklist.

Frequently Asked Questions

Is Supabase secure by default?

Supabase provides strong security primitives: RLS, JWT-based auth, and encrypted connections. However, these primitives must be configured correctly. RLS "enabled" without policies is not secure. The service_role key in client code is not secure. Supabase gives you the tools. You (or your AI coding tool) must use them correctly.

My AI tool set up Supabase for me. Is it secure?

Almost certainly not without manual review. Cursor, Lovable, and Bolt all generate Supabase configurations that work but are not secure. The most common issues are USING(true) policies, missing WITH CHECK on UPDATE, and service_role key exposure. Test using the methods in this guide or run a scan.

Can I use Supabase without RLS?

Technically yes, if all database access goes through server-side API routes that handle authorization. However, if any client-side Supabase SDK call exists in your app, RLS is mandatory. The client SDK uses the anon key, which means any user can modify the SDK calls in their browser to query any table. Without RLS, there is nothing stopping them.

How often should I audit my RLS policies?

After every schema change and after every feature that touches the database. AI tools add new tables and modify existing ones without considering the RLS implications of the change. A weekly automated scan catches regressions between manual audits. VibeArmor's Continuous plan ($999/month) includes weekly auto-scans for this purpose.

What is the performance impact of RLS?

Minimal when done correctly. The biggest performance killer is missing indexes on columns referenced in RLS policies. Always add an index on any column your policies filter by (typically user_id). Supabase's official guidance recommends using security definer functions for complex policies instead of subqueries. For most apps, RLS adds less than 5ms per query when properly indexed.

Related reading

Scan your app free

Paste a URL, get a letter grade and Cursor-ready fixes in 3 minutes. No signup required.

Start Free Scan