Use Cases (Aktualisiert: 2.6.2026)

Sichere Authentifizierung mit Claude Code: Next.js Sessions, JWT und OAuth

Sichere Auth mit Claude Code: Next.js Sessions, JWT, OAuth, CSRF, RBAC, Audit-Logs und Tests.

Sichere Authentifizierung mit Claude Code: Next.js Sessions, JWT und OAuth

Authentifizierung ist mehr als ein Login-Formular. Vor einem Release musst du Passwortspeicherung, Sessions, Cookies, JWT, OAuth-Callbacks, CSRF, Passwort-Reset, RBAC, Secrets, Audit-Logs und Tests gemeinsam betrachten. Wenn du Claude Code nur “füge Auth hinzu” sagst, entsteht schnell eine funktionierende Oberfläche mit unsichtbaren Produktionsrisiken.

Der häufigste gefährliche Shortcut ist ein langlebiges JWT im Browser-localStorage. Bei XSS, also bösartigem JavaScript in der Seite, kann dieser Token gelesen und weiterverwendet werden. Für normale Web-Apps ist eine serverseitige Session meist besser: Der Server hält den Login-Zustand, der Browser bekommt nur eine nicht erratbare Session-ID als HttpOnly Cookie.

Dieser Leitfaden zeigt eine kopierbare Next.js-App-Router-Implementierung mit Zod-Validierung, bcrypt-Hashing, signiertem Session-Cookie, Middleware-Guard, CSRF- und Origin-Prüfung, RBAC, Audit-Log und Vitest-Tests. JWT bleibt hier eine kurzlebige Grenze für APIs oder mobile Clients. OAuth ist eine Grenze zum Identity Provider, ersetzt aber nicht lokale Autorisierung.

Nutze offizielle Quellen als Wahrheit: Next.js Authentication guide, Next.js cookies API, OWASP Authentication Cheat Sheet, OWASP Password Storage Cheat Sheet, OWASP Forgot Password Cheat Sheet, MDN Secure cookie configuration, Auth.js und Claude Code docs. Passende interne Artikel sind Cookie-Verwaltung, RBAC und Zod Validation.

Session, JWT und OAuth trennen

Eine Session beantwortet: “Ist dieser Browser noch eingeloggt?” Ein JWT beantwortet: “Kann dieser Client für kurze Zeit signierte Claims beweisen?” OAuth/OIDC beantwortet: “Hat ein vertrauenswürdiger Provider diese Person authentifiziert?” Wenn diese Aufgaben in einer Funktion verschwimmen, wird der Review unsicher.

MechanismusGeeignet fürStärkeRisiko
Serverseitige SessionSaaS-Dashboard, Mitgliederbereich, AdminEinfache Revocation und AuditsBraucht Redis, Postgres, DynamoDB oder anderen Store
JWTMobile API, kurze Service-Calls, externe APISignaturprüfung ohne DB-LookupRevocation schwer; keine langfristige Browser-Speicherung
OAuth / OIDCGoogle, GitHub, Unternehmens-SSODu speicherst kein NutzerpasswortProvider-Login ersetzt keine lokalen Rechte

Drei Praxisfälle machen die Grenze klar. Ein SaaS-Dashboard nutzt ein HttpOnly Session-Cookie und verlangt vor Billing- oder E-Mail-Änderungen Reauthentifizierung. Eine Paid-Content-Seite trennt kostenlose Leser, Käufer, Redakteure und Admins per RBAC und Audit-Log. Ein internes B2B-Tool kann Google Workspace oder Entra ID für Login nutzen, muss aber Tenant, Rolle, Ablauf und Widerruf lokal prüfen.

Sicherer Prompt für Claude Code

Gib Claude Code zuerst den Sicherheitsvertrag.

Implementiere Authentifizierung für Next.js App Router.
Anforderungen:
- Browser-Login mit serverseitiger Session und HttpOnly Cookie
- JWT nur für kurzlebige externe API-Tokens; nicht in localStorage speichern
- Passwörter mit bcrypt oder Argon2id hashen, nie Klartext
- Eingaben mit Zod validieren und nicht verraten, ob eine E-Mail existiert
- Cookies mit Secure, HttpOnly, SameSite, Path und Max-Age setzen
- Zustandsändernde APIs mit Origin check und CSRF token schützen
- OAuth bevorzugt mit Auth.js oder bewährter Bibliothek, nicht komplett selbst bauen
- RBAC, password reset, audit logs und tests einschließen
- Pitfalls und Verifikationsbefehle ausgeben

Dieser Prompt verhindert nicht jede Schwäche, aber er zwingt Claude Code, gefährliche Bequemlichkeit nicht als Standard zu wählen.

