Most Next.js security advice is a list of headers to paste into next.config.ts. Headers matter, and they're the last section of this article, because they are not where applications actually get compromised.

These are the failures we find repeatedly in real audits, ordered roughly by how much damage they cause.


1. Authorisation that lives only in middleware

The most common serious flaw, and it looks completely reasonable:

typescript
// middleware.ts
export const config = { matcher: ["/dashboard/:path*"] };

Middleware redirects unauthenticated users away from /dashboard. The pages render data. Job done — except the pages are not the only way to reach that data.

Server Actions are independently reachable POST endpoints. Route Handlers have their own URLs. Neither is protected by a matcher covering /dashboard. If your action trusts that it can only be invoked from a page the middleware guards, it's wrong.

The fix: check authorisation where the work happens, in every action and every handler.

typescript
"use server";
import { requireUser } from "@/lib/auth";

export async function deleteLead(id: string) {
  await requireUser();          // not optional, not inherited
  await prisma.lead.delete({ where: { id } });
}

Middleware is a useful outer layer for redirecting humans. It is not an access control boundary. Treat it as UX, and put the real check next to the data.

There's a second, sharper version of this: authentication is not authorisation. requireUser() proves someone is signed in. It does not prove they own the record they just asked you to delete. If your data is multi-tenant, scope every query by the owner:

typescript
const user = await requireUser();
await prisma.lead.delete({ where: { id, ownerId: user.id } });

Without that ownerId, any authenticated user can delete any record by guessing an ID. This is OWASP's Broken Access Control, it's the number one category on their list, and it's the single most common real vulnerability in dashboard applications.


2. Server Actions that trust their input

FormData is attacker-controlled. All of it. Anyone can invoke a Server Action with any payload — the form on your page is a convenience, not a constraint.

typescript
"use server";
import { z } from "zod";

const schema = z.object({
  contactName: z.string().min(1).max(120),
  phone: z.string().min(7).max(30),
  status: z.enum(["NEW", "CONTACTED", "QUALIFIED", "WON", "LOST"]),
});

export async function saveLead(formData: FormData) {
  await requireUser();

  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.issues[0]?.message ?? "Invalid input" };
  }
  // parsed.data is now typed and checked
}

Note the enum. Without it, a crafted request sets status to any string your database accepts. Validation isn't only about rejecting malformed input — it's about constraining the domain of valid input.

The mirror-image mistake is on the way out. Server Action return values are serialised to the client. Returning a whole Prisma record ships every column — internal notes, other users' identifiers, flags you forgot were there. Return the fields the UI needs and nothing else.


3. Webhooks anyone can forge

A webhook URL is not a secret. It appears in logs, dashboards, screenshots, and browser history. If your handler processes whatever arrives, anyone who learns the URL can inject data into your system.

Every serious provider signs its payloads. Verify the signature:

typescript
import { createHmac, timingSafeEqual } from "crypto";

export const runtime = "nodejs";   // crypto is unavailable on Edge

