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.
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.
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:
Die Nachteile sind ebenfalls real:
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:
Die Nachteile — und das sind die, die gerne übergangen werden:
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.
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.
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.
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.
alg: none Angriff#Das ist die berühmteste JWT-Schwachstelle, und sie ist peinlich einfach. Manche JWT-Bibliotheken haben früher:
alg-Feld aus dem Header gelesenalg: "none" die Signaturprüfung komplett übersprungenEin 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.
Verwandt mit dem obigen: Wenn ein Server für RS256 konfiguriert ist, könnte ein Angreifer:
alg: "HS256" erstellenWenn 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.
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:
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 };
}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 };
}Betrachte dieses Szenario:
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 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.
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).
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,
};
}Jeder OAuth/OIDC-Provider stellt diese bereit:
Der state-Parameter verhindert CSRF-Angriffe auf den OAuth-Callback. Ohne ihn:
https://deineapp.com/callback?code=ANGREIFER_CODEMit 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 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.
// 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",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// 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",
],
};// 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>
);
}"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 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.
Passkeys nutzen Public-Key-Kryptografie, gestützt durch Biometrie oder Geräte-PINs:
Kein gemeinsames Geheimnis überquert jemals das Netzwerk. Es gibt nichts zu phishen, nichts das lecken kann, nichts zum Credential-Stuffing.
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.
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");
}
}
}Zwei Begriffe, denen du begegnen wirst:
attestationType: "none".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 ist das Protokoll hinter Google Authenticator, Authy und 1Password. Es funktioniert so:
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());
}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;
}MFA-Wiederherstellung ist der Teil, den die meisten Tutorials überspringen und die meisten echten Anwendungen vermasseln. Das implementiere ich:
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." };
}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 (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
}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.
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();
});Das sind die Angriffe, die ich am häufigsten in echten Codebasen sehe. Sie zu verstehen ist essentiell.
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;
}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.
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 "/";
}
}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:
Referrer-Policy: strict-origin-when-cross-origin setzen (oder strenger)// In deiner Next.js Middleware oder deinem Layout
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");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:
jwk-Header auf seinen öffentlichen Schlüssel zeigen lassenWenn 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.
Nach Jahren des Bauens von Authentifizierungssystemen, hier ist, was ich heute tatsächlich verwende.
Das ist mein Standard-Stack für neue Projekte:
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,
},
});Ich greife zu einem verwalteten Auth-Provider wenn:
Die Abwägungen bei verwalteten Providern:
jose für JWTs, @simplewebauthn/server für Passkeys, bcrypt oder argon2 für Passwörter. Niemals selbst geschrieben.crypto.randomBytes(32). UUID v4 ist akzeptabel, hat aber weniger Entropie als rohe Zufallsbytes.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.
Bevor du ein Authentifizierungssystem auslieferst, überprüfe:
HttpOnly, Secure, SameSite=Lax oder Strictalg-Header)Referrer-Policy-Header ist gesetztDiese Liste ist nicht vollständig, aber sie deckt die Schwachstellen ab, die ich am häufigsten in Produktionssystemen gesehen habe.
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.