CodeAuditAgent
Alle Artikel
  • Next.js
  • Sicherheit
  • Checkliste

Sicherheits-Checkliste für den Next.js App Router

Praxisnahe Sicherheits-Checkliste für Next.js App Router: Server Actions, Route Handler, Middleware, Umgebungsvariablen, CSRF, Header, Webhooks und Caching.

· 8 Min. Lesezeit · Lina Source LLC

Der App Router hat viel Code aus dem Browser zurück auf den Server verlagert. Für die Sicherheit ist das überwiegend ein Gewinn: Abfragen, Secrets und Geschäftslogik laufen jetzt dort, wo Nutzer sie nicht lesen können. Gleichzeitig ist die Grenze zwischen einem öffentlichen Endpunkt und etwas, das nur wie ein Funktionsaufruf aussieht, verschwommen. Die meisten schweren Bugs in Next.js-Apps entstehen genau dort.

Diese Checkliste behandelt die Probleme, die wir in App-Router-Codebasen am häufigsten sehen, in der Reihenfolge, in der sich die Prüfung lohnt. Nichts davon ist exotisch; jeder Punkt ist eine Stelle, an der der Komfort des Frameworks eine Vertrauensgrenze verdeckt.

1. Server Actions sind öffentliche Endpunkte

Eine mit 'use server' markierte Funktion wird zu einem HTTP-Endpunkt kompiliert. Jeder, der Ihre Website laden kann, kann die Action-ID finden und sie mit beliebigen Argumenten aufrufen, unabhängig davon, ob Ihre UI den Button, der sie nutzt, jemals rendert. Ein Formular vor Nicht-Admins zu verstecken schützt die dahinterliegende Action nicht.

Jede Action braucht dieselben drei Schritte wie jeder API-Handler: den Aufrufer authentifizieren, die Eingabe validieren und den Zugriff auf den konkret betroffenen Datensatz autorisieren. Platzieren Sie diese Prüfungen im Rumpf der Action selbst. Eine Prüfung in der Page-Komponente, die das Formular rendert, läuft beim Laden der Seite, nicht beim Aufruf der Action, und schützt daher nichts.

'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'),
  });

  // Die Eigentümerprüfung ist Teil des Schreibvorgangs, keine separate Abfrage
  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');
}

Achten Sie auf Hilfsdateien, die viele Actions exportieren. Jede exportierte Funktion in einem 'use server'-Modul ist aufrufbar, auch die, die jemand für ein internes Skript hinzugefügt und dann vergessen hat.

2. Route Handler brauchen dieselben Prüfungen

Route Handler in app/api sind offensichtlicher Endpunkte, teilen aber dasselbe Fehlerbild: Ein dynamisches Segment wird genutzt, um einen Datensatz zu laden, ohne zu prüfen, wem er gehört. In aktuellen Next.js-Versionen ist params ein Promise; warten Sie es mit await ab, validieren Sie es und beschränken Sie die Abfrage auf den Aufrufer.

// 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' },
  });
}

Geben Sie für Datensätze, die dem Aufrufer nicht gehören, 404 statt 403 zurück, damit der Endpunkt nicht bestätigt, welche IDs existieren. Vertrauen Sie bei Autorisierungsentscheidungen nie auf params, searchParams, Header oder Cookies; all das ist vom Angreifer kontrollierte Eingabe.

3. Middleware allein ist keine Auth-Grenze

Middleware (in Next.js 16 in proxy umbenannt) eignet sich gut, um abgemeldete Nutzer umzuleiten und Header zu setzen. Sie sollte aber nicht der einzige Ort sein, an dem Autorisierung stattfindet. Matcher sind leicht falsch zu konfigurieren, neue Routen landen außerhalb davon, und Server Actions senden ihre POSTs an den Seitenpfad, der möglicherweise nicht zu dem Muster passt, das Sie im Kopf hatten. 2025 zeigte CVE-2025-29927, dass ein präparierter interner Header einige Next.js-Versionen dazu bringen konnte, die Middleware komplett zu überspringen.

