Blog
supabasesecurity-checklistrlsbackend-securitydatabase

Supabase Security Checklist: 15 Things to Check Before Launch

CheckVibe Team
17 min read

In our analysis of thousands of web applications built with AI coding tools, roughly 1 in 5 have at least one serious security vulnerability. The single most common issue? Misconfigured Supabase Row Level Security. Developers ship fast, RLS gets forgotten, and the entire database is exposed to anyone with a browser console.

This checklist covers the 15 things you need to verify before your Supabase-powered app goes live.

Why Supabase Security Matters

Supabase is built on PostgreSQL, which is battle-tested. But Supabase adds a client-facing API layer that changes the threat model. Three things you need to internalize:

  1. Your anon key is public. It ships in your JavaScript bundle. Anyone can see it. It is not a secret.
  2. RLS is your primary defense. Since the anon key gives direct database access via PostgREST, Row Level Security policies are what stand between your data and the public internet.
  3. The service_role key bypasses everything. It ignores RLS, auth, and every policy you write. If it leaks, game over.

Understanding this architecture is non-negotiable. Every item in this checklist flows from these three facts.

The Checklist

1. Enable RLS on Every Table

Supabase creates new tables with RLS disabled. This means any authenticated user (or anyone with your anon key) can read, insert, update, and delete every row.

-- Check which tables have RLS disabled
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;

-- Enable RLS on a table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

Do this for every table in your public schema. No exceptions. Even lookup tables and config tables should have RLS enabled with appropriate read policies.

2. Write Restrictive RLS Policies

Enabling RLS without adding policies locks the table down completely (only service_role can access it). That is actually the safest default. Then add policies for exactly what you need:

-- Users can only read their own profiles
CREATE POLICY "Users read own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = id);

-- Users can only update their own profile
CREATE POLICY "Users update own profile"
  ON profiles FOR UPDATE
  USING (auth.uid() = id)
  WITH CHECK (auth.uid() = id);

-- Users can read their own orders
CREATE POLICY "Users read own orders"
  ON orders FOR SELECT
  USING (auth.uid() = user_id);

-- Users can insert orders only for themselves
CREATE POLICY "Users create own orders"
  ON orders FOR INSERT
  WITH CHECK (auth.uid() = user_id);

The USING clause filters which rows you can see. The WITH CHECK clause validates what you can write. Always include both for UPDATE policies — a missing WITH CHECK lets users reassign rows to other users.

3. Never Use the Service Role Key in Client Code

This is the most dangerous mistake you can make with Supabase. The SUPABASE_SERVICE_ROLE_KEY bypasses all RLS policies. If it ends up in your client bundle, an attacker has full, unrestricted database access.

// WRONG — this key will be exposed in the browser
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY! // NEVER DO THIS
);

// CORRECT — use the anon key on the client
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // This is safe — it's meant to be public
);

The service role key should only exist in server-side code: API routes, server actions, edge functions, and cron jobs. Never prefix it with NEXT_PUBLIC_.

4. Protect Anon Key Exposure in Client Bundles

Your anon key is designed to be public — but that does not mean you should be careless about what else ships alongside it. Audit your client bundle for:

  • Service role keys accidentally bundled via shared config files
  • Database connection strings (these should never exist client-side)
  • Internal API keys for third-party services
// In your Next.js config, verify ONLY these are NEXT_PUBLIC_:
// NEXT_PUBLIC_SUPABASE_URL — safe
// NEXT_PUBLIC_SUPABASE_ANON_KEY — safe
// Everything else: server-only

Use a tool like CheckVibe's API key scanner or manually search your built output for key patterns.

5. Enable Email Confirmation

By default, Supabase lets users sign up and immediately access your app without confirming their email. This allows attackers to create accounts with fake emails.

In the Supabase Dashboard, go to Authentication > Settings and enable:

  • Confirm email — requires users to click a confirmation link
  • Secure email change — requires confirmation before email updates
// In your signup handler, check email confirmation status
const { data: { user } } = await supabase.auth.getUser();

if (user && !user.email_confirmed_at) {
  return NextResponse.json(
    { error: 'Please confirm your email address' },
    { status: 403 }
  );
}

6. Set Strong Password Requirements

Supabase defaults to a minimum password length of 6 characters. That is too weak for production.

In Authentication > Settings, increase the minimum password length to at least 8 characters. If you are building anything that handles sensitive data, go to 12.

You can also enforce password strength in your signup form before hitting the Supabase API:

