Naar de inhoud
CodeAuditAgent
Alle artikelen

Beveiligingschecklist voor de Next.js App Router

Een beveiligingschecklist voor App Router-apps: server actions, route handlers, middleware, env-vars, CSRF, headers, webhooks en caching per gebruiker.

· 8 min. leestijd · Lina Source LLC

De App Router verplaatste veel code van de browser terug naar de server. Dat is beveiligingstechnisch vooral winst: queries, secrets en bedrijfslogica draaien nu ergens waar gebruikers niet kunnen meelezen. Het vervaagde ook de grens tussen wat een openbaar endpoint is en wat er alleen uitziet als een functieaanroep. De meeste serieuze bugs in Next.js-apps komen uit die vervaging.

Deze checklist behandelt de problemen die we het vaakst in App Router-codebases zien, in de volgorde waarin het loont om ze te controleren. Niets ervan is exotisch; elk punt is een plek waar het gemak van het framework een vertrouwensgrens verbergt.

1. Server actions zijn openbare endpoints

Een functie met 'use server' compileert naar een HTTP-endpoint. Iedereen die je site kan laden, kan de action-ID vinden en hem met willekeurige argumenten aanroepen, of je interface de knop die hem gebruikt nu ooit rendert of niet. Een formulier verbergen voor niet-beheerders beschermt de action erachter niet.

Elke action heeft dezelfde drie stappen nodig als elke API-handler: authenticeer de aanroeper, valideer de invoer en autoriseer het specifieke record dat wordt geraakt. Zet die controles in het lichaam van de action zelf. Een controle in de paginacomponent die het formulier rendert, draait wanneer de pagina wordt geladen, niet wanneer de action wordt aangeroepen, en beschermt dus niets.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // De eigenaarscontrole is onderdeel van de schrijfactie, geen aparte lookup
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

Let op helperbestanden die veel actions exporteren. Elke geëxporteerde functie in een 'use server'-module is aanroepbaar, inclusief die ene die iemand voor een intern script toevoegde en daarna vergat.

2. Route handlers hebben dezelfde controles nodig

Route handlers in app/api zijn duidelijker herkenbaar als endpoints, maar ze delen dezelfde faalwijze: een dynamisch segment wordt gebruikt om een record te laden zonder te controleren wie de eigenaar is. In recente Next.js-versies is params een Promise; await hem, valideer hem en baken de query af op de aanroeper.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

Geef 404 in plaats van 403 terug voor records die de aanroeper niet bezit, zodat het endpoint niet bevestigt welke ID’s bestaan. Vertrouw nooit op params, searchParams, headers of cookies voor autorisatiebeslissingen; het is allemaal invoer waar de aanvaller controle over heeft.

3. Middleware is op zichzelf geen authenticatiegrens

Middleware (in Next.js 16 hernoemd naar proxy) is handig om uitgelogde gebruikers door te sturen en headers te zetten. Het hoort niet de enige plek te zijn waar autorisatie gebeurt. Matchers zijn makkelijk verkeerd te configureren, nieuwe routes komen erbuiten te staan, en server actions posten naar het paginapad, dat niet hoeft te matchen met het patroon dat je in gedachten had. In 2025 liet CVE-2025-29927 zien dat een geprepareerde interne header sommige Next.js-versies middleware volledig kon laten overslaan.

Beschouw middleware als een gemakslaag. De echte controle hoort naast de data: in elke action en handler, of beter nog in een datatoegangslaag waar elke serverkant-lezing doorheen gaat. Een datatoegangslaag is één server-only module die functies als getProjectForUser(projectId) aanbiedt en de sessiecontrole en het eigenaarsfilter intern uitvoert. Pagina’s, actions en route handlers roepen die aan in plaats van de databaseclient rechtstreeks, zodat een nieuwe route de controle niet kan vergeten: er is geen ongecontroleerd pad om te vergeten.

4. Houd servercode op de server

Elke omgevingsvariabele met het voorvoegsel NEXT_PUBLIC_ wordt tijdens de build in de JavaScript-bundle gezet en is zichtbaar voor elke bezoeker. Dat klopt voor een publiceerbare Stripe-sleutel of een analytics-ID, en is een lek voor al het andere. Zoek in de codebase naar NEXT_PUBLIC_ en controleer of elke waarde veilig op een billboard zou staan. De omgekeerde fout komt ook voor: een variabele zonder voorvoegsel wordt in een clientcomponent gelezen, komt in de browser als undefined terug, en iemand hernoemt hem naar NEXT_PUBLIC_ om de fout te laten verdwijnen. Is een waarde in de browser nodig, vraag dan eerst of de browser hem überhaupt hoort te hebben.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // Geef alleen de velden terug die de UI nodig heeft, nooit de hele rij
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

Het pakket server-only laat de build falen als een clientcomponent de module importeert, wat databaseclients en helpers die secrets lezen ervan weerhoudt in de browser te belanden. Let ook op wat servercomponenten als props aan clientcomponenten doorgeven: alles wat die grens passeert, wordt in de pagina geserialiseerd, dus een volledige gebruikersrij doorgeven stuurt de wachtwoordhash en interne vlaggen naar de browser.

