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.
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:
Understanding this architecture is non-negotiable. Every item in this checklist flows from these three facts.
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.
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.
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_.
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:
// 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.
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:
// 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 }
);
}
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;
}
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');
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.
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.
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.
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:
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.
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.
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 });
}
Supabase includes a built-in Security Advisor under Database > Security Advisor in the Dashboard. It checks for:
SECURITY DEFINER that do not set search_pathRun this before every release. It takes 30 seconds and catches the most common configuration mistakes.
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:
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.
These are real patterns we see repeatedly in scanned applications.
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);
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} />;
}
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 });
}
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);
CheckVibe includes a dedicated Supabase security scanner that runs automated checks against your live application. It detects:
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.
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.
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.
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.
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.
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.
Quick reference for your next Supabase launch:
public schemaNEXT_PUBLIC_)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.
Paste your URL and get a security report in 30 seconds. 100+ automated checks with AI-powered fix prompts.
Scan your site freeRelated articles
The essential security checklist for SaaS founders shipping their first product. Covers auth, data protection, API security, payments, and monitoring — no security team needed.
A production security checklist for Next.js apps on Vercel. Covers environment variables, headers, deployment protection, edge middleware, and common misconfigurations.
Step-by-step security checklist for Next.js apps with Supabase. Covers RLS policies, API key exposure, auth hardening, security headers, and common mistakes.