Next.js + Supabase + Vercel security — the 2026 playbook
Next.js + Supabase + Vercel is the 80% stack of vibe-coded apps. Lovable, Bolt, v0, Cursor, and Replit Agent all default to variations of it. The convenience is real — auth, database, deploy, and edge CDN in one tight integration — but the security surface has three specific failure modes that show up in nearly every audit. First, Supabase Row-Level-Security is off, or on but scoped wrong, on at least one table. Second, a service-role key or a third-party API key shipped inside the client bundle via a mis-prefixed NEXT_PUBLIC_ environment variable. Third, a Server Action or API route made it past code review without an explicit auth() call — usually because the AI that generated it optimized for shortest-compiling code, not safety. This playbook covers the defenses against those three, plus the ten secondary checks that come up in follow-on reviews. It is stack-specific — configurations and code snippets are the exact versions you need for Next.js 15 App Router with Supabase's `@supabase/ssr` package on Vercel.
What breaks on this stack
Supabase RLS disabled on one or more tables
A single table with RLS off exposes every row to anyone holding your anon key — which ships with your client bundle by design. Fix: `alter table <t> enable row level security` on every table, then a default-deny policy `create policy deny_all on <t> for all using (false)` layered under explicit allow policies like `create policy users_own on <t> for all using (auth.uid() = user_id)`.
Read the guide →Service-role or third-party key behind NEXT_PUBLIC_
Every variable starting with NEXT_PUBLIC_ is bundled into client JavaScript. Service-role keys, OpenAI, Stripe, Anthropic, or AWS keys with this prefix are effectively published on the public internet. Fix: proxy through server routes (`app/api/*`) that read the unprefixed environment variable.
Read the guide →Server Actions without auth() assertion
Server Actions accept cross-origin POSTs by default. Every sensitive action must start with `const session = await auth(); if (!session) throw new Error('unauth')` — and every mutation must also verify the resource belongs to the caller.
Read the guide →CVE-2025-29927 middleware bypass
A single header — `x-middleware-subrequest` — bypasses every Next.js middleware including auth, on versions below the patched line. Upgrade to Next.js 15.2.3+, 14.2.25+, 13.5.9+, or 12.3.5+, or reject the header explicitly in middleware.
Read the guide →Storage buckets default-public
Supabase Storage buckets need RLS policies just like tables. Many tutorials leave buckets public because the first example app did. Fix: scope bucket policies to the owning user and switch to signed URLs for downloads.
Missing security headers
Vercel does not set HSTS, CSP, X-Frame-Options, or Referrer-Policy by default. Add them via `headers()` in next.config.mjs. CSP with nonces requires wiring through middleware.
Read the guide →Rate limits absent on paid-API routes
Any route that calls OpenAI, Stripe, Anthropic, or your own paid vendor can be looped by an attacker until your monthly budget burns. Cap requests per IP and per user at the middleware layer, and set vendor-side spend limits as a backstop.
Pre-ship checklist
- RLS enabled on every Supabase table (select, insert, update, delete)
- Default-deny policy layered under explicit allow policies per table
- Service-role key never used in client code or NEXT_PUBLIC_ vars
- Tenant-scoped RLS (auth.uid() + tenant claim from JWT), not just user_id
- Every NEXT_PUBLIC_ env var audited; secrets proxied via server routes
- Every Server Action and API route has explicit auth() assertion
- Middleware matcher covers every protected path
- CVE-2025-29927 header rejected in middleware
- Storage buckets have RLS policies + signed-URL downloads
- Security headers set via next.config.mjs (HSTS, CSP, XFO, XCTO, Referrer-Policy)
- CSP nonce wiring through middleware if using inline scripts
- Rate limits on every paid-API proxy route (per-IP + per-user)
- HSTS preload-list submitted at hstspreload.org
- Supabase JWT expiration set to minutes, not hours, for sensitive sessions
- Vercel Environment Variables reviewed for Production vs Preview scoping
Starter config
// middleware.ts — nonce-based CSP + CVE-2025-29927 block
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
if (req.headers.has("x-middleware-subrequest")) {
return new NextResponse("rejected", { status: 400 });
}
const nonce = crypto.randomUUID().replace(/-/g, "");
const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'`;
const headers = new Headers(req.headers);
headers.set("x-nonce", nonce);
const res = NextResponse.next({ request: { headers } });
res.headers.set("Content-Security-Policy", csp);
return res;
}
// next.config.mjs — HSTS + headers
const securityHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
];
export default {
async headers() { return [{ source: "/:path*", headers: securityHeaders }]; },
};
-- supabase/migrations/0001_rls.sql — default-deny + user-own pattern
alter table public.orders enable row level security;
create policy deny_all on public.orders for all using (false) with check (false);
create policy users_own on public.orders for all
using (auth.uid() = user_id) with check (auth.uid() = user_id);