5. CSRF: weet wat het framework afdekt

Server actions accepteren alleen POST, en Next.js vergelijkt de Origin-header met de host voordat ze draaien. Deploy je achter een proxy of op meerdere domeinen, configureer serverActions.allowedOrigins dan bewust in plaats van hem op te rekken tot de fouten verdwijnen.

Route handlers krijgen zulke bescherming niet. Authenticeert een POST-, PUT- of DELETE-handler met cookies, dan kan een formulier op een andere site er nog steeds naartoe posten. Het contenttype controleren is op zichzelf niet genoeg: een HTML-formulier kan urlencoded, multipart en text/plain bodies versturen zonder preflight, en een handler die de body soepel parset, accepteert ze. Zet sessiecookies met SameSite=Lax of Strict, voer nooit statuswijzigingen uit op GET, en controleer de Origin-header bij mutaties die met cookies zijn geauthenticeerd. Handlers die alleen een bearer token in de Authorization-header accepteren, zijn niet blootgesteld aan klassieke CSRF.

6. Security headers en CSP

Next.js stuurt standaard heel weinig security headers. Voeg ze toe in next.config via de functie headers(), of in middleware wanneer je per request een nonce nodig hebt voor een strikte Content-Security-Policy.

  • Content-Security-Policy, bij voorkeur op basis van nonces, met object-src 'none' en base-uri 'self'.
  • Strict-Transport-Security met een lange max-age zodra HTTPS overal solide is.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors in de CSP, of X-Frame-Options, om clickjacking te voorkomen.
  • poweredByHeader: false in next.config, om de X-Powered-By-header te laten vallen.

7. Rate limiting

Er is geen ingebouwde rate limiter. Inloggen, registreren, wachtwoordherstel, OTP-verificatie en alles wat per aanroep geld kost (e-mail, sms, AI-requests) hebben limieten per gebruiker en per IP nodig. Tellers in het geheugen werken niet op serverless of bij meerdere instances; gebruik een gedeelde opslag zoals Redis of je database. Pas limieten toe in de action of handler, waar de geauthenticeerde gebruiker bekend is, en niet alleen in middleware. Sleutel limieten op iets wat een aanvaller niet goedkoop kan wisselen: het account voor geauthenticeerde routes, en het doel-e-mailadres of telefoonnummer voor herstel- en OTP-stromen, naast het IP. Geef 429 terug met een Retry-After-header, zodat legitieme clients netjes terugschakelen.

8. Verifieer webhooks tegen de ruwe body

Webhook-handtekeningen worden berekend over exact de bytes die de provider heeft verstuurd. De body als JSON parsen en opnieuw serialiseren verandert die bytes en breekt de verificatie, wat mensen verleidt om die over te slaan. Lees de body in een route handler met req.text() en verifieer voordat je iets anders doet.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Handlers moeten idempotent zijn: providers proberen opnieuw en leveren soms dubbel
  await handleStripeEvent(event);
  return new Response('ok');
}

Sla verwerkte event-ID’s op met een unieke constraint, zodat een herhaalde of opnieuw afgespeelde levering niet tweemaal een abonnement toekent, en houd er rekening mee dat events buiten volgorde aankomen, want providers garanderen geen leveringsvolgorde.

9. Cache geen data per gebruiker globaal

De App Router cachet agressief, en de standaardinstellingen zijn tussen versies veranderd. Een functie in unstable_cache of 'use cache' die data voor 'de huidige gebruiker' teruggeeft maar de gebruikers-ID niet in haar cachesleutel opneemt, serveert de data van de ene gebruiker aan de volgende. Hetzelfde geldt voor pagina’s die statisch worden gerenderd terwijl ze dynamisch hadden moeten zijn, en voor CDN-caching van API-responses. Test het direct: log in twee browsers in als twee verschillende gebruikers en laad dezelfde pagina’s. Alles wat de data van de eerste gebruiker aan de tweede toont, is een cachebug, en meestal een serieuze.

  • Geef de gebruikers- of tenant-ID expliciet mee aan gecachete functies, zodat die onderdeel van de sleutel wordt.
  • Lees geen cookies of headers binnen gecachete functies; lees ze erbuiten en geef de waarden mee.
  • Stuur Cache-Control: private, no-store bij responses die data per gebruiker bevatten.
  • Controleer de build-uitvoer: routes die je dynamisch verwacht, horen niet als statisch vermeld te staan.

De checklist doorlopen

Loop de lijst per route door, niet per bestand: wie kan bij elke action en handler aanroepen, welke invoer vertrouwt hij, wat cachet hij en wat geeft hij terug. CodeAuditAgent kan een eerste ronde doen op een openbare GitHub-repository of een geplakt codefragment, en rapporteert elke bevinding met ernst, CWE, de geciteerde regel en een voorgestelde patch; de ontwerpvragen, zoals welke routes überhaupt openbaar horen te zijn, vragen nog steeds het oordeel van je team.