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
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.
Run the scan this post is about.
Free, no signup. See what's hiding inside your walls in ~30 seconds.