function signatureValid(raw: string, signature: string | null) {
  const secret = process.env.WEBHOOK_SECRET;
  if (!secret || !signature) return false;   // fail closed

  const expected = `sha256=${createHmac("sha256", secret).update(raw).digest("hex")}`;

  // Length check first: timingSafeEqual throws on mismatched lengths, and a
  // thrown error becomes a 500 — which most providers treat as retryable.
  return expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

export async function POST(request: Request) {
  const raw = await request.text();          // raw bytes, BEFORE parsing
  if (!signatureValid(raw, request.headers.get("x-hub-signature-256"))) {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }
  const payload = JSON.parse(raw);
  // ...
}

Three details that are each individually sufficient to break this:

  • Hash the raw body. JSON.parse then JSON.stringify will not reproduce the sender's exact bytes. Your HMAC will never match.
  • Use timingSafeEqual. String comparison short-circuits and leaks signature bytes through response timing.
  • Fail closed on a missing secret. A misconfigured environment variable should reject everything, not accept everything.

4. Error messages that hand over your schema

typescript
// Don't do this
catch (error) {
  return Response.json({ error: "Failed: " + String(error) }, { status: 500 });
}

A Prisma error string contains table names, column names, and constraint names. You've just published your schema to anyone who can trigger an exception — which is usually anyone, since malformed input is easy to send.

Log the detail server-side, return something opaque:

typescript
catch (error) {
  logger.error("lead.submit_failed", { error: String(error) });
  return Response.json({ error: "Submission failed" }, { status: 500 });
}

The same applies to stack traces in responses and verbose validation errors on public endpoints.


5. Public write endpoints with no rate limit

Any unauthenticated endpoint that writes to your database — a contact form, a lead capture, a newsletter signup — will eventually be found and abused. At minimum you get junk records; at worst you get a bill, because each write costs you database capacity and each submission may trigger an email or an AI call.

An in-memory limiter is a speed bump, and worth knowing its limits:

typescript
const hits = new Map<string, { count: number; reset: number }>();

export function rateLimit(key: string, limit = 30, windowMs = 60_000) {
  const now = Date.now();
  const entry = hits.get(key);
  if (!entry || entry.reset < now) {
    hits.set(key, { count: 1, reset: now + windowMs });
    return { ok: true, retryAfter: 0 };
  }
  entry.count++;
  return { ok: entry.count <= limit, retryAfter: Math.ceil((entry.reset - now) / 1000) };
}

On serverless this is per-instance. Each lambda has its own Map, so the effective limit is your limit multiplied by the number of running instances, and it resets on every cold start. It also grows unboundedly — one entry per distinct IP, never pruned. For real protection use a shared store (Redis, or your platform's rate-limit primitive) and add a CAPTCHA on genuinely public forms.


6. Environment variables that shouldn't be public

NEXT_PUBLIC_ prefixed variables are inlined into the JavaScript bundle and shipped to every visitor. This is intended behaviour and it catches people constantly.

A Supabase anon key is designed to be public — that's fine, provided Row Level Security is actually enabled on your tables. A service role key is not, and putting one behind NEXT_PUBLIC_ hands full database access to anyone who opens DevTools.

Audit it directly:

bash
grep -r "NEXT_PUBLIC_" --include="*.ts" --include="*.tsx" .

Every result should be something you'd be comfortable printing on the homepage.

While you're there: check your repository for credentials committed to source files. Short-lived tokens expire and feel harmless, but the habit is the vulnerability, and git history is permanent. Move them to environment variables and rotate anything that was ever committed.


7. Now the headers

Genuinely worth setting, and genuinely not where the risk is.

typescript
// next.config.ts
const securityHeaders = [
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "X-Frame-Options", value: "SAMEORIGIN" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains; preload",
  },
  {
    key: "Permissions-Policy",
    value: "camera=(), microphone=(), geolocation=()",
  },
];

export default {
  async headers() {
    return [{ source: "/(.*)", headers: securityHeaders }];
  },
};

The one that actually stops XSS is Content-Security-Policy, and it's the one usually omitted because it's hard. Next.js supports a nonce-based CSP through middleware; it takes an afternoon and it's the difference between a header list that looks secure and one that is.

Be aware Strict-Transport-Security with preload is difficult to reverse. Don't add preload until you're certain every subdomain will serve HTTPS indefinitely.


A short audit you can run today

  1. Does every Server Action call an auth check as its first statement?
  2. Does every query scope by owner, not just by ID?
  3. Does every action validate input with a schema, including enums?
  4. Do webhook handlers verify signatures against the raw body?
  5. Do error responses leak driver or schema detail?
  6. Do public write endpoints have rate limiting?
  7. Is every NEXT_PUBLIC_ variable genuinely safe to publish?
  8. Do you have a CSP, or only the easy headers?

Most applications fail three or four of these. The first two are where the real breaches come from.


Want an audit?

We review and harden Next.js applications — access control, input validation, webhook integrity, and a CSP that actually works. Talk to us about web development.