function validatePassword(password: string): string | null {
  if (password.length < 8) return 'Password must be at least 8 characters';
  if (!/[A-Z]/.test(password)) return 'Password must contain an uppercase letter';
  if (!/[a-z]/.test(password)) return 'Password must contain a lowercase letter';
  if (!/[0-9]/.test(password)) return 'Password must contain a number';
  return null;
}

7. Configure MFA for Sensitive Operations

For apps that handle payments, personal data, or admin operations, Multi-Factor Authentication adds a critical second layer.

Supabase supports TOTP-based MFA out of the box:

// Enroll a user in MFA
const { data, error } = await supabase.auth.mfa.enroll({
  factorType: 'totp',
  friendlyName: 'Authenticator App',
});

// Verify MFA challenge before sensitive operations
const { data: challenge } = await supabase.auth.mfa.challenge({
  factorId: factorId,
});

const { data: verify } = await supabase.auth.mfa.verify({
  factorId: factorId,
  challengeId: challenge.id,
  code: userProvidedCode, // 6-digit TOTP code
});

You can also use RLS policies to require MFA at the database level using auth.jwt() ->> 'aal':

-- Only allow access if user has completed MFA (AAL2)
CREATE POLICY "Require MFA for sensitive data"
  ON sensitive_table FOR ALL
  USING ((auth.jwt() ->> 'aal') = 'aal2');

8. Restrict Storage Bucket Access with Policies

Supabase Storage uses the same RLS-style policies as the database. Without policies, uploaded files may be accessible to anyone.

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

-- Users can only read their own files
CREATE POLICY "Users read own files"
  ON storage.objects FOR SELECT
  USING (
    bucket_id = 'avatars' AND
    auth.uid()::text = (storage.foldername(name))[1]
  );

-- Users can delete their own files
CREATE POLICY "Users delete own files"
  ON storage.objects FOR DELETE
  USING (
    bucket_id = 'avatars' AND
    auth.uid()::text = (storage.foldername(name))[1]
  );

Never create public buckets for user-uploaded content unless you explicitly want every file to be world-readable.

9. Use Signed URLs for Private File Access

For files that should not be publicly accessible, use signed URLs with short expiry times instead of public URLs:

// WRONG — public URL, accessible forever
const { data } = supabase.storage
  .from('documents')
  .getPublicUrl('invoice.pdf');

// CORRECT — signed URL, expires in 60 seconds
const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl('invoice.pdf', 60);

Signed URLs are cryptographically verified and expire after the specified duration. Use them for invoices, private documents, user uploads, and anything that should not be permanently linkable.

10. Validate Inputs in Edge Functions

Supabase Edge Functions run on Deno and accept arbitrary HTTP requests. Validate everything:

import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';

serve(async (req) => {
  // Validate HTTP method
  if (req.method !== 'POST') {
    return new Response('Method not allowed', { status: 405 });
  }

  // Parse and validate body
  let body;
  try {
    body = await req.json();
  } catch {
    return new Response('Invalid JSON', { status: 400 });
  }

  // Validate required fields
  const { email, action } = body;
  if (typeof email !== 'string' || !email.includes('@')) {
    return new Response('Invalid email', { status: 400 });
  }

  const VALID_ACTIONS = ['subscribe', 'unsubscribe'];
  if (!VALID_ACTIONS.includes(action)) {
    return new Response('Invalid action', { status: 400 });
  }

  // Validate URL inputs against SSRF
  if (body.webhookUrl) {
    const url = new URL(body.webhookUrl);
    if (['localhost', '127.0.0.1', '0.0.0.0'].includes(url.hostname)) {
      return new Response('Invalid URL', { status: 400 });
    }
  }

  // Proceed safely...
});

Pay special attention to SSRF protection — if your edge function fetches user-provided URLs, validate that they do not point to internal infrastructure.

11. Set Up Rate Limiting on Auth Endpoints

Supabase has built-in rate limiting, but the defaults may be too permissive for your use case. In the Dashboard under Authentication > Rate Limits, configure:

  • Sign-up rate limit — prevent mass account creation (recommended: 5 per hour per IP)
  • Sign-in rate limit — prevent brute force attacks (recommended: 10 per hour per IP)
  • Token refresh rate limit — prevent token abuse

For API routes that call Supabase, add application-level rate limiting:

import { headers } from 'next/headers';

const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);

  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
    return true;
  }

  if (entry.count >= MAX_REQUESTS) return false;
  entry.count++;
  return true;
}

For production, use a database-backed or Redis-backed rate limiter rather than an in-memory map.

12. Enable SSL Enforcement

Ensure all connections to your Supabase database require SSL. In the Dashboard under Settings > Database, verify that SSL enforcement is enabled.