Betrachten Sie Middleware als Komfortschicht. Die eigentliche Prüfung gehört neben die Daten: in jede Action und jeden Handler oder, besser noch, in eine Datenzugriffsschicht, über die jeder serverseitige Lesezugriff läuft. Eine Datenzugriffsschicht ist ein einzelnes, nur serverseitiges Modul, das Funktionen wie getProjectForUser(projectId) bereitstellt und darin die Session-Prüfung und den Eigentümerfilter durchführt. Pages, Actions und Route Handler rufen sie auf statt direkt den Datenbank-Client, sodass eine neue Route die Prüfung nicht vergessen kann: Es gibt keinen ungeprüften Pfad, den man vergessen könnte.

4. Servercode auf dem Server halten

Jede Umgebungsvariable mit dem Präfix NEXT_PUBLIC_ wird zur Build-Zeit in das JavaScript-Bundle eingebettet und ist für jeden Besucher sichtbar. Für einen veröffentlichbaren Stripe-Key oder eine Analytics-ID ist das richtig, für alles andere ist es ein Leak. Durchsuchen Sie die Codebasis nach NEXT_PUBLIC_ und prüfen Sie, ob jeder Wert auch auf einer Plakatwand unbedenklich wäre. Der umgekehrte Fehler kommt ebenfalls vor: Eine Variable ohne Präfix wird in einer Client-Komponente gelesen, ist im Browser undefined, und jemand benennt sie in NEXT_PUBLIC_ um, damit der Fehler verschwindet. Wenn ein Wert im Browser benötigt wird, fragen Sie zuerst, ob der Browser ihn überhaupt haben sollte.

// 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;
  // Nur die Felder zurückgeben, die die UI braucht, nie die ganze Zeile
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

Das Paket server-only lässt den Build fehlschlagen, wenn eine Client-Komponente das Modul importiert. So landen Datenbank-Clients und Helfer, die Secrets lesen, nicht im Browser. Achten Sie außerdem darauf, was Server-Komponenten als Props an Client-Komponenten übergeben: Alles, was diese Grenze überquert, wird in die Seite serialisiert. Wer eine vollständige Benutzerzeile übergibt, liefert deren Passwort-Hash und interne Flags an den Browser aus.

5. CSRF: wissen, was das Framework abdeckt

Server Actions akzeptieren nur POST, und Next.js vergleicht vor der Ausführung den Origin-Header mit dem Host. Wenn Sie hinter einem Proxy oder auf mehreren Domains deployen, konfigurieren Sie serverActions.allowedOrigins bewusst, statt die Liste so lange zu erweitern, bis die Fehler verschwinden.

Route Handler erhalten keinen solchen Schutz. Wenn ein POST-, PUT- oder DELETE-Handler per Cookie authentifiziert, kann ein Formular auf einer anderen Website ihn trotzdem ansprechen. Den Content-Type zu prüfen reicht allein nicht aus: Ein HTML-Formular kann urlencoded-, multipart- und text/plain-Bodies ohne Preflight senden, und ein Handler, der den Body tolerant parst, akzeptiert sie. Setzen Sie Session-Cookies mit SameSite=Lax oder Strict, führen Sie nie Zustandsänderungen per GET aus, und prüfen Sie bei cookie-authentifizierten Mutationen den Origin-Header. Handler, die ausschließlich ein Bearer-Token im Authorization-Header akzeptieren, sind für klassisches CSRF nicht anfällig.

6. Security-Header und CSP

Next.js sendet standardmäßig nur sehr wenige Security-Header. Fügen Sie sie in next.config über die Funktion headers() hinzu oder in der Middleware, wenn Sie für eine strikte Content-Security-Policy eine Nonce pro Request brauchen.

  • Content-Security-Policy, idealerweise nonce-basiert, mit object-src 'none' und base-uri 'self'.
  • Strict-Transport-Security mit langem max-age, sobald HTTPS überall zuverlässig läuft.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors in der CSP oder X-Frame-Options, um Clickjacking zu verhindern.
  • poweredByHeader: false in next.config, um den Header X-Powered-By zu entfernen.

