Troja
All posts
Next.jsMar 3, 2026·17 min read

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.

By Security Desk

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/ssr to manage sessions in HttpOnly cookies, not localStorage — 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

  1. With the anon key and no session, query a protected table — you should get nothing back.
  2. Log in as user A, try to read user B's row by ID — denied by RLS.
  3. grep the client bundle for service_role — clean.
  4. curl -sI your URL — headers present.
  5. Inspect cookies — HttpOnly; Secure; SameSite.

Checklist

  • RLS on every table, with owner-scoped policies
  • service_role key server-only, never NEXT_PUBLIC_
  • server-only guarding 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.

Free scan · no signup · results in ~30 seconds
How to Secure Your Next.js + Supabase App: A Complete Security Checklist — Troja