Step-by-Step Guide
8 steps

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

1

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.

Code Example
-- 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.

2

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.

Code Example
-- 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);
3

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.

Code Example
// 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')!
)
4

Secure storage buckets

Configure storage policies to restrict file uploads and downloads. Scope access to authenticated users and their own directories.

Code Example
-- 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
  );
5

Configure authentication securely

Go to Authentication > Settings in the Supabase dashboard and configure email confirmation, password requirements, and rate limiting.

Code Example
-- 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/callback

Disable any auth providers you do not use. Each enabled provider is an additional attack surface.

6

Secure Edge Functions

Edge Functions that perform sensitive operations must verify the JWT token and validate input.

Code Example
// 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
})
7

Monitor and audit database access

Enable pgAudit or use Supabase logs to monitor database queries and detect unusual access patterns.

Code Example
-- 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)
8

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