For direct database connections (e.g., from a migration script or admin tool), always use the SSL connection string:

# Always use the SSL-enabled connection string
postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres?sslmode=require

Never disable SSL verification in production, even if it "fixes" a connection issue during development.

13. Use Database Functions for Atomic Operations

Read-then-write patterns are vulnerable to race conditions. If you read a value, check it, then update it, another request can slip in between the read and the write.

// VULNERABLE — race condition between read and write
const { data: profile } = await supabase
  .from('profiles')
  .select('scan_count, scan_limit')
  .eq('id', userId)
  .single();

if (profile.scan_count >= profile.scan_limit) {
  return NextResponse.json({ error: 'Limit reached' }, { status: 429 });
}

// Another request could increment scan_count here!
await supabase
  .from('profiles')
  .update({ scan_count: profile.scan_count + 1 })
  .eq('id', userId);

Instead, use a PostgreSQL function that performs the check and update atomically:

CREATE OR REPLACE FUNCTION increment_scan_usage(p_user_id uuid)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
  v_count int;
  v_limit int;
BEGIN
  SELECT scan_count, scan_limit INTO v_count, v_limit
  FROM profiles WHERE id = p_user_id
  FOR UPDATE; -- Row-level lock prevents race conditions

  IF v_count >= v_limit THEN
    RETURN json_build_object('allowed', false, 'current', v_count, 'limit', v_limit);
  END IF;

  UPDATE profiles SET scan_count = v_count + 1 WHERE id = p_user_id;
  RETURN json_build_object('allowed', true, 'current', v_count + 1, 'limit', v_limit);
END;
$$;
// SAFE — atomic operation, no race condition
const { data, error } = await supabase.rpc('increment_scan_usage', {
  p_user_id: userId,
});

if (!data.allowed) {
  return NextResponse.json({ error: 'Limit reached' }, { status: 429 });
}

14. Audit with Supabase Security Advisor

Supabase includes a built-in Security Advisor under Database > Security Advisor in the Dashboard. It checks for:

  • Tables without RLS enabled
  • Overly permissive RLS policies
  • Functions with SECURITY DEFINER that do not set search_path
  • Unused or orphaned roles

Run this before every release. It takes 30 seconds and catches the most common configuration mistakes.

15. Run Automated Security Scans Regularly

Manual checklists are a point-in-time check. Your app changes with every deployment. Security scanning should be automated and continuous.

Set up scans that check for:

  • Exposed API keys in your client bundle
  • Missing or misconfigured security headers
  • Open redirect vulnerabilities
  • CORS misconfigurations
  • Auth endpoints without rate limiting
  • RLS policy gaps

CheckVibe runs 36 security checks including a dedicated Supabase scanner that detects RLS misconfigurations, exposed keys, auth issues, and more. You can run scans manually or schedule them daily or weekly.

Common Supabase Security Mistakes

These are real patterns we see repeatedly in scanned applications.

Mistake 1: RLS Enabled but No Policies

Developers enable RLS (good) but then create an overly broad policy to "make things work" (bad):

-- DANGEROUS — this grants full access to everyone, defeating the purpose of RLS
CREATE POLICY "Allow all" ON documents
  FOR ALL USING (true) WITH CHECK (true);

Fix: Write specific policies for each operation (SELECT, INSERT, UPDATE, DELETE) scoped to the authenticated user:

CREATE POLICY "Owner read" ON documents
  FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Owner insert" ON documents
  FOR INSERT WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Owner update" ON documents
  FOR UPDATE USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Owner delete" ON documents
  FOR DELETE USING (auth.uid() = user_id);

Mistake 2: Using Supabase Client in Server Components Without Proper Auth

Server Components in Next.js run on the server, but the Supabase client still uses the user's session. A common mistake is assuming server = trusted:

// WRONG — fetching data without checking who the user is
export default async function DashboardPage() {
  const supabase = await createClient();
  const { data } = await supabase.from('projects').select('*');
  // This returns ALL projects the RLS policy allows —
  // but did you verify RLS is correctly configured?
  return <ProjectList projects={data} />;
}

// CORRECT — explicitly verify the user and scope queries
export default async function DashboardPage() {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) redirect('/login');

  const { data } = await supabase
    .from('projects')
    .select('*')
    .eq('user_id', user.id); // Defense in depth — don't rely solely on RLS

  return <ProjectList projects={data} />;
}

Mistake 3: Trusting Client-Submitted User IDs

Never let the client tell you who they are. Always derive the user ID from the authenticated session:

// WRONG — user_id comes from the request body
export async function POST(req: Request) {
  const { user_id, title } = await req.json();
  await supabase.from('posts').insert({ user_id, title }); // Attacker can set any user_id
}

