Supabase is secure at the platform level, but your app is only as secure as your configuration. Two things decide it: Row Level Security enabled with correct policies on every table, and your secret key kept server-side. Get those wrong and your database is effectively public — the client key is meant to be public by design.
The failure mode is almost always the same. Developers ship fast, RLS gets forgotten on a new table, and anyone who opens DevTools can read the whole table through the auto-generated REST API. This checklist covers the 15 things to verify before your Supabase-powered app goes live.
Yes — with an important qualifier. Supabase runs on PostgreSQL with TLS in transit, encryption at rest, SOC 2 Type II compliance, and a hardened auth service. The platform itself is not the weak link.
What breaks is the shared-responsibility boundary. Supabase exposes your Postgres schema directly to the internet through PostgREST, which means your database access control is your application access control. There is no application server in between doing the filtering for you unless you put one there.
Look at how Supabase apps actually get breached and you find the same three causes, none of them platform flaws:
USING (true) to unblock themselves, and shipped it.So the honest answer: Supabase is safe when RLS is on and your policies are correct. It is dangerously unsafe when they are not, and it fails open rather than closed — a missing policy on a table without RLS means "everyone can read this," not "nobody can." You can check the RLS side of this in about a minute with the free Supabase RLS checker.
Key confusion causes real breaches, and there are now two generations of keys in circulation. Get this straight before anything else.
anon and service_roleProjects created before Supabase introduced the new format use two JWT-formatted keys, both signed with your project's JWT secret:
| Key | Ships to the browser? | Respects RLS? |
|---|---|---|
anon | Yes — public by design | Yes, via the anon / authenticated Postgres roles |
service_role | Never | No — it holds BYPASSRLS and skips every policy |
The anon key is not a secret. It is in your JavaScript bundle, and Supabase intends it to be. It only grants what your RLS policies grant. The service_role key is the opposite: per Supabase's docs it uses the Postgres BYPASSRLS attribute, "skipping any and all Row Level Security policies you attach." A leaked service_role key is a full database compromise.
sb_publishable_... and sb_secret_...Newer projects use a clearer format that makes the safe/unsafe split obvious from the prefix:
# Safe to expose — web pages, mobile apps, CLIs, source code
sb_publishable_xxxxxxxxxxxxxxxxxxxxxx
# Backend only — servers, Edge Functions, secured admin APIs
sb_secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
The semantics map one-to-one onto the legacy pair: publishable behaves like anon (RLS applies), secret behaves like service_role (RLS is bypassed). The practical improvements are that the prefix is self-documenting, secret keys can be created and deleted individually, and secret keys are no longer derived from the JWT signing key — Supabase's docs note that publishable and secret keys "no longer are based on the JWT signing key and can be independently managed."
Supabase's docs state the legacy anon and service_role keys will be deprecated by the end of 2026. Creating the new keys adds them alongside the old ones without breaking anything, so you can migrate incrementally.
// Browser / client components — publishable or anon key
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY! // or ..._ANON_KEY
);
// Server-only: route handlers, server actions, Edge Functions, cron
// NOTE: no NEXT_PUBLIC_ prefix. Ever.
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!, // or SUPABASE_SERVICE_ROLE_KEY
{ auth: { persistSession: false } }
);
A useful rule: if a key can bypass RLS, it must never appear in any file that could be imported by a client component. In Next.js, add import 'server-only' at the top of the module that reads it — the build then fails instead of silently shipping the key.
anon and service_role to keep a consistent setup.Rotate immediately if a secret key ever touched a client bundle, a public repo, a CI log, a screenshot, or a support ticket. Assume anything committed to git is compromised even after a force-push.
Every item in the checklist below flows from three facts about how Supabase is wired. Keep them in front of you as you work through it:
anon key (or sb_publishable_... on newer projects) ships in your JavaScript bundle. Anyone can see it. It is not a secret and was never meant to be.service_role (or sb_secret_...) ignores RLS and every policy you write. If it leaks, game over.Miss any one of them and the rest of your hardening does not matter.
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.
For multi-tenant apps, "own rows" is not enough — you need tenant isolation, where a user sees every row belonging to their organization and nothing belonging to another. The naive version queries the membership table directly inside the policy, which triggers infinite recursion the moment that table has RLS of its own. Use a SECURITY DEFINER helper instead:
-- Helper runs as the definer, so it is not re-filtered by RLS on org_members
CREATE OR REPLACE FUNCTION public.user_org_ids()
RETURNS setof uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT org_id FROM org_members WHERE user_id = auth.uid();
$$;
-- Tenant isolation: read anything in an org you belong to
CREATE POLICY "Members read org documents"
ON documents FOR SELECT
TO authenticated
USING (org_id IN (SELECT public.user_org_ids()));
-- Writes must land in an org you belong to — and be stamped with your own id
CREATE POLICY "Members create org documents"
ON documents FOR INSERT
TO authenticated
WITH CHECK (
org_id IN (SELECT public.user_org_ids())
AND created_by = (SELECT auth.uid())
);
Two details worth copying from those policies:
TO authenticated. Without it, the policy is also evaluated for the anon role on every anonymous request — wasted work, and one more path to reason about.auth.uid() in a subselect: (SELECT auth.uid()). Postgres then evaluates it once per query as an InitPlan instead of once per row. On a large table this is the difference between a policy that scales and one that times out.Test every policy before you trust it. In the SQL Editor you can impersonate a user by setting the request claims and switching roles:
-- Test as an authenticated user
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';
-- Should return only this user's rows
SELECT * FROM documents;
-- Should fail: writing into an org you do not belong to
INSERT INTO documents (org_id, created_by, title)
VALUES ('some-other-org-uuid', 'user-uuid-here', 'Should not work');
-- Reset
RESET role;
Every table you add needs this treatment. If you want a second pair of eyes on which of your tables are actually reachable without a policy, the free Supabase RLS checker probes your live project and reports what a stranger with your public key can read.
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, and Supabase's own docs say anything under 8 is not recommended.
All of this is configurable under Authentication > Sign In / Providers > Email in the Dashboard. Four settings matter:
!@#$%^&*()_+-=[]{};'\:"|<>?,./`~.supabase.auth.reauthenticate()) or the current password before a change goes through. Without it, anyone who gets a live session — a borrowed laptop, a stolen token — can lock the real owner out.One thing to know before you tighten the rules: existing users are not locked out. They can keep signing in, but Supabase returns a WeakPasswordError on signInWithPassword explaining why the password no longer meets your policy, which gives you a natural place to prompt for an upgrade.
These settings are enforced server-side, which is what actually counts. Mirror them in your signup form only so users get feedback before a round trip:
// UX only — the Supabase Auth settings above are the real enforcement
function validatePassword(password: string): string | null {
if (password.length < 12) return 'Password must be at least 12 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';
if (!/[!@#$%^&*()_+\-=[\]{};':"|<>?,./`~\\]/.test(password)) {
return 'Password must contain a symbol';
}
return null;
}
Never do the reverse — validating only on the client while leaving the Supabase minimum at 6 means any request that skips your form gets a 6-character password.
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.
Yes — it is designed to be public and already sits in your JavaScript bundle. Open DevTools, search for eyJ or sb_publishable_, and you will find it. Exposure is not the vulnerability; a missing RLS policy is. The key only grants what your policies grant. The key that must never leak is service_role (or sb_secret_...), which bypasses RLS entirely.
Your table is served to the public internet. Supabase auto-generates a REST endpoint for every table in the public schema, and with RLS off, anyone holding your project URL and public key can select, insert, update, and delete rows without authenticating. They only need the table name, which is often guessable or visible in your bundle. RLS off means open, not closed.
For the new format, create a replacement in Settings > API Keys, deploy it everywhere, then delete the old secret key — deletion is irreversible, so ship first and delete second. Legacy anon and service_role keys stay valid until you explicitly disable them, so the migration is: add publishable and secret keys alongside, move your code across, then disable the legacy pair. Rotating a JWT signing key does not sign existing users out.
Two layers. In the SQL Editor, set the request JWT claims, switch to the authenticated role, and run the same reads and writes your app runs — you should see only that user's rows, and cross-user writes should fail. Then test end to end by calling your API with two different user tokens and confirming neither can reach the other's data. Automate both in CI.
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 schemaSECURITY DEFINER helper, TO authenticated, and (SELECT auth.uid())service_role / sb_secret_...) only used server-side, never NEXT_PUBLIC_anon / service_role keys migrated to publishable + secret (legacy deprecated end of 2026)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-ready 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.