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.
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.
| Mechanismus | Geeignet für | Stärke | Risiko |
|---|---|---|---|
| Serverseitige Session | SaaS-Dashboard, Mitgliederbereich, Admin | Einfache Revocation und Audits | Braucht Redis, Postgres, DynamoDB oder anderen Store |
| JWT | Mobile API, kurze Service-Calls, externe API | Signaturprüfung ohne DB-Lookup | Revocation schwer; keine langfristige Browser-Speicherung |
| OAuth / OIDC | Google, GitHub, Unternehmens-SSO | Du speicherst kein Nutzerpasswort | Provider-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.
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.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.