Sichere API-Authentifizierung: JWT, OAuth und Sessions
Praktischer Leitfaden zu sicherer Authentifizierung für moderne APIs: JWT-Tokens, OAuth-2.0-Flows und typische Fehler bei der Sitzungsverwaltung.

Warum API-Authentifizierung 2024 immer noch kaputt ist
Jede Woche taucht ein neuer Bericht über eine Sicherheitsverletzung auf. Gestohlene Tokens, Session-Hijacking, unsichere OAuth-Flows – das sind keine exotischen Angriffe. Sie sind das tägliche Brot moderner Angriffe. Die Ursache ist fast immer dieselbe: Entwickler wählen eine Authentifizierungsstrategie, ohne die Kompromisse dahinter zu verstehen.
Authentifizierung wirkt auf den ersten Blick einfach. Man prüft, wer jemand ist, gibt ihm eine Anmeldeinformation und überprüft sie bei jeder weiteren Anfrage. Doch der Teufel steckt im Detail: Token-Speicherung, Rotationsrichtlinien, Scope-Verwaltung und ein Dutzend weiterer Punkte, die in Tutorials gerne übergangen werden.
Dieser Leitfaden geht die drei vorherrschenden Ansätze für die API-Authentifizierung durch – JWT, OAuth 2.0 und serverseitige Sessions – und zeigt, wie man jeden davon umsetzt, ohne die typischen Fehler zu machen, die zu Sicherheitsverletzungen führen.
JWT-Authentifizierung: Macht und Risiko
JSON Web Tokens wurden zum Standard für zustandslose Authentifizierung. Ein JWT enthält einen Base64-kodierten Header, eine Payload und eine Signatur. Der Server erzeugt ihn, der Client speichert ihn, und jede Anfrage schickt ihn zurück.
Der Reiz liegt auf der Hand: kein serverseitiger Session-Speicher, horizontale Skalierung ohne Sticky Sessions und eine einfache Verifizierung über mehrere Services hinweg. Aber unvorsichtig implementiert bergen JWTs echte Risiken.
// ❌ Bad: Long-lived JWT with sensitive data in payload
import jwt from "jsonwebtoken";
function generateToken(user: User): string {
return jwt.sign(
{
id: user.id,
email: user.email,
role: user.role,
ssn: user.ssn, // Never put sensitive data in JWT
},
"my-secret-key", // Hardcoded secret
{ expiresIn: "30d" } // Way too long
);
}// ✅ Good: Short-lived JWT with minimal claims and proper key management
import jwt from "jsonwebtoken";
interface TokenPayload {
sub: string;
role: string;
jti: string;
}
function generateAccessToken(user: User): string {
const payload: TokenPayload = {
sub: user.id,
role: user.role,
jti: crypto.randomUUID(),
};
return jwt.sign(payload, process.env.JWT_SECRET!, {
algorithm: "HS256",
expiresIn: "15m",
issuer: "api.example.com",
audience: "example.com",
});
}
function generateRefreshToken(user: User): string {
return jwt.sign(
{ sub: user.id, jti: crypto.randomUUID() },
process.env.JWT_REFRESH_SECRET!,
{ algorithm: "HS256", expiresIn: "7d" }
);
}Kurzlebige Access Tokens (15 Minuten oder weniger) in Kombination mit Refresh Tokens begrenzen den Schaden, den ein kompromittiertes Token anrichten kann. Der jti-Claim liefert dir eine eindeutige Kennung für die Nachverfolgung bei Widerruf.
Token-Speicherung: Wo die meisten Teams es falsch machen
Wo du Tokens auf dem Client speicherst, ist wichtiger als wie du sie erzeugst. Auf LocalStorage kann jedes JavaScript auf der Seite zugreifen – eine einzige XSS-Lücke genügt, und deine Tokens werden abgegriffen.
// ❌ Bad: Storing JWT in localStorage
function login(token: string): void {
localStorage.setItem("access_token", token);
}
function getAuthHeader(): Record<string, string> {
const token = localStorage.getItem("access_token");
return { Authorization: `Bearer ${token}` };
}// ✅ Good: HTTP-only cookies with proper flags
import { NextResponse } from "next/server";
function setAuthCookies(
response: NextResponse,
accessToken: string,
refreshToken: string
): NextResponse {
response.cookies.set("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 900, // 15 minutes
path: "/",
});
response.cookies.set("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 604800, // 7 days
path: "/api/auth/refresh",
});
return response;
}HTTP-only-Cookies können von JavaScript nicht gelesen werden, wodurch der XSS-Diebstahlvektor für Tokens entfällt. Das Flag sameSite: "strict" verhindert CSRF-Angriffe, indem es das Cookie bei Cross-Origin-Anfragen blockiert. Wird der Pfad des Refresh Tokens auf den Refresh-Endpunkt beschränkt, sinkt seine Angriffsfläche zusätzlich.
OAuth-2.0-Flows: den richtigen auswählen
OAuth 2.0 ist keine Authentifizierung, sondern Autorisierung. In Kombination mit OpenID Connect wird es jedoch zur Grundlage moderner Identitätssysteme. Das Problem: OAuth definiert mehrere Flows, und der falsche Flow reißt Sicherheitslücken auf.
// Authorization Code Flow with PKCE (recommended for SPAs and mobile)
import crypto from "crypto";
function generatePKCE(): {
codeVerifier: string;
codeChallenge: string;
} {
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
return { codeVerifier, codeChallenge };
}
function buildAuthorizationUrl(
clientId: string,
redirectUri: string,
codeChallenge: string
): string {
const params = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: "openid profile email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state: crypto.randomBytes(16).toString("hex"),
});
return `https://auth.example.com/authorize?${params.toString()}`;
}Der Authorization-Code-Flow mit PKCE ist heute der empfohlene Ansatz für alle Client-Typen. Der implizite Flow gilt als veraltet – er legt Tokens im URL-Fragment offen, das im Browserverlauf und in Server-Logs landet.
// ❌ Bad: Implicit flow exposes tokens in URL
// redirect: https://app.com/callback#access_token=eyJ...&token_type=bearer
// ✅ Good: Authorization code exchange happens server-side
async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const response = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: process.env.OAUTH_REDIRECT_URI!,
client_id: process.env.OAUTH_CLIENT_ID!,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
return response.json();
}Validiere beim Callback immer den Parameter state, um CSRF-Angriffe auf den OAuth-Flow selbst zu verhindern. Überspringe diesen Schritt niemals, auch nicht in der Entwicklung.
Serverseitige Sessions: die unterschätzte Option
Sessions gerieten aus der Mode, als Microservices die Oberhand gewannen, doch für monolithische Anwendungen und Backends-for-Frontends bleiben sie die sicherste Option. Eine Session-ID in einem HTTP-only-Cookie, deren Zustand serverseitig gehalten wird, ermöglicht sofortigen Widerruf – ganz ohne die Komplexität von Token-Blacklists.
import { Redis } from "ioredis";
import crypto from "crypto";
const redis = new Redis(process.env.REDIS_URL!);
const SESSION_TTL = 3600; // 1 hour
interface SessionData {
userId: string;
role: string;
createdAt: number;
lastActivity: number;
}
async function createSession(user: User): Promise<string> {
const sessionId = crypto.randomBytes(32).toString("hex");
const sessionData: SessionData = {
userId: user.id,
role: user.role,
createdAt: Date.now(),
lastActivity: Date.now(),
};
await redis.setex(
`session:${sessionId}`,
SESSION_TTL,
JSON.stringify(sessionData)
);
return sessionId;
}
async function validateSession(
sessionId: string
): Promise<SessionData | null> {
const data = await redis.get(`session:${sessionId}`);
if (!data) return null;
const session: SessionData = JSON.parse(data);
session.lastActivity = Date.now();
await redis.setex(
`session:${sessionId}`,
SESSION_TTL,
JSON.stringify(session)
);
return session;
}
async function revokeSession(sessionId: string): Promise<void> {
await redis.del(`session:${sessionId}`);
}Der Kompromiss ist offensichtlich: Sessions brauchen einen gemeinsam genutzten Speicher (Redis, eine Datenbank), was eine zusätzliche Abhängigkeit schafft und die horizontale Skalierung einschränkt. Dafür bekommst du sofortigen Widerruf, keinen Overhead durch die Tokengröße und volle Kontrolle über den Lebenszyklus der Session.
Middleware-Muster für die Authentifizierung
Authentifizierungslogik gehört in Middleware, nicht verstreut über einzelne Route-Handler. Eine saubere Middleware-Kette validiert Anmeldedaten, extrahiert die Identität und hängt sie an den Request-Kontext an.
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";
interface AuthenticatedRequest extends NextRequest {
user?: { sub: string; role: string };
}
function authMiddleware(
handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) {
return async (req: AuthenticatedRequest): Promise<NextResponse> => {
const token = req.cookies.get("access_token")?.value;
if (!token) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ["HS256"],
issuer: "api.example.com",
}) as { sub: string; role: string };
req.user = payload;
return handler(req);
} catch {
return NextResponse.json(
{ error: "Invalid or expired token" },
{ status: 401 }
);
}
};
}
function requireRole(...roles: string[]) {
return (
handler: (req: AuthenticatedRequest) => Promise<NextResponse>
) => {
return authMiddleware(async (req: AuthenticatedRequest) => {
if (!req.user || !roles.includes(req.user.role)) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
);
}
return handler(req);
});
};
}Beachte das Feld algorithms in jwt.verify. Ohne dieses Feld könnte ein Angreifer ein Token senden, das mit dem Algorithmus "none" signiert wurde, und die Verifizierung komplett umgehen. Gib den erwarteten Algorithmus immer explizit an.
Rotation und Widerruf von Refresh-Tokens
Refresh Tokens sind langlebig und mächtig. Wird eines gestohlen, kann der Angreifer beliebig oft neue Access Tokens ausstellen. Die Rotation von Refresh Tokens löst dieses Problem, indem bei jeder Erneuerung des Access Tokens ein neues Refresh Token ausgestellt und das alte ungültig gemacht wird.
async function rotateRefreshToken(
currentRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
let payload: { sub: string; jti: string };
try {
payload = jwt.verify(
currentRefreshToken,
process.env.JWT_REFRESH_SECRET!,
{ algorithms: ["HS256"] }
) as { sub: string; jti: string };
} catch {
throw new Error("Invalid refresh token");
}
const isRevoked = await redis.get(`revoked:${payload.jti}`);
if (isRevoked) {
await redis.del(`refresh_family:${payload.sub}`);
throw new Error("Refresh token reuse detected — all sessions revoked");
}
await redis.setex(`revoked:${payload.jti}`, 604800, "true");
const user = await getUserById(payload.sub);
if (!user) throw new Error("User not found");
return {
accessToken: generateAccessToken(user),
refreshToken: generateRefreshToken(user),
};
}Entscheidend ist, die Wiederverwendung eines Refresh Tokens zu erkennen. Wird ein bereits widerrufenes Token erneut vorgelegt, bedeutet das, dass es gestohlen wurde – sowohl der rechtmäßige Nutzer als auch der Angreifer besitzen eine Kopie. Die richtige Reaktion ist, die gesamte Token-Familie zu widerrufen und eine erneute Authentifizierung zu erzwingen.
Die wichtigsten Erkenntnisse
API-Authentifizierung ist ein Spektrum von Kompromissen. JWTs geben dir zustandslose Verifizierung auf Kosten der Widerrufbarkeit. Sessions geben dir Kontrolle auf Kosten der Infrastruktur. OAuth gibt dir Delegation auf Kosten der Komplexität.
Die nicht verhandelbaren Grundsätze bleiben unabhängig vom gewählten Ansatz dieselben: Tokens in HTTP-only-Cookies speichern, Access Tokens kurzlebig halten, alles serverseitig validieren, Algorithmen explizit angeben, Refresh-Token-Rotation implementieren und niemals sensible Daten in Token-Payloads ablegen.
Sicherheit ist kein Feature, das man am Ende hinzufügt. Sie ist eine Randbedingung, um die herum man von Anfang an gestaltet. Die Authentifizierungsstrategie, die du am ersten Tag wählst, prägt jeden API-Endpunkt, den du danach baust.