Minimale Next.js-Implementierung

Installiere die Abhängigkeiten.

npm install zod bcryptjs
npm install -D vitest typescript @types/node

Setze in .env.local ein Secret mit mindestens 32 Zeichen. Es gehört nicht ins Git-Repository.

SESSION_SECRET="replace-with-at-least-32-random-characters"

lib/auth/password.ts. OWASP nennt Argon2id als modernen Favoriten; diese Demo nutzt bcryptjs, damit sie einfach kopierbar bleibt.

import bcrypt from "bcryptjs";
import { z } from "zod";

export const passwordSchema = z.string().min(12).max(128);

export async function hashPassword(password: string) {
  const parsed = passwordSchema.parse(password);
  return bcrypt.hash(parsed, 12);
}

export async function verifyPassword(password: string, hash: string) {
  return bcrypt.compare(password, hash);
}

lib/auth/session.ts. Der Speicher-Map ist nur für die Demo. In Produktion ersetze ihn durch Redis, PostgreSQL, DynamoDB oder einen gemeinsamen Store.

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { z } from "zod";

const env = z
  .object({
    NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
    SESSION_SECRET: z.string().min(32),
  })
  .parse(process.env);

export type Role = "user" | "admin";
type SessionRecord = { userId: string; role: Role; csrfToken: string; expiresAt: number };

declare global {
  var demoSessions: Map<string, SessionRecord> | undefined;
}

const sessions = globalThis.demoSessions ?? new Map<string, SessionRecord>();
globalThis.demoSessions = sessions;

export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
export const SESSION_COOKIE_NAME =
  env.NODE_ENV === "production" ? "__Host-session" : "dev-session";
export const sessionCookieOptions = {
  httpOnly: true,
  secure: env.NODE_ENV === "production",
  sameSite: "lax" as const,
  path: "/",
  maxAge: SESSION_MAX_AGE_SECONDS,
};

function signSessionId(sessionId: string) {
  return createHmac("sha256", env.SESSION_SECRET).update(sessionId).digest("base64url");
}

function safeEqual(left: string, right: string) {
  const a = Buffer.from(left);
  const b = Buffer.from(right);
  return a.length === b.length && timingSafeEqual(a, b);
}

export function createSession(userId: string, role: Role = "user") {
  const sessionId = randomBytes(32).toString("base64url");
  const token = `${sessionId}.${signSessionId(sessionId)}`;
  const csrfToken = randomBytes(32).toString("base64url");
  sessions.set(sessionId, {
    userId,
    role,
    csrfToken,
    expiresAt: Date.now() + SESSION_MAX_AGE_SECONDS * 1000,
  });
  return { token, csrfToken };
}

export function getSession(token?: string) {
  if (!token) return null;
  const [sessionId, signature] = token.split(".");
  if (!sessionId || !signature || !safeEqual(signature, signSessionId(sessionId))) return null;
  const session = sessions.get(sessionId);
  if (!session || session.expiresAt < Date.now()) {
    sessions.delete(sessionId);
    return null;
  }
  return { id: sessionId, ...session };
}

export function destroySession(token?: string) {
  const sessionId = token?.split(".")[0];
  if (sessionId) sessions.delete(sessionId);
}

export function assertSameOrigin(request: Request) {
  const origin = request.headers.get("origin");
  if (origin && origin !== new URL(request.url).origin) throw new Error("Bad origin");
}

export function assertCsrf(request: Request, session: { csrfToken: string }) {
  const submitted = request.headers.get("x-csrf-token");
  if (!submitted || submitted !== session.csrfToken) throw new Error("Bad CSRF token");
}

app/api/login/route.ts. In echten Anwendungen kommen Hash und Rolle aus der Datenbank.

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { SESSION_COOKIE_NAME, createSession, sessionCookieOptions } from "@/lib/auth/session";

export const runtime = "nodejs";
export const loginInputSchema = z.object({
  email: z.string().trim().toLowerCase().email(),
  password: z.string().min(12).max(128),
});

async function findUserByEmail(email: string) {
  if (email !== "masa@example.com") return null;
  return {
    id: "user_123",
    role: "admin" as const,
    passwordHash: await hashPassword("correct-horse-battery-staple"),
  };
}

export async function POST(request: NextRequest) {
  const parsed = loginInputSchema.safeParse(await request.json());
  if (!parsed.success) return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });

  const user = await findUserByEmail(parsed.data.email);
  const passwordOk = user ? await verifyPassword(parsed.data.password, user.passwordHash) : false;
  if (!user || !passwordOk) {
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const session = createSession(user.id, user.role);
  const response = NextResponse.json({ ok: true, csrfToken: session.csrfToken });
  response.cookies.set({ name: SESSION_COOKIE_NAME, value: session.token, ...sessionCookieOptions });
  response.cookies.set({
    name: "csrf-token",
    value: session.csrfToken,
    secure: sessionCookieOptions.secure,
    sameSite: "lax",
    path: "/",
    maxAge: sessionCookieOptions.maxAge,
  });
  return response;
}