// CORRECT — user_id comes from the authenticated session
export async function POST(req: Request) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { title } = await req.json();
  await supabase.from('posts').insert({ user_id: user.id, title });
}

Mistake 4: Forgetting DELETE Policies

Many developers write SELECT and INSERT policies but forget DELETE. Without an explicit DELETE policy (when RLS is enabled), users cannot delete anything — which sounds safe until you realize some apps need controlled deletion, and developers bypass RLS with service_role as a workaround:

-- Add explicit DELETE policies scoped to the owner
CREATE POLICY "Owner delete" ON posts
  FOR DELETE USING (auth.uid() = user_id);

How CheckVibe Detects Supabase Issues

CheckVibe includes a dedicated Supabase security scanner that runs automated checks against your live application. It detects:

  • Exposed keys — Scans your client-side JavaScript bundle for service role keys, database connection strings, and other secrets that should be server-only
  • RLS configuration — Detects publicly accessible Supabase endpoints and tests for missing or overly permissive RLS policies
  • Auth configuration — Checks for email confirmation settings, password requirements, and OAuth misconfigurations
  • Security headers — Verifies CSP, HSTS, X-Frame-Options, and other headers that protect against common web attacks
  • API exposure — Maps your Supabase API surface and flags endpoints that accept unauthenticated requests

The scanner runs alongside 35 other security checks covering security headers, XSS, CORS misconfigurations, API security, and more. You can run a free scan in under a minute.

FAQ

Is Supabase secure by default?

Partially. Supabase provides strong primitives — PostgreSQL, RLS, JWT auth, encrypted connections. But several critical features require explicit configuration. RLS is disabled on new tables. Email confirmation is off by default. Password minimum length defaults to 6 characters. Supabase gives you the tools, but you need to use them.

What happens if I don't enable RLS?

Without RLS, the PostgREST API serves your table data to anyone who has your Supabase URL and anon key. Since the anon key is public (it is in your JavaScript bundle), this means anyone can query, insert, update, or delete rows in your table. They do not need to be authenticated. They just need to know the table name.

Can someone steal my Supabase anon key?

They do not need to steal it — it is already in your client-side code by design. Open DevTools, search the JavaScript bundle for eyJ, and you will find it. The anon key is meant to be public. Your security comes from RLS policies, not from keeping the anon key secret. The key you must protect is the service_role key.

How do I test my RLS policies?

Use the Supabase SQL Editor with different roles to simulate access. You can also write integration tests:

-- Test as an authenticated user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';

-- This should only return the user's own rows
SELECT * FROM profiles;

-- This should fail (inserting for a different user)
INSERT INTO profiles (id, name) VALUES ('different-user-uuid', 'Hacker');

-- Reset
RESET role;

Alternatively, use the Supabase Dashboard's RLS policy debugger or write automated tests that make API calls with different user tokens and verify the responses.

How often should I audit my Supabase security?

At minimum, audit after every schema change (new tables, new columns, new policies). Ideally, run automated scans on every deployment. Security configurations drift over time — a new developer adds a table without RLS, a migration drops a policy, a feature flag exposes an admin endpoint. Continuous scanning catches these regressions before attackers do.

TL;DR Checklist

Quick reference for your next Supabase launch:

  • [ ] RLS enabled on every table in public schema
  • [ ] Restrictive RLS policies for SELECT, INSERT, UPDATE, DELETE
  • [ ] Service role key only used server-side (never NEXT_PUBLIC_)
  • [ ] Client bundle audited for leaked secrets
  • [ ] Email confirmation enabled
  • [ ] Password minimum length set to 8+ characters
  • [ ] MFA configured for sensitive operations
  • [ ] Storage bucket policies restrict access per user
  • [ ] Signed URLs used for private files (not public URLs)
  • [ ] Edge Function inputs validated and sanitized
  • [ ] Rate limiting configured on auth and API endpoints
  • [ ] SSL enforcement enabled on database connections
  • [ ] Atomic database functions replace read-then-write patterns
  • [ ] Supabase Security Advisor run and clean
  • [ ] Automated security scans running on a schedule

Security is not a one-time task. It is a practice. Set up automated scanning, review your policies on every schema change, and treat this checklist as a living document.

Need a quick security check? Run a free CheckVibe scan — it takes less than a minute and covers Supabase-specific issues alongside 35 other security checks for your Next.js + Supabase application.

Is your app vulnerable?

Paste your URL and get a security report in 30 seconds. 100+ automated checks, AI-powered fix prompts for Cursor & Copilot.

Scan Your App Free