How to Secure Your Next.js + Supabase App: A Complete Security Checklist
The Next.js + Supabase stack is fast to ship and easy to leak. This end-to-end checklist covers RLS, the anon vs service key, auth, headers, and the bundle — with real code.

The stack everyone ships — and the holes everyone leaves
Next.js + Supabase is a fantastic combination: a React framework with server rendering, and a Postgres database the browser can talk to directly. That directness is the catch. Your database is one RLS policy away from the public internet, and Next.js gives you a dozen ways to leak a secret. Here's the full pass.
Part 1: Database (Supabase)
Enable RLS on every table
The anon key ships in your client bundle, so without Row Level Security anyone can query your tables.
alter table public.profiles enable row level security;
alter table public.posts enable row level security;
Confirm there are no holdouts:
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
Zero rows for real tables, or you have a public table.
Write owner-scoped policies
create policy "read own posts" on public.posts
for select using (auth.uid() = user_id);
create policy "insert own posts" on public.posts
for insert with check (auth.uid() = user_id);
create policy "update own posts" on public.posts
for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
using controls which rows are visible; with check validates rows being written. You generally need both for writes.
Guard the service_role key with your life
The service_role key bypasses RLS. It must only ever exist server-side:
grep -rn "service_role\|SERVICE_ROLE_KEY" ./app ./components ./src
A single hit in client code is critical. Use it only in Route Handlers, Server Actions, and server utilities — never in a Client Component or anything NEXT_PUBLIC_.
Part 2: The key boundary
Use the right client in the right place:
// Browser / Client Components — anon key, RLS-protected
import { createBrowserClient } from "@supabase/ssr";
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Server only — service role, full access, must stay off the client
import "server-only";
import { createClient } from "@supabase/supabase-js";
const admin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // no NEXT_PUBLIC_ prefix, ever
);
The server-only import makes the build fail if this module is ever pulled into client code.
Part 3: Auth and sessions
- Use
@supabase/ssrto manage sessions in HttpOnly cookies, notlocalStorage— that defeats XSS token theft. - Enable email confirmation and a sensible minimum password length in Auth settings.
- Set tight redirect URLs (no wildcards) to prevent token-stealing redirects.
- Refresh and validate the session on the server before trusting it:
const { data: { user } } = await supabase.auth.getUser(); // verifies the JWT server-side
if (!user) redirect("/login");
Prefer getUser() (which validates) over trusting getSession() data blindly on the server.
Part 4: Next.js layer
Authorize, don't just authenticate
Even with RLS, add authorization in Route Handlers and Server Actions for anything using the service key or doing privileged work:
"use server";
export async function deletePost(id: string) {
const { data: { user } } = await supabaseServer.auth.getUser();
if (!user) throw new Error("Unauthorized");
// RLS still enforces ownership on the query — defense in depth
await supabaseServer.from("posts").delete().eq("id", id).eq("user_id", user.id);
}
Security headers
// next.config.js
async headers() {
return [{
source: "/:path*",
headers: [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "Content-Security-Policy", value: "default-src 'self'" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
}];
}
Don't over-fetch
Select only the columns the UI needs. Never serialize internal flags or other users' data to the client.
Part 5: Verify it works
- With the anon key and no session, query a protected table — you should get nothing back.
- Log in as user A, try to read user B's row by ID — denied by RLS.
grepthe client bundle forservice_role— clean.curl -sIyour URL — headers present.- Inspect cookies —
HttpOnly; Secure; SameSite.
Checklist
- RLS on every table, with owner-scoped policies
- service_role key server-only, never
NEXT_PUBLIC_ -
server-onlyguarding admin modules - Sessions in HttpOnly cookies;
getUser()validated server-side - Tight redirect URLs, email confirmation on
- Authorization in handlers/actions + RLS as defense in depth
- Security headers set and verified
- No over-fetched data serialized to the client
Trace each request across both trust boundaries
The safest way to review this stack is to follow a concrete action from browser to database. For “update profile,” record the browser key, cookie behavior, Route Handler or Server Action, server client, SQL operation and RLS policy. Then run the same path as anonymous, owner, different user and administrator. A control gap at any hop can undo the others.
Use the publishable/anon client for operations intended to run under the user's RLS context. If server code creates a service-role client, it has stepped outside that boundary and must recreate every authorization rule before the query. Prefer the user's session client whenever elevated access is unnecessary; reserve service-role operations for narrow administrative jobs with explicit inputs and logging.
Test policies as code
Keep policy changes in migrations and pair them with integration tests against a disposable project or database. Seed user A and user B, then assert denied cross-owner reads, updates and deletes as well as permitted owner actions. Include storage objects, Realtime subscriptions, views and RPC functions—teams often test tables and forget alternate paths to the same data.
For security definer functions, set a safe search_path, qualify objects and revoke execution from roles that do not need it. Review grants as well as policies. Supabase's API guidance notes that schemas and privileges can reduce the surface exposed by the generated data API.
Keep session and cache semantics aligned
Validate the user on the server before protected work and avoid sharing personalized responses through a public cache. Revalidation and caching are correctness features with security consequences: a response computed for one identity must not be reused for another. Select only columns the component needs, and never pass privileged rows wholesale into a Client Component.
A release proof stronger than a checklist
Before launch, preserve the migration version, policy-test output, production response headers, protected-route test, bundle secret scan and key-rotation owner. Repeat it after any auth, database or caching change. Automated public scanning can catch exposed behavior, but only your identity matrix proves that user B cannot act on user A's data.
Use the focused Supabase pre-launch checklist when database work dominates, and the Next.js best practices for framework-only applications.
Scan it with Troja
Troja tests this exact stack — probing your Supabase tables with the anon key to confirm RLS actually denies access, and checking your Next.js app for leaked keys, missing headers, and unprotected routes — with a fix prompt for each finding. Scan your app and ship with confidence.
Frequently asked questions
Should server-side Supabase queries always use the service role?
No. Prefer a client carrying the user's session when RLS should enforce user permissions. Use the service role only for narrow trusted operations that perform explicit authorization and cannot safely run under the user role.
How should I test Next.js and Supabase authorization?
Trace real actions end to end and run each as anonymous, owner, another user and administrator. Assert both permitted behavior and denied cross-owner reads, writes and deletes.
Can Next.js caching leak Supabase data?
Yes, if personalized output is placed in a cache shared across identities. Keep user-specific data out of public caches and verify cache and revalidation behavior with two accounts.
What evidence should be kept for launch?
Retain the database migration, RLS and grants test output, protected-route tests, production header capture, client-bundle secret scan and named owners for credentials and incident response.
Sources and verification notes
Product capabilities are vendor-attributed and source-dated. Technical guidance uses primary documentation or vendor-neutral standards.
- Next.js data security guidePrimary framework guidance for data access, authorization and safe transfer objects.
- Supabase Row Level SecurityPrimary database guidance for policy behavior and auth context.
- Securing the Supabase Data APIPrimary source for schemas, grants and API defense in depth.
- Supabase production checklistPrimary operational checklist for a production Supabase project.
Run the scan this post is about.
Free, no signup. See what's hiding inside your walls in ~30 seconds.
Keep reading
All posts
Supabase Security Checklist: 15 Things to Check Before Launch
Supabase exposes your Postgres database to the browser. That's powerful — and dangerous if RLS is off. Here are 15 concrete checks, with real policies, before you go live.
Read
Next.js Security Best Practices: 10 Things Most Developers Miss
Next.js is secure by default — until you reach for client components, route handlers, and middleware. Here are ten places the framework's footguns hide, with the correct patterns.
Read
Vercel Deployment Security: The Production Checklist for Next.js
Vercel makes deploying trivial — and makes a few security footguns trivial too. Here's the production checklist: env scoping, headers, preview protection, and the bundle leak everyone hits.
Read