middleware.ts leitet nur um. Es ist keine vollständige Autorisierung.

import { NextRequest, NextResponse } from "next/server";

const SESSION_COOKIE_NAME =
  process.env.NODE_ENV === "production" ? "__Host-session" : "dev-session";

export function middleware(request: NextRequest) {
  const hasSession = request.cookies.has(SESSION_COOKIE_NAME);
  const pathname = request.nextUrl.pathname;
  if (!hasSession && (pathname.startsWith("/dashboard") || pathname.startsWith("/admin"))) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };

test/auth.test.ts.

import { beforeAll, describe, expect, it } from "vitest";

beforeAll(() => {
  process.env.NODE_ENV = "test";
  process.env.SESSION_SECRET = "test-secret-value-with-more-than-32-characters";
});

describe("auth primitives", () => {
  it("hashes and verifies passwords", async () => {
    const { hashPassword, verifyPassword } = await import("../lib/auth/password");
    const hash = await hashPassword("correct-horse-battery-staple");
    await expect(verifyPassword("correct-horse-battery-staple", hash)).resolves.toBe(true);
    await expect(verifyPassword("wrong-password", hash)).resolves.toBe(false);
  });

  it("creates and destroys a session", async () => {
    const { createSession, destroySession, getSession } = await import("../lib/auth/session");
    const session = createSession("user_123", "admin");
    expect(getSession(session.token)?.role).toBe("admin");
    destroySession(session.token);
    expect(getSession(session.token)).toBeNull();
  });

  it("validates login input", async () => {
    const { loginInputSchema } = await import("../app/api/login/route");
    expect(loginInputSchema.safeParse({ email: "bad", password: "short" }).success).toBe(false);
  });
});

Password Reset, OAuth und JWT

Password Reset darf nicht verraten, ob eine E-Mail existiert. Verwende dieselbe Antwort, erzeuge starke Zufallstokens, speichere nur den Hash, setze eine kurze Ablaufzeit und mache Tokens single-use. Nach einem Passwortwechsel sollte das Beenden bestehender Sessions möglich sein.

OAuth solltest du nicht komplett selbst bauen. Nutze Auth.js oder eine bewährte Bibliothek, verknüpfe die Provider-Identität mit einem lokalen User und erstelle danach deine eigene Session. Ein Provider Access Token ist kein Session-Cookie deiner App.

JWT ist praktisch für APIs, aber kein Allheilmittel. Lege aud, iss, exp, Key Rotation und Reaktion auf Leaks fest.

Pitfalls, Audit und Monetarisierung

Verhindere vor dem Release diese Fehler: langlebige JWTs in localStorage, Cookies ohne Secure oder HttpOnly, einfache SHA-256-Passworthashes, Reset Tokens im Klartext, Login-Fehler mit User Enumeration, RBAC nur in Middleware und Logs mit Secrets.

Audit-Logs enthalten actor, action, result und Zeitpunkt. Sie enthalten keine Passwörter, Session IDs, Reset Tokens oder OAuth Access Tokens. Bei SaaS prüfst du tenantId vor role.

Auth schützt auch Umsatz: Premium-Content, Templates, Gumroad-Links, B2B-Formulare und Mitgliederbereiche. Starte mit der kostenlosen Checkliste, nutze Produkte und Templates für wiederverwendbare Regeln und Claude Code Training oder Beratung für Auth, RBAC, Audit Logs und CI in einem echten Repo.

Praktisches Ergebnis

Beim Testen war für Masa nicht die Login-Route allein entscheidend. Der Gewinn entstand dadurch, Session Cookie, CSRF, RBAC, Audit Log und Tests als eine Arbeitseinheit zu behandeln. Früher wirkte Middleware wie die Sicherheitsgrenze, obwohl sie nur Cookie-Präsenz prüfte. Server-seitige Checks machten den Review deutlich konkreter.

Zusammenfassung

Mit Claude Code beginnst du bei Auth immer mit Grenzen: Browser nutzt serverseitige Session und sicheres Cookie, API nutzt kurzlebiges JWT, externer Login nutzt OAuth/OIDC-Bibliothek, Zustandsänderungen nutzen CSRF und Origin, Autorisierung nutzt RBAC, sensible Aktionen nutzen Audit Logs und Tests.

#Claude Code #authentication #Next.js #JWT #OAuth #security
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.