Coreframe Labs

Automated security audit

Security audits for Next.js App Router

The App Router moved authorization decisions into places that look like ordinary React. A Server Action is a public HTTP endpoint with a function signature — and it is remarkably easy to ship one that any logged-out visitor can invoke. These are the four patterns that come up again and again.

Check a file now — free

Paste one file. The free tier runs a single security pass and returns up to three findings, each with the line of code that evidences it. Nothing is stored, nothing is used for training, and detected secrets are redacted before analysis.

Paste one file0 / 400 lines

Not stored. Not trained on. Secrets redacted before analysis.

What we find most often in Next.js App Router

Each of these is a real pattern, with the code that causes it and the change that fixes it.

1. Server Actions treated as private functions

Symptom
Nothing looks wrong. The action is only imported by one form, and that form only renders for admins.

The pattern

"use server";

export async function deleteProject(projectId: string) {
  await db.project.delete({ where: { id: projectId } });
  revalidatePath("/projects");
}

Why it bites
`"use server"` publishes the function as an RPC endpoint with a stable, guessable action ID. Whether the form renders is irrelevant — anyone can POST to it directly with any projectId. The import graph is not an access control list.

The fix

"use server";

export async function deleteProject(projectId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  // Authorize the *object*, not just the caller.
  const project = await db.project.findUnique({
    where: { id: projectId },
    select: { ownerId: true },
  });
  if (project?.ownerId !== session.user.id) throw new Error("Forbidden");

  await db.project.delete({ where: { id: projectId } });
  revalidatePath("/projects");
}

2. Server-only secrets crossing into a Client Component

Symptom
Everything works locally and in production. The key sits in the JS bundle, readable by anyone who opens devtools.

The pattern

// app/dashboard/page.tsx  (Server Component)
export default async function Page() {
  const config = { apiKey: process.env.STRIPE_SECRET_KEY };
  return <Billing config={config} />;   // Billing is "use client"
}

Why it bites
Props passed from a Server Component to a Client Component are serialized into the RSC payload and shipped to the browser. The variable has no NEXT_PUBLIC_ prefix, so it looks safe — but the boundary crossing, not the prefix, is what exposes it.

The fix

// Keep the secret server-side; send only the result across the boundary.
export default async function Page() {
  const intent = await stripe.paymentIntents.create({ amount: 2000, currency: "gbp" });
  return <Billing clientSecret={intent.client_secret} />;
}

// Or mark the module so an accidental client import fails at build time:
import "server-only";

3. Middleware as the only authorization layer

Symptom
Auth works. Then someone adds a route handler under a path the matcher doesn't cover, and it is wide open.

The pattern

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

// app/api/users/[id]/route.ts — not matched, no check of its own
export async function GET(_: Request, { params }: { params: { id: string } }) {
  return Response.json(await db.user.findUnique({ where: { id: params.id } }));
}

Why it bites
Middleware runs on a path allowlist. Every route added outside that list is unauthenticated by default, and the matcher is the kind of config nobody updates during a feature branch. Middleware is a redirect layer; it is not an authorization boundary.

The fix

// Authorize inside the handler — the only place that can't be routed around.
export async function GET(_: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) return new Response("Unauthorized", { status: 401 });
  if (session.user.id !== params.id && !session.user.isAdmin) {
    return new Response("Forbidden", { status: 403 });
  }
  return Response.json(await db.user.findUnique({ where: { id: params.id } }));
}

4. Cache keys that omit the viewer

Symptom
Under load, users intermittently see another account's data. Impossible to reproduce on a single-user staging box.

The pattern

export const getInvoices = unstable_cache(
  async (orgId: string) => db.invoice.findMany({ where: { orgId } }),
  ["invoices"],              // key does not include orgId
  { revalidate: 60 },
);

Why it bites
The key array is the whole cache identity — arguments are not folded in automatically. Every org shares one entry, so the first org to populate it serves its invoices to everyone for the next 60 seconds. It only appears under concurrent multi-tenant traffic, which is why it reaches production.

The fix

export const getInvoices = unstable_cache(
  async (orgId: string) => db.invoice.findMany({ where: { orgId } }),
  ["invoices"],
  { revalidate: 60, tags: ["invoices"] },
);
// Key must vary with the tenant:
const cached = unstable_cache(fn, ["invoices", orgId], { revalidate: 60 });

An automated pass reads one file. An audit reads the system.

The tool above is deliberately narrow — one file, one dimension, one pass. The issues that actually take systems down live between files: in the trust boundary nobody documented, the query pattern that only emerges at scale, the cache that was correct until the second tenant arrived. That is what the Coreframe Technical Audit is for — five business days, a written report, a live findings call, and a remediation proposal you can execute with or without us.

£1,500–£2,500 solo · £3,000–£5,000 funded startup · £3,000–£6,000 SME

See what the full audit covers →

Related audits

All stack audits →