7. Rate Limiting

Es gibt keinen eingebauten Rate Limiter. Anmeldung, Registrierung, Passwort-Reset, OTP-Verifizierung und alles, was pro Aufruf Geld kostet (E-Mail, SMS, KI-Anfragen), brauchen Limits pro Nutzer und pro IP. Zähler im Arbeitsspeicher funktionieren bei Serverless- oder Multi-Instanz-Deployments nicht; nutzen Sie einen gemeinsamen Speicher wie Redis oder Ihre Datenbank. Setzen Sie Limits in der Action oder im Handler durch, wo der authentifizierte Nutzer bekannt ist, nicht nur in der Middleware. Binden Sie Limits an etwas, das ein Angreifer nicht billig wechseln kann: an das Konto bei authentifizierten Routen und an die Ziel-E-Mail-Adresse oder Telefonnummer bei Reset- und OTP-Flows, zusätzlich zur IP. Geben Sie 429 mit einem Retry-After-Header zurück, damit legitime Clients korrekt zurückweichen.

8. Webhooks gegen den Raw Body verifizieren

Webhook-Signaturen werden über genau die Bytes berechnet, die der Anbieter gesendet hat. Den Body als JSON zu parsen und neu zu serialisieren verändert diese Bytes und bricht die Verifizierung, was dazu verleitet, sie wegzulassen. Lesen Sie den Body in einem Route Handler mit req.text() und verifizieren Sie ihn, bevor Sie irgendetwas anderes tun.

// 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 });
  }

  // Handler müssen idempotent sein: Anbieter wiederholen Zustellungen und liefern evtl. doppelt
  await handleStripeEvent(event);
  return new Response('ok');
}

Speichern Sie verarbeitete Event-IDs mit einem Unique Constraint, damit eine wiederholte oder erneut eingespielte Zustellung kein Abonnement doppelt gewährt, und behandeln Sie Events, die in falscher Reihenfolge eintreffen, denn Anbieter garantieren keine Zustellreihenfolge.

9. Nutzerspezifische Daten nicht global cachen

Der App Router cacht aggressiv, und die Standardeinstellungen haben sich zwischen den Versionen geändert. Eine in unstable_cache oder 'use cache' gekapselte Funktion, die Daten für „den aktuellen Nutzer“ zurückgibt, die Benutzer-ID aber nicht in ihren Cache-Key aufnimmt, liefert die Daten eines Nutzers an den nächsten aus. Dasselbe gilt für statisch gerenderte Seiten, die dynamisch hätten sein sollen, und für CDN-Caching von API-Antworten. Testen Sie es direkt: Melden Sie sich in zwei Browsern als zwei verschiedene Nutzer an und laden Sie dieselben Seiten. Alles, was dem zweiten Nutzer die Daten des ersten zeigt, ist ein Cache-Bug, und zwar meist ein schwerer.

  • Übergeben Sie die Benutzer- oder Mandanten-ID explizit an gecachte Funktionen, damit sie Teil des Keys wird.
  • Lesen Sie in gecachten Funktionen keine Cookies oder Header; lesen Sie sie außerhalb und übergeben Sie die Werte.
  • Senden Sie Cache-Control: private, no-store bei Antworten mit nutzerspezifischen Daten.
  • Prüfen Sie die Build-Ausgabe: Routen, die dynamisch sein sollen, dürfen nicht als statisch aufgeführt sein.

Die Checkliste anwenden

Gehen Sie die Liste pro Route durch, nicht pro Datei: Wer kann jede Action und jeden Handler aufrufen, welcher Eingabe wird vertraut, was wird gecacht und was zurückgegeben? CodeAuditAgent kann einen ersten Durchgang für ein öffentliches GitHub-Repository oder ein eingefügtes Snippet übernehmen und meldet jeden Befund mit Schweregrad, CWE, der zitierten Zeile und einem vorgeschlagenen Patch; die Designfragen, etwa welche Routen überhaupt öffentlich sein sollen, erfordern weiterhin das Urteil Ihres Teams.