Moderne Authentifizierung 2026: JWT, Sessions, OAuth und Passkeys
Die vollständige Auth-Landschaft: wann Sessions vs JWT, OAuth 2.0 / OIDC Flows, Refresh Token Rotation, Passkeys (WebAuthn) und die Next.js Auth-Muster, die ich tatsächlich einsetze.
Authentifizierung ist der einzige Bereich in der Webentwicklung, in dem "es funktioniert" niemals ausreicht. Ein Bug in deinem Datepicker ist nervig. Ein Bug in deinem Auth-System ist ein Datenleck.
Ich habe Authentifizierung von Grund auf implementiert, zwischen Providern migriert, Token-Diebstahl-Vorfälle debuggt und die Konsequenzen von "wir kümmern uns später um die Sicherheit"-Entscheidungen erlebt. Dieser Beitrag ist der umfassende Leitfaden, den ich mir gewünscht hätte, als ich anfing. Nicht nur die Theorie — die echten Abwägungen, die tatsächlichen Schwachstellen und die Muster, die unter Produktionsdruck standhalten.
Wir decken die gesamte Landschaft ab: Sessions, JWTs, OAuth 2.0, Passkeys, MFA und Autorisierung. Am Ende wirst du nicht nur verstehen, wie jeder Mechanismus funktioniert, sondern wann du ihn einsetzen solltest und warum die Alternativen existieren.
Sessions vs JWT: Die echten Abwägungen#
Das ist die erste Entscheidung, vor der du stehst, und das Internet ist voll mit schlechten Ratschlägen dazu. Lass mich darlegen, worauf es wirklich ankommt.
Session-basierte Authentifizierung#
Sessions sind der ursprüngliche Ansatz. Der Server erstellt einen Session-Datensatz, speichert ihn irgendwo (Datenbank, Redis, Speicher) und gibt dem Client eine opake Session-ID in einem Cookie.
// Vereinfachte Session-Erstellung
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ip: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 Tage
ip: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// In Redis speichern mit TTL
await redis.set(
`session:${sessionId}`,
JSON.stringify(session),
"EX",
30 * 24 * 60 * 60
);
// HttpOnly-Cookie setzen
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 30 * 24 * 60 * 60,
path: "/",
});
return sessionId;
}Die Vorteile sind real:
- Sofortige Sperrung. Session-Datensatz löschen und der Benutzer ist ausgeloggt. Kein Warten auf Ablauf. Das ist wichtig, wenn du verdächtige Aktivität erkennst.
- Session-Übersicht. Du kannst Benutzern ihre aktiven Sessions anzeigen ("eingeloggt auf Chrome, Windows 11, Istanbul") und sie einzelne sperren lassen.
- Kleine Cookie-Größe. Die Session-ID ist typischerweise 64 Zeichen lang. Das Cookie wächst nie.
- Serverseitige Kontrolle. Du kannst Session-Daten aktualisieren (Benutzer zum Admin befördern, Berechtigungen ändern) und es wird beim nächsten Request wirksam.
Die Nachteile sind ebenfalls real:
- Datenbankzugriff bei jedem Request. Jeder authentifizierte Request braucht einen Session-Lookup. Mit Redis ist das sub-Millisekunde, aber es bleibt eine Abhängigkeit.
- Horizontale Skalierung erfordert geteilten Speicher. Wenn du mehrere Server hast, brauchen alle Zugriff auf denselben Session-Store. Sticky Sessions sind ein fragiler Workaround.
- CSRF ist ein Thema. Weil Cookies automatisch gesendet werden, brauchst du CSRF-Schutz. SameSite-Cookies lösen das weitgehend, aber du musst verstehen warum.
JWT-basierte Authentifizierung#
JWTs drehen das Modell um. Statt den Session-State auf dem Server zu speichern, kodierst du ihn in einen signierten Token, den der Client hält.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}Die Vorteile:
- Kein serverseitiger Speicher. Der Token ist eigenständig. Du verifizierst die Signatur und liest die Claims. Kein Datenbankzugriff.
- Funktioniert über Services hinweg. In einer Microservices-Architektur kann jeder Service mit dem Public Key den Token verifizieren. Kein geteilter Session-Store nötig.
- Zustandslose Skalierung. Server hinzufügen ohne sich um Session-Affinität zu kümmern.
Die Nachteile — und das sind die, die gerne übergangen werden:
- Du kannst einen JWT nicht widerrufen. Einmal ausgestellt, ist er bis zum Ablauf gültig. Wenn ein Benutzerkonto kompromittiert wird, kannst du keinen Force-Logout machen. Du kannst eine Blocklist bauen, aber dann hast du serverseitigen State wieder eingeführt und den Hauptvorteil verloren.
- Token-Größe. JWTs mit ein paar Claims sind typischerweise 800+ Bytes. Füge Rollen, Berechtigungen und Metadaten hinzu und du sendest Kilobytes bei jedem Request.
- Payload ist lesbar. Der Payload ist Base64-kodiert, nicht verschlüsselt. Jeder kann ihn dekodieren. Niemals sensible Daten in einem JWT speichern.
- Clock-Skew-Probleme. Wenn deine Server unterschiedliche Uhren haben (das passiert), werden Ablaufprüfungen unzuverlässig.
Wann was verwenden#
Meine Faustregel:
Verwende Sessions wenn: Du eine monolithische Anwendung hast, du sofortige Sperrung brauchst, du ein verbraucherorientiertes Produkt baust, bei dem Kontosicherheit kritisch ist, oder deine Auth-Anforderungen sich häufig ändern können.
Verwende JWTs wenn: Du eine Microservices-Architektur hast, in der Services unabhängig Identitäten verifizieren müssen, du API-zu-API-Kommunikation baust, oder du ein Drittanbieter-Authentifizierungssystem implementierst.
In der Praxis: Die meisten Anwendungen sollten Sessions verwenden. Das "JWTs sind skalierbarer"-Argument gilt nur, wenn du tatsächlich ein Skalierungsproblem hast, das Session-Speicher nicht lösen kann — und Redis bewältigt Millionen von Session-Lookups pro Sekunde. Ich habe zu viele Projekte gesehen, die JWTs wählen, weil sie moderner klingen, und dann eine Blocklist und ein Refresh-Token-System bauen, das komplexer ist als Sessions gewesen wären.
JWT Deep Dive#
Selbst wenn du dich für session-basierte Auth entscheidest, wirst du JWTs durch OAuth, OIDC und Drittanbieter-Integrationen begegnen. Die Interna zu verstehen ist nicht verhandelbar.
Anatomie eines JWT#
Ein JWT hat drei Teile, getrennt durch Punkte: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
Header — deklariert den Algorithmus und Token-Typ:
{
"alg": "RS256",
"typ": "JWT"
}Payload — enthält Claims. Standard-Claims haben kurze Namen:
{
"sub": "user_123", // Subject (um wen geht es)
"iss": "https://auth.example.com", // Issuer (wer hat es erstellt)
"aud": "https://api.example.com", // Audience (wer soll es akzeptieren)
"iat": 1709312000, // Issued At (Unix-Zeitstempel)
"exp": 1709312900, // Expiration (Unix-Zeitstempel)
"role": "admin" // Eigener Claim
}Signatur — beweist, dass der Token nicht manipuliert wurde. Erstellt durch Signierung des kodierten Headers und Payloads mit einem geheimen Schlüssel.
RS256 vs HS256: Das ist wirklich wichtig#
HS256 (HMAC-SHA256) — symmetrisch. Dasselbe Secret signiert und verifiziert. Einfach, aber jeder Service, der Tokens verifizieren muss, braucht das Secret. Wenn einer davon kompromittiert wird, kann ein Angreifer Tokens fälschen.
RS256 (RSA-SHA256) — asymmetrisch. Ein privater Schlüssel signiert, ein öffentlicher Schlüssel verifiziert. Nur der Auth-Server braucht den privaten Schlüssel. Jeder Service kann mit dem öffentlichen Schlüssel verifizieren. Wenn ein Verifizierungsservice kompromittiert wird, kann der Angreifer Tokens lesen, aber nicht fälschen.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — verwende dies, wenn mehrere Services Tokens verifizieren
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // KRITISCH: Algorithmen immer einschränken
});
return payload;
}Regel: Verwende RS256, wann immer Tokens Service-Grenzen überschreiten. Verwende HS256 nur, wenn derselbe Service sowohl signiert als auch verifiziert.
Der alg: none Angriff#
Das ist die berühmteste JWT-Schwachstelle, und sie ist peinlich einfach. Manche JWT-Bibliotheken haben früher:
- Das
alg-Feld aus dem Header gelesen - Den dort angegebenen Algorithmus verwendet
- Bei
alg: "none"die Signaturprüfung komplett übersprungen
Ein Angreifer konnte einen gültigen JWT nehmen, den Payload ändern (z.B. "role": "admin" setzen), alg auf "none" setzen, die Signatur entfernen und ihn senden. Der Server akzeptierte ihn.
// VERWUNDBAR — niemals so machen
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "Keine Signatur nötig" — KATASTROPHAL
return JSON.parse(atob(payloadB64));
}
// ... Signatur verifizieren
}Die Lösung ist einfach: Gib den erwarteten Algorithmus immer explizit an. Lass niemals den Token bestimmen, wie er verifiziert werden soll.
// SICHER — Algorithmus ist hartcodiert, nicht aus dem Token gelesen
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // Nur RS256 akzeptieren — Header ignorieren
});Moderne Bibliotheken wie jose handhaben das standardmäßig korrekt, aber du solltest trotzdem die algorithms-Option explizit übergeben als Defense in Depth.
Algorithm-Confusion-Angriff#
Verwandt mit dem obigen: Wenn ein Server für RS256 konfiguriert ist, könnte ein Angreifer:
- Den öffentlichen Schlüssel des Servers besorgen (er ist schließlich öffentlich)
- Einen Token mit
alg: "HS256"erstellen - Ihn mit dem öffentlichen Schlüssel als HMAC-Secret signieren
Wenn der Server den alg-Header liest und auf HS256-Verifizierung umschaltet, wird der öffentliche Schlüssel (den jeder kennt) zum Shared Secret. Die Signatur ist gültig. Der Angreifer hat einen Token gefälscht.
Auch hier ist die Lösung dieselbe: Vertraue niemals dem Algorithmus aus dem Token-Header. Codiere ihn immer hart.
Refresh Token Rotation#
Wenn du JWTs verwendest, brauchst du eine Refresh-Token-Strategie. Einen langlebigen Access Token zu senden ist fahrlässig — wenn er gestohlen wird, hat der Angreifer Zugriff für die gesamte Laufzeit.
Das Muster:
- Access Token: kurzlebig (15 Minuten). Wird für API-Requests verwendet.
- Refresh Token: langlebig (30 Tage). Wird nur verwendet, um einen neuen Access Token zu erhalten.
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // Gruppiert zusammengehörige Tokens
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// Refresh-Token-Datensatz speichern
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}Rotation bei jeder Verwendung#
Jedes Mal, wenn der Client einen Refresh Token verwendet, um einen neuen Access Token zu erhalten, stellst du einen neuen Refresh Token aus und invalidierst den alten:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// Token existiert nicht — möglicher Diebstahl
return null;
}
if (record.expiresAt < new Date()) {
// Token abgelaufen
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// DIESER TOKEN WURDE BEREITS VERWENDET.
// Jemand spielt ihn erneut ab — entweder der legitime Benutzer
// oder ein Angreifer. In beiden Fällen: ganze Familie löschen.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`Refresh Token Wiederverwendung erkannt für Benutzer ${record.userId}, Familie ${record.familyId}. Alle Tokens der Familie invalidiert.`
);
return null;
}
// Aktuellen Token als verwendet markieren (nicht löschen — brauchen wir zur Wiederverwendungserkennung)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// Neues Paar mit derselben Familien-ID ausstellen
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // Selbe Familie
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}Warum Familien-Invalidierung wichtig ist#
Betrachte dieses Szenario:
- Benutzer loggt sich ein, bekommt Refresh Token A
- Angreifer stiehlt Refresh Token A
- Angreifer verwendet A, um ein neues Paar zu erhalten (Access Token + Refresh Token B)
- Benutzer versucht, A (das er noch hat) zum Auffrischen zu verwenden
Ohne Wiederverwendungserkennung bekommt der Benutzer einfach einen Fehler. Der Angreifer macht mit Token B weiter. Der Benutzer loggt sich erneut ein, ohne je zu erfahren, dass sein Konto kompromittiert wurde.
Mit Wiederverwendungserkennung und Familien-Invalidierung: Wenn der Benutzer den bereits verwendeten Token A benutzt, erkennt das System die Wiederverwendung, invalidiert jeden Token in der Familie (einschließlich B) und erzwingt bei beiden — Benutzer und Angreifer — eine erneute Authentifizierung. Der Benutzer bekommt ein "Bitte erneut einloggen"-Prompt und merkt möglicherweise, dass etwas nicht stimmt.
Das ist der Ansatz, den Auth0, Okta und Auth.js verwenden. Er ist nicht perfekt — wenn der Angreifer den Token vor dem legitimen Benutzer verwendet, wird der legitime Benutzer derjenige, der den Wiederverwendungsalarm auslöst. Aber es ist das Beste, was wir mit Bearer Tokens erreichen können.
OAuth 2.0 & OIDC#
OAuth 2.0 und OpenID Connect sind die Protokolle hinter "Anmelden mit Google/GitHub/Apple." Sie zu verstehen ist essentiell, selbst wenn du eine Bibliothek verwendest, denn wenn Dinge kaputtgehen — und das werden sie — musst du wissen, was auf Protokollebene passiert.
Die wichtige Unterscheidung#
OAuth 2.0 ist ein Autorisierungs-Protokoll. Es beantwortet: "Darf diese Anwendung auf die Daten dieses Benutzers zugreifen?" Das Ergebnis ist ein Access Token, der bestimmte Berechtigungen (Scopes) gewährt.
OpenID Connect (OIDC) ist eine Authentifizierungs-Schicht, die auf OAuth 2.0 aufbaut. Es beantwortet: "Wer ist dieser Benutzer?" Das Ergebnis ist ein ID Token (ein JWT), der Benutzeridentitätsinformationen enthält.
Wenn du "Mit Google anmelden" nutzt, verwendest du OIDC. Google teilt deiner App mit, wer der Benutzer ist (Authentifizierung). Du kannst auch OAuth-Scopes anfordern, um auf deren Kalender oder Drive zuzugreifen (Autorisierung).
Authorization Code Flow mit PKCE#
Das ist der Flow, den du für Webanwendungen verwenden solltest. PKCE (Proof Key for Code Exchange) wurde ursprünglich für Mobile-Apps entwickelt, wird aber jetzt für alle Clients empfohlen, einschließlich serverseitiger Anwendungen.
import { randomBytes, createHash } from "crypto";
// Schritt 1: PKCE-Werte generieren und den Benutzer weiterleiten
function initiateOAuthFlow() {
// Code Verifier: zufälliger String mit 43-128 Zeichen
const codeVerifier = randomBytes(32)
.toString("base64url")
.slice(0, 43);
// Code Challenge: SHA256-Hash des Verifiers, base64url-kodiert
const codeChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
// State: zufälliger Wert für CSRF-Schutz
const state = randomBytes(16).toString("hex");
// Beide in der Session speichern (serverseitig!) bevor weitergeleitet wird
// NIEMALS den code_verifier in ein Cookie oder URL-Parameter packen
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// Schritt 2: Callback verarbeiten
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// Auf Fehler vom Provider prüfen
if (error) {
throw new Error(`OAuth-Fehler: ${error}`);
}
// State-Übereinstimmung prüfen (CSRF-Schutz)
if (state !== session.oauthState) {
throw new Error("State stimmt nicht überein — möglicher CSRF-Angriff");
}
// Authorization Code gegen Tokens tauschen
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: beweist, dass wir diesen Flow gestartet haben
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — für API-Aufrufe an Google
// tokens.id_token — JWT mit Benutzeridentität (OIDC)
// tokens.refresh_token — für neue Access Tokens
// Schritt 3: ID Token verifizieren und Benutzerinfo extrahieren
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}Die drei Endpunkte#
Jeder OAuth/OIDC-Provider stellt diese bereit:
- Authorization-Endpunkt — wohin du den Benutzer zum Einloggen und Erteilen von Berechtigungen weiterleitest. Gibt einen Authorization Code zurück.
- Token-Endpunkt — wo dein Server den Authorization Code gegen Access/Refresh/ID Tokens tauscht. Das ist ein Server-zu-Server-Aufruf.
- UserInfo-Endpunkt — wo du zusätzliche Benutzerprofildaten mit dem Access Token abrufen kannst. Bei OIDC ist vieles davon bereits im ID Token.
Der State-Parameter#
Der state-Parameter verhindert CSRF-Angriffe auf den OAuth-Callback. Ohne ihn:
- Angreifer startet einen OAuth-Flow auf seinem eigenen Rechner, bekommt einen Authorization Code
- Angreifer erstellt eine URL:
https://deineapp.com/callback?code=ANGREIFER_CODE - Angreifer bringt das Opfer dazu, darauf zu klicken (E-Mail-Link, verstecktes Bild)
- Deine App tauscht den Code des Angreifers und verknüpft das Google-Konto des Angreifers mit der Session des Opfers
Mit state: Deine App generiert einen zufälligen Wert, speichert ihn in der Session und fügt ihn in die Authorization-URL ein. Wenn der Callback kommt, verifizierst du, dass der state übereinstimmt. Der Angreifer kann das nicht fälschen, weil er keinen Zugriff auf die Session des Opfers hat.
Auth.js (NextAuth) mit Next.js App Router#
Auth.js ist das, wonach ich in den meisten Next.js-Projekten zuerst greife. Es übernimmt den OAuth-Tanz, Session-Verwaltung, Datenbankpersistenz und CSRF-Schutz. Hier ein produktionsreifes Setup.
Grundkonfiguration#
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// Datenbank-Sessions verwenden (nicht JWT) für bessere Sicherheit
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30 Tage
updateAge: 24 * 60 * 60, // Session alle 24 Stunden verlängern
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// Bestimmte Scopes anfordern
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // Refresh Token erhalten
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// E-Mail/Passwort-Login (vorsichtig verwenden)
Credentials({
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// Kontrollieren, wer sich anmelden darf
async signIn({ user, account }) {
// Anmeldung für gesperrte Benutzer blockieren
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// Eigene Felder zur Session hinzufügen
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// Rolle aus der Datenbank holen
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});Route Handler#
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;Middleware-Schutz#
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login")
|| req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
|| req.nextUrl.pathname.startsWith("/settings")
|| req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// Eingeloggte Benutzer von Auth-Seiten wegleiten
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// Nicht-authentifizierte Benutzer zum Login weiterleiten
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(
new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
);
}
// Admin-Rolle prüfen
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [
"/dashboard/:path*",
"/settings/:path*",
"/admin/:path*",
"/login",
"/register",
],
};Session in Server Components verwenden#
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>Willkommen, {session.user.name}</h1>
<p>Rolle: {session.user.role}</p>
</div>
);
}Session in Client Components verwenden#
"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>Lädt...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">Anmelden</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "Benutzer"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkeys (WebAuthn)#
Passkeys sind die bedeutendste Authentifizierungsverbesserung seit Jahren. Sie sind Phishing-resistent, Replay-resistent und eliminieren die gesamte Kategorie passwortbezogener Schwachstellen. Wenn du 2026 ein neues Projekt startest, solltest du Passkeys unterstützen.
Wie Passkeys funktionieren#
Passkeys nutzen Public-Key-Kryptografie, gestützt durch Biometrie oder Geräte-PINs:
- Registrierung: Der Browser generiert ein Schlüsselpaar. Der private Schlüssel bleibt auf dem Gerät (in einer Secure Enclave, geschützt durch Biometrie). Der öffentliche Schlüssel wird an deinen Server gesendet.
- Authentifizierung: Der Server sendet eine Challenge (zufällige Bytes). Das Gerät signiert die Challenge mit dem privaten Schlüssel (nach biometrischer Verifizierung). Der Server verifiziert die Signatur mit dem gespeicherten öffentlichen Schlüssel.
Kein gemeinsames Geheimnis überquert jemals das Netzwerk. Es gibt nichts zu phishen, nichts das lecken kann, nichts zum Credential-Stuffing.
Warum Passkeys Phishing-resistent sind#
Wenn ein Passkey erstellt wird, ist er an die Origin gebunden (z.B. https://example.com). Der Browser wird den Passkey nur auf der exakten Origin verwenden, für die er erstellt wurde. Wenn ein Angreifer eine Nachahmer-Seite unter https://exarnple.com erstellt, wird der Passkey einfach nicht angeboten. Das wird vom Browser durchgesetzt, nicht von der Wachsamkeit des Benutzers.
Das unterscheidet sich grundlegend von Passwörtern, wo Benutzer routinemäßig ihre Zugangsdaten auf Phishing-Seiten eingeben, weil die Seite richtig aussieht.
Implementierung mit SimpleWebAuthn#
SimpleWebAuthn ist die Bibliothek, die ich empfehle. Sie handhabt das WebAuthn-Protokoll korrekt und hat gute TypeScript-Types.
// Serverseite: Registrierung
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
async function startRegistration(userId: string, userEmail: string) {
// Vorhandene Passkeys des Benutzers abrufen, um sie auszuschließen
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // Attestierung brauchen wir für die meisten Apps nicht
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// Challenge temporär speichern — brauchen wir zur Verifizierung
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300 // 5 Minuten Ablauf
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("Challenge abgelaufen oder nicht gefunden");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`Registrierungsverifizierung fehlgeschlagen: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registrierungsverifizierung fehlgeschlagen");
}
const { credential } = verification.registrationInfo;
// Credential in der Datenbank speichern
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// Aufräumen
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// Serverseite: Authentifizierung
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// Wenn wir den Benutzer kennen (z.B. hat seine E-Mail eingegeben), auf seine Passkeys beschränken
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// Challenge zur Verifizierung speichern
const challengeKey = userId
? `webauthn:auth:${userId}`
: `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(
response: any,
expectedChallenge: string,
userId: string
) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("Credential nicht gefunden");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("Authentifizierungsverifizierung fehlgeschlagen");
}
// WICHTIG: Counter aktualisieren, um Replay-Angriffe zu verhindern
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// Clientseite: Registrierung
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// Optionen vom Server abrufen
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// Das löst die Passkey-UI des Browsers aus (biometrischer Prompt)
const credential = await webAuthnRegister(options);
// Credential zur Verifizierung an den Server senden
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey erfolgreich registriert!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("Benutzer hat die Passkey-Registrierung abgebrochen");
}
}
}Attestation vs Assertion#
Zwei Begriffe, denen du begegnen wirst:
- Attestation (Registrierung): Der Prozess der Erstellung eines neuen Credentials. Der Authenticator "bezeugt" seine Identität und Fähigkeiten. Für die meisten Anwendungen brauchst du keine Attestierung zu verifizieren — setze
attestationType: "none". - Assertion (Authentifizierung): Der Prozess der Verwendung eines bestehenden Credentials zum Signieren einer Challenge. Der Authenticator "behauptet", dass der Benutzer derjenige ist, der er vorgibt zu sein.
MFA-Implementierung#
Selbst mit Passkeys wirst du Szenarien begegnen, in denen MFA via TOTP benötigt wird — Passkeys als zweiter Faktor neben Passwörtern, oder Unterstützung für Benutzer, deren Geräte keine Passkeys unterstützen.
TOTP (Time-Based One-Time Passwords)#
TOTP ist das Protokoll hinter Google Authenticator, Authy und 1Password. Es funktioniert so:
- Server generiert ein zufälliges Secret (base32-kodiert)
- Benutzer scannt einen QR-Code, der das Secret enthält
- Sowohl Server als auch Authenticator-App berechnen denselben 6-stelligen Code aus dem Secret und der aktuellen Zeit
- Codes ändern sich alle 30 Sekunden
import { createHmac, randomBytes } from "crypto";
// TOTP-Secret für einen Benutzer generieren
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// TOTP-URI für QR-Code generieren
function generateTOTPUri(
secret: string,
userEmail: string,
issuer: string = "akousa.net"
): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// TOTP-Code verifizieren
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// Aktuellen Zeitschritt und benachbarte prüfen (Clock-Drift-Toleranz)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// Konstantzeitvergleich zur Verhinderung von Timing-Angriffen
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// Zeitschritt in 8-Byte Big-Endian Buffer konvertieren
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// Dynamische Trunkierung
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest()
.equals(createHmac("sha256", "key").update(bufB).digest());
}Backup-Codes#
Benutzer verlieren ihre Handys. Generiere immer Backup-Codes bei der MFA-Einrichtung:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString("hex").toUpperCase() // 8-stellige Hex-Codes
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// Codes vor dem Speichern hashen — behandle sie wie Passwörter
const hashedCodes = codes.map((code) =>
createHash("sha256").update(code).digest("hex")
);
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// Die Klartextcodes EINMAL für den Benutzer zum Speichern zurückgeben
// Danach haben wir nur noch die Hashes
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256")
.update(code.toUpperCase().replace(/\s/g, ""))
.digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// Als verwendet markieren — jeder Backup-Code funktioniert genau einmal
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}Wiederherstellungsablauf#
MFA-Wiederherstellung ist der Teil, den die meisten Tutorials überspringen und die meisten echten Anwendungen vermasseln. Das implementiere ich:
- Primär: TOTP-Code von der Authenticator-App
- Sekundär: Einer der 10 Backup-Codes
- Letzter Ausweg: E-Mail-basierte Wiederherstellung mit 24-Stunden-Wartezeit und Benachrichtigung an die anderen verifizierten Kanäle des Benutzers
Die Wartezeit ist kritisch. Wenn ein Angreifer die E-Mail des Benutzers kompromittiert hat, willst du ihm nicht erlauben, MFA sofort zu deaktivieren. Die 24-Stunden-Verzögerung gibt dem legitimen Benutzer Zeit, die E-Mail zu bemerken und einzugreifen.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Nicht verraten, ob das Konto existiert
return { message: "Falls diese E-Mail existiert, haben wir Wiederherstellungsanweisungen gesendet." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 Stunden
status: "pending",
},
});
// E-Mail mit Wiederherstellungslink senden
await sendEmail(email, {
subject: "Kontowiederherstellungsanfrage",
body: `
Eine Anfrage zur Deaktivierung von MFA auf deinem Konto wurde gestellt.
Falls das du warst, klicke den Link unten nach 24 Stunden: ...
Falls das NICHT du warst, ändere bitte sofort dein Passwort.
`,
});
return { message: "Falls diese E-Mail existiert, haben wir Wiederherstellungsanweisungen gesendet." };
}Autorisierungsmuster#
Authentifizierung sagt dir, wer jemand ist. Autorisierung sagt dir, was er tun darf. Das falsch zu machen ist der Weg, wie du in den Nachrichten landest.
RBAC vs ABAC#
RBAC (Role-Based Access Control): Benutzer haben Rollen, Rollen haben Berechtigungen. Einfach, leicht nachvollziehbar, funktioniert für die meisten Anwendungen.
// RBAC — direkte Rollenprüfungen
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile", "update:own_profile",
"read:posts", "create:posts", "update:posts", "delete:posts",
"read:users", "update:users",
],
super_admin: ["*"], // Vorsicht mit Wildcards
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// Verwendung in einer API-Route
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Nicht autorisiert" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Verboten" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (Attribute-Based Access Control): Berechtigungen hängen von Attributen des Benutzers, der Ressource und des Kontexts ab. Flexibler, aber komplexer.
// ABAC — wenn RBAC nicht reicht
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// Benutzer können immer ihre eigenen Ressourcen lesen
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// Admins können jede Ressource in ihrer Abteilung lesen
if (
action === "read" &&
user.role === "admin" &&
user.department === resource.department
) {
return true;
}
// Klassifizierte Ressourcen erfordern MFA und Mindestsicherheitsstufe
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// Destruktive Aktionen außerhalb der Geschäftszeiten blockiert
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // Standardmäßig verweigern
}Die "Prüfung an der Grenze"-Regel#
Das ist das wichtigste Autorisierungsprinzip: Berechtigungen an jeder Vertrauensgrenze prüfen, nicht nur auf UI-Ebene.
// SCHLECHT — nur in der Komponente prüfen
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// Das versteckt den Button, verhindert aber nicht das Löschen
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>Löschen</button>;
}
// AUCH SCHLECHT — in einer Server Action prüfen, aber nicht in der API-Route
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Verboten");
await db.post.delete({ where: { id: postId } });
}
// Ein Angreifer kann trotzdem POST /api/posts/123 direkt aufrufen
// GUT — an jeder Grenze prüfen
// 1. Button in der UI verstecken (UX, keine Sicherheit)
// 2. In der Server Action prüfen (Defense in Depth)
// 3. In der API-Route prüfen (die eigentliche Sicherheitsgrenze)
// 4. Optional: in der Middleware prüfen (für routenbasiertenSchutz)Die UI-Prüfung dient der Benutzererfahrung. Die Server-Prüfung dient der Sicherheit. Verlasse dich nie nur auf eine davon.
Berechtigungsprüfungen in Next.js Middleware#
Middleware läuft vor jedem gematchten Request. Ein guter Ort für grobgranulare Zugriffskontrolle:
// "Darf dieser Benutzer überhaupt auf diesen Bereich zugreifen?"
// Feingranulare Prüfungen ("Darf dieser Benutzer DIESEN Post bearbeiten?") gehören in den Route Handler,
// weil die Middleware keinen einfachen Zugriff auf den Request-Body oder Route-Params hat.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// Routenbasierte Zugriffskontrolle
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});Häufige Schwachstellen#
Das sind die Angriffe, die ich am häufigsten in echten Codebasen sehe. Sie zu verstehen ist essentiell.
Session Fixation#
Der Angriff: Ein Angreifer erstellt eine gültige Session auf deiner Seite und bringt dann ein Opfer dazu, diese Session-ID zu verwenden (z.B. über einen URL-Parameter oder durch Setzen eines Cookies über eine Subdomain). Wenn sich das Opfer einloggt, hat die Session des Angreifers jetzt einen authentifizierten Benutzer.
Die Lösung: Generiere die Session-ID nach erfolgreicher Authentifizierung immer neu. Lass niemals eine Pre-Authentication-Session-ID in eine Post-Authentication-Session übergehen.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("Ungültige Zugangsdaten");
// KRITISCH: Alte Session löschen und eine neue erstellen
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// Komplett neue Session mit neuer ID erstellen
const newSessionId = await createSession(user.id, request);
return newSessionId;
}CSRF (Cross-Site Request Forgery)#
Der Angriff: Ein Benutzer ist auf deiner Seite eingeloggt. Er besucht eine bösartige Seite, die einen Request an deine Seite stellt. Weil Cookies automatisch gesendet werden, ist der Request authentifiziert.
Die moderne Lösung: SameSite-Cookies. Das Setzen von SameSite: Lax (mittlerweile Standard in den meisten Browsern) verhindert, dass Cookies bei Cross-Origin-POST-Requests gesendet werden, was die meisten CSRF-Szenarien abdeckt.
// SameSite=Lax deckt die meisten CSRF-Szenarien ab:
// - Blockiert Cookies bei Cross-Origin POST, PUT, DELETE
// - Erlaubt Cookies bei Cross-Origin GET (Top-Level-Navigation)
// Das ist in Ordnung, weil GET-Requests keine Seiteneffekte haben sollten
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // Das ist dein CSRF-Schutz
maxAge: 86400,
path: "/",
});Für APIs, die JSON akzeptieren, bekommst du zusätzlichen Schutz gratis: Der Content-Type: application/json Header kann nicht von HTML-Formularen gesetzt werden, und CORS verhindert, dass JavaScript von anderen Origins Requests mit benutzerdefinierten Headern macht.
Wenn du stärkere Garantien brauchst (z.B. Formular-Submissions akzeptierst), verwende das Double-Submit-Cookie-Pattern oder einen Synchronizer Token. Auth.js übernimmt das für dich.
Open Redirects in OAuth#
Der Angriff: Ein Angreifer erstellt eine OAuth-Callback-URL, die nach der Authentifizierung auf seine Seite weiterleitet: https://deineapp.com/callback?redirect_to=https://evil.com/steal-token
Wenn dein Callback-Handler blind zum redirect_to-Parameter weiterleitet, landet der Benutzer auf der Seite des Angreifers, möglicherweise mit Tokens in der URL.
// VERWUNDBAR
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... Benutzer authentifizieren ...
return Response.redirect(redirectTo); // Könnte https://evil.com sein!
}
// SICHER
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// Redirect-URL validieren
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... Benutzer authentifizieren ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// Nur Redirects zur selben Origin erlauben
if (url.origin !== base.origin) {
return "/";
}
// Nur Pfad-Redirects erlauben (keine javascript: oder data: URIs)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}Token-Leakage über Referrer#
Wenn du Tokens in URLs packst (tu das nicht), werden sie über den Referer-Header lecken, wenn Benutzer auf Links klicken. Das hat echte Datenlecks verursacht, auch bei GitHub.
Regeln:
- Niemals Tokens in URL-Query-Parameter für Authentifizierung packen
Referrer-Policy: strict-origin-when-cross-originsetzen (oder strenger)- Wenn du Tokens in URLs packen musst (z.B. E-Mail-Verifizierungslinks), mache sie einmalig verwendbar und kurzlebig
// In deiner Next.js Middleware oder deinem Layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT Key Injection#
Ein weniger bekannter Angriff: Manche JWT-Bibliotheken unterstützen einen jwk- oder jku-Header, der dem Verifizierer sagt, wo der öffentliche Schlüssel zu finden ist. Ein Angreifer kann:
- Sein eigenes Schlüsselpaar generieren
- Einen JWT mit seinem Payload erstellen und mit seinem privaten Schlüssel signieren
- Den
jwk-Header auf seinen öffentlichen Schlüssel zeigen lassen
Wenn deine Bibliothek blind den Schlüssel aus dem jwk-Header holt und verwendet, verifiziert sich die Signatur. Die Lösung: Lass niemals den Token seinen eigenen Verifizierungsschlüssel angeben. Verwende immer Schlüssel aus deiner eigenen Konfiguration.
Mein Auth-Stack 2026#
Nach Jahren des Bauens von Authentifizierungssystemen, hier ist, was ich heute tatsächlich verwende.
Für die meisten Projekte: Auth.js + PostgreSQL + Passkeys#
Das ist mein Standard-Stack für neue Projekte:
- Auth.js (v5) für die schwere Arbeit: OAuth-Provider, Session-Verwaltung, CSRF, Datenbankadapter
- PostgreSQL mit Prisma-Adapter für Session- und Kontospeicherung
- Passkeys über SimpleWebAuthn als primäre Login-Methode für neue Benutzer
- E-Mail/Passwort als Fallback für Benutzer, die keine Passkeys verwenden können
- TOTP MFA als zweiter Faktor für passwortbasierte Logins
Die Session-Strategie ist datenbankgestützt (kein JWT), was mir sofortige Sperrung und einfache Session-Verwaltung gibt.
// Das ist meine typische auth.ts für ein neues Projekt
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5 hat eingebaute Passkey-Unterstützung
// Das nutzt SimpleWebAuthn unter der Haube
}),
],
experimental: {
enableWebAuthn: true,
},
});Wann stattdessen Clerk oder Auth0 verwenden#
Ich greife zu einem verwalteten Auth-Provider wenn:
- Das Projekt Enterprise SSO braucht (SAML, SCIM). SAML korrekt zu implementieren ist ein Mehrmonatsprojekt. Clerk macht es out of the box.
- Das Team keine Sicherheitsexpertise hat. Wenn niemand im Team PKCE erklären kann, sollte das Team Auth nicht von Grund auf bauen.
- Time to Market wichtiger ist als Kosten. Auth.js ist kostenlos, braucht aber Tage für ein korrektes Setup. Clerk braucht einen Nachmittag.
- Du Compliance-Garantien brauchst (SOC 2, HIPAA). Verwaltete Provider übernehmen die Compliance-Zertifizierung.
Die Abwägungen bei verwalteten Providern:
- Kosten: Clerk berechnet pro monatlich aktivem Benutzer. Bei Skalierung summiert sich das.
- Vendor Lock-in: Von Clerk oder Auth0 wegzumigrieren ist schmerzhaft. Deine Benutzertabelle liegt auf deren Servern.
- Anpassungsgrenzen: Wenn dein Auth-Flow ungewöhnlich ist, wirst du gegen die Meinungen des Providers kämpfen.
- Latenz: Jede Auth-Prüfung geht an eine Drittanbieter-API. Mit Datenbank-Sessions ist es eine lokale Abfrage.
Was ich vermeide#
- Eigene Kryptografie schreiben. Ich verwende
josefür JWTs,@simplewebauthn/serverfür Passkeys,bcryptoderargon2für Passwörter. Niemals selbst geschrieben. - Passwörter in SHA256 speichern. Verwende bcrypt (Kostenfaktor 12+) oder argon2id. SHA256 ist zu schnell — ein Angreifer kann Milliarden Hashes pro Sekunde mit einer GPU probieren.
- Langlebige Access Tokens. Maximal 15 Minuten. Verwende Refresh Token Rotation für längere Sessions.
- Symmetrische Secrets für service-übergreifende Verifizierung. Wenn mehrere Services Tokens verifizieren müssen, verwende RS256 mit einem Public/Private-Schlüsselpaar.
- Eigene Session-IDs mit unzureichender Entropie. Verwende mindestens
crypto.randomBytes(32). UUID v4 ist akzeptabel, hat aber weniger Entropie als rohe Zufallsbytes.
Passwort-Hashing: Der richtige Weg#
Da wir es erwähnt haben — so hashst du Passwörter 2026 korrekt:
import { hash, verify } from "@node-rs/argon2";
// Argon2id ist der empfohlene Algorithmus
// Das sind vernünftige Standardwerte für eine Webanwendung
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 Iterationen
parallelism: 4, // 4 Threads
});
}
async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}Warum argon2id statt bcrypt? Argon2id ist speicherhart, was bedeutet, dass ein Angriff nicht nur CPU-Leistung, sondern auch große Mengen an RAM erfordert. Das macht GPU- und ASIC-Angriffe deutlich teurer. Bcrypt ist immer noch in Ordnung — es ist nicht gebrochen — aber argon2id ist die bessere Wahl für neue Projekte.
Sicherheits-Checkliste#
Bevor du ein Authentifizierungssystem auslieferst, überprüfe:
- Passwörter werden mit argon2id oder bcrypt (Kostenfaktor 12+) gehasht
- Sessions werden nach dem Login regeneriert (verhindert Session Fixation)
- Cookies sind
HttpOnly,Secure,SameSite=LaxoderStrict - JWTs geben Algorithmen explizit an (vertraue nie dem
alg-Header) - Access Tokens laufen in 15 Minuten oder weniger ab
- Refresh Token Rotation ist implementiert mit Wiederverwendungserkennung
- OAuth-State-Parameter wird verifiziert (CSRF-Schutz)
- Redirect-URLs werden gegen eine Allowlist validiert
- Rate Limiting wird auf Login-, Registrierungs- und Passwort-Reset-Endpunkte angewandt
- Fehlgeschlagene Login-Versuche werden mit IP, aber nicht mit Passwörtern geloggt
- Kontosperrung nach N fehlgeschlagenen Versuchen (mit progressiven Verzögerungen, nicht permanenter Sperre)
- Passwort-Reset-Tokens sind einmalig verwendbar und laufen in 1 Stunde ab
- MFA-Backup-Codes werden wie Passwörter gehasht
- CORS ist so konfiguriert, dass nur bekannte Origins erlaubt sind
-
Referrer-Policy-Header ist gesetzt - Keine sensiblen Daten in JWT-Payloads (sie sind für jeden lesbar)
- WebAuthn-Counter wird verifiziert und aktualisiert (verhindert Credential-Klonen)
Diese Liste ist nicht vollständig, aber sie deckt die Schwachstellen ab, die ich am häufigsten in Produktionssystemen gesehen habe.
Fazit#
Authentifizierung ist eine dieser Domänen, in denen sich die Landschaft ständig weiterentwickelt, aber die Grundlagen gleich bleiben: Identität verifizieren, die minimal notwendigen Credentials ausstellen, Berechtigungen an jeder Grenze prüfen und von einem Breach ausgehen.
Die größte Veränderung 2026 ist, dass Passkeys Mainstream werden. Browser-Support ist universell, Plattform-Support (iCloud Keychain, Google Password Manager) macht die UX nahtlos, und die Sicherheitseigenschaften sind wirklich besser als alles, was wir vorher hatten. Wenn du eine neue Anwendung baust, mach Passkeys zu deiner primären Login-Methode und behandle Passwörter als Fallback.
Die zweitgrößte Veränderung ist, dass es immer schwerer zu rechtfertigen ist, Auth selbst zu bauen. Auth.js v5, Clerk und ähnliche Lösungen handhaben die schweren Teile korrekt. Der einzige Grund, es selbst zu machen, ist wenn deine Anforderungen wirklich in keine bestehende Lösung passen — und das ist seltener, als die meisten Entwickler denken.
Was auch immer du wählst, teste dein Auth so, wie ein Angreifer es tun würde. Versuche Tokens abzuspielen, Signaturen zu fälschen, auf Routen zuzugreifen, die du nicht solltest, und Redirect-URLs zu manipulieren. Die Bugs, die du vor dem Launch findest, sind die, die nicht in den Nachrichten landen.