Was Session-Authentifizierung ist
Die Session-Authentifizierung ist die älteste und nach wie vor am häufigsten verwendete Methode, um einen Benutzer im Web angemeldet zu halten. Die Idee dahinter ist simpel: Der Server erinnert sich an dich und der Browser bewahrt eine Quittung auf.
Wenn du dich einloggst, überprüft der Server deine Anmeldedaten und erstellt eine Session – einen Datensatz, den er selbst speichert. Dieser Datensatz kann deine User-ID, den Zeitpunkt der Erstellung, das Ablaufdatum und Zustände wie einen Warenkorb enthalten. Der Server sendet dann eine Session-ID an deinen Browser: eine lange, zufällige Zeichenfolge, die genau diesen einen Datensatz identifiziert. Der Browser speichert die ID in einem Cookie und sendet sie bei jeder Anfrage zurück. Eine middleware auf dem Server liest die ID aus, lädt den Datensatz und weiß nun, wer du bist.
Das entscheidende Merkmal ist, dass der Browser einen opaken Identifikator hält und nicht deine Identität. Es ist wie eine Garderobenmarke, nicht der Mantel selbst. Wenn die ID durchsickert, kann ein Angreifer deine Identität übernehmen, bis du oder der Server den Datensatz ungültig machen – aber die ID selbst verrät nichts, kann nicht dekodiert und nicht bearbeitet werden, um zusätzliche Berechtigungen zu erhalten. Alles Relevante existiert hinter der Abfrage des Servers.
Dies ist das Gegenteil eines eigenständigen Tokens wie einem JWT, bei dem die Anmeldedaten signierte Claims tragen und der Server diesen vertraut, ohne einen Datenbank-Roundtrip durchführen zu müssen. Die Session-Authentifizierung entscheidet sich für eine Abfrage im Austausch gegen mehr Kontrolle. Dieser Kompromiss ist das Thema dieses Guides.
Der Session-Lifecycle
Jede Session folgt demselben Ablauf: Sie wird beim Login erstellt, per Cookie übertragen, pro Request geladen und beim Logout zerstört.
Erstellung. Ein erfolgreicher POST /login ist der einzige Ort, an dem eine Session entstehen sollte. Generieren Sie eine kryptografisch zufällige ID – mindestens 128 Bit aus einem CSPRNG, niemals einen Zähler oder einen erratbaren Wert – und speichern Sie einen Datensatz, der über diese ID referenziert wird. Legen Sie ein Ablaufdatum fest. Wenn die App eine „Angemeldet halten“-Funktion unterstützt, wählen Sie bewusst eine längere Lebensdauer, anstatt dies dem Zufall zu überlassen.
Transport. Senden Sie die ID mit einem Set-Cookie Header. Das HttpOnly Flag schützt sie vor dem Zugriff durch JavaScript, Secure beschränkt die Übertragung auf HTTPS und SameSite limitiert, wann sie an Cross-Site-Requests angehängt wird. Ohne diese Flags ist die Session-ID anfällig für XSS und Network Sniffing.
Lookup. Bei jedem folgenden Request liest eine middleware das Cookie aus, sucht die ID im Store und verknüpft entweder den Benutzer mit dem Request oder lehnt den Request ab. Eine fehlende oder abgelaufene ID bedeutet einen anonymen Request, keinen Fehler, sodass öffentliche Seiten weiterhin funktionieren.
Zerstörung. Ein Logout löscht den Datensatz aus dem Store und leert das Cookie. Das Löschen des Datensatzes ist das, was die Session tatsächlich beendet; das Leeren des Cookies ist eine Höflichkeit, die verhindert, dass der Browser eine ungültige ID sendet. Ein serverseitiger Expiry-Job sollte zudem verwaiste Sessions bereinigen, damit der Store nicht unendlich anwächst.
Dieser Lifecycle ist bewusst langweilig, und genau das ist ein Feature. Es gibt einen Ort, der Sessions erstellt, einen Ort, der sie lädt, und einen Ort, der sie zerstört – das ist leicht zu auditieren und einfach zu testen.
Passwörter sind die erste Hürde
Eine Session kann nur so vertrauenswürdig sein wie der Login, der sie erstellt hat. Daher verdient die Passwortspeicherung Beachtung, noch bevor überhaupt ein Cookie zum Einsatz kommt.
Speichern Sie niemals ein Passwort im Klartext. Speichern Sie einen langsamen, gesalzenen Hash, der von einem eigens dafür entwickelten Algorithmus wie Argon2id, bcrypt oder scrypt erzeugt wurde. Diese sind bewusst rechenintensiv, wodurch ein Datenbank-Leak von einem sofortigen Credential-Dump in einen langwierigen und kostspieligen Cracking-Prozess verwandelt wird. Ein Allzweck-Hash wie SHA-256 ist hierfür das falsche Werkzeug: Er ist schnell, und genau das ist es, was ein Angreifer will.
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
Die Verifizierung muss in konstanter Zeit (constant-time) erfolgen oder, noch besser, an den Vergleichsmechanismus des Algorithmus selbst delegiert werden, damit keine Informationen über das Timing preisgegeben werden. Am wichtigsten ist es, bei einer unbekannten E-Mail-Adresse und einem falschen Passwort den gleichen Fehler zurückzugeben. Die Meldung „Benutzer nicht gefunden“ liefert einem Angreifer ein kostenloses Oracle zur Account-Enumeration. Eine generische invalid_credentials-Antwort, bei der in beiden Fällen ein vergleichbarer Rechenaufwand betrieben wird, verrät hingegen nichts.
Ein erfolgreicher Login ist zudem der richtige Zeitpunkt, um über Rate Limiting, Lockouts nach wiederholten Fehlversuchen und Multi-Faktor-Challenges nachzudenken. All dies geschieht, bevor der Session-Datensatz erstellt wird.
Was in einer Session gespeichert werden sollte
Ein Session-Datensatz sollte ein Zeiger sein, keine Fotokopie. Die Versuchung, das gesamte User-Objekt hineinzustopfen, ist groß und meistens ein Fehler.
Speichern Sie die User ID und lassen Sie den Request den Rest laden. Wenn Sie den User aus Performance-Gründen cachen, geben Sie ihm eine kurze Lebensdauer und invalidieren Sie ihn bei Änderungen. Eine veraltete Kopie in einer langlebigen Session führt nämlich zu Bugs, die schwer zu reproduzieren sind: Ein Admin, dessen Rechte herabgestuft wurden, behält seine alte Rolle, bis er sich erneut anmeldet.
Angemessene Dinge, die serverseitig gespeichert werden können, sind die User ID, eine Rolle oder Tenant ID (sofern diese klein ist und sich selten ändert), ein CSRF-Token, ein „Remember Me“-Flag sowie kurzlebiger UI-State, wie etwa eine Flash-Message oder ein Redirect-Ziel nach dem Login. Vermeiden Sie es, große Objekte, Secrets, Tokens für andere Dienste oder irgendetwas zu speichern, das nicht in einem Debugging-Dump erscheinen sollte.
declare module "express-session" {
interface SessionData {
userId: string;
tenantId: string;
csrfToken: string;
flash?: { type: "info" | "error"; message: string };
}
}
Wenn der Session-Payload über einige Kilobyte anwächst, ist das ein Zeichen dafür, dass die Daten in die Datenbank gehören – keyed by the user und bei Bedarf geladen. Kleine Sessions lassen sich schneller serialisieren, sind günstiger zu speichern und leichter zu handhaben.
Aufbau des middleware-Stacks
Die Session-Verwaltung lässt sich am besten als eine kleine, geordnete Kette darstellen: Cookie parsen, Session laden, Benutzer zuordnen und anschließend geschützte Routen absichern. Jedes Element erledigt genau eine Aufgabe, was das gesamte System testbar macht.
import cookieParser from "cookie-parser";
import session from "express-session";
app.use(cookieParser());
app.use(
session({
name: "__Host-sid",
secret: process.env.SESSION_SECRET!,
store,
resave: false, // do not rewrite unchanged sessions
saveUninitialized: false, // do not create a session for anonymous visitors
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 1000 * 60 * 60 * 12,
},
})
);
app.use(loadUser()); // attach req.user when a session exists
app.use(csrf()); // issue and verify CSRF tokens
app.use("/api", routes); // handlers can call requireAuth() themselves
Zwei Optionen sorgen meist für die größte Verwirrung. resave: false verhindert, dass die middleware die Session bei jeder Anfrage zurück in den Store schreibt, selbst wenn sich nichts geändert hat, was einen Netzwerk-Roundtrip einspart. saveUninitialized: false verhindert, dass für jeden anonymen Besucher eine Session erstellt wird; so wird vermieden, dass der Store mit leeren Datensätzen gefüllt wird und ein Cookie gesetzt wird, bevor der Benutzer überhaupt eine Aktion ausgeführt hat. Beides sind Einstellungen, die man fast immer aktivieren möchte.
Die Reihenfolge ist entscheidend. Der Cookie-Parser muss vor der Session-middleware laufen, die Session-middleware vor allem, was req.session ausliest, und der User-Loader vor jeder Route, die req.user prüft. Guard-middleware kann global oder pro Route implementiert werden; die Zuweisung pro Route sorgt dafür, dass öffentliche Endpunkte standardmäßig öffentlich bleiben.
SameSite im Detail
SameSite ist das Attribut, das am häufigsten falsch verstanden wird. Daher lohnt es sich, genau zu verstehen, was die einzelnen Werte tatsächlich erlauben.
Strict sendet das Cookie niemals bei einer Anfrage, deren Site sich von der unterscheidet, die das Cookie gesetzt hat. Wenn ein Nutzer in einer E-Mail auf einen Link zu Ihrer App klickt, kommt die Anfrage ohne Session an. Der Nutzer erscheint also beim ersten Navigationsschritt möglicherweise als ausgeloggt und erst nach einem Refresh als eingeloggt. Dies ist die sicherste Einstellung und eignet sich gut für hochsensible Admin-Tools.
Lax sendet das Cookie bei Top-Level-Navigationen unter Verwendung sicherer Methoden, jedoch nicht bei cross-site POSTs, Bildern oder iframes. Dies deckt den häufigen Fall ab, einem Link zu folgen und eingeloggt zu bleiben, während der klassische CSRF-Form-Post blockiert wird. Es ist der vernünftige Standard für die meisten Web-Apps und der Browser-Standard in modernem Chrome, wenn das Attribut weggelassen wird.
None sendet das Cookie bei jeder cross-site Anfrage und erfordert Secure. Dies ist nur für echte cross-site Flows notwendig, wie etwa einen eingebetteten Checkout oder ein Widget, das von einem anderen Origin bereitgestellt wird. Jede Verwendung von None vergrößert Ihre CSRF-Angriffsfläche. Fügen Sie daher Token auf Anwendungsebene hinzu und stellen Sie sicher, dass das Secure-Flag gesetzt ist, da der Browser das Cookie andernfalls komplett ablehnen wird.
Ein wichtiger Detailpunkt: SameSite vergleicht Sites, nicht Origins, und die Definition einer Site ist die registrierbare Domain. app.example.com und api.example.com gelten als dieselbe Site. Eine kompromittierte Subdomain ist daher durch SameSite überhaupt nicht geschützt. Dies ist ein weiterer Grund, warum das __Host--Cookie-Präfix und eine strikte Subdomain-Hygiene wichtig sind.
Eigenbau oder Nutzung einer Library
Es ist verlockend, Sessions von Hand zu implementieren: Ein Cookie setzen, einen Map führen, diesen abfragen. Zum Lernen ist das hervorragend. Für die Produktion ist es meist ein Fehler, da die entscheidenden Details genau jene sind, die man leicht übersieht.
Eine ausgereifte Library wie express-session für Node, das Session-Framework von Django oder der Session-Store von Rails bietet signierte IDs, sichere Cookie-Standardwerte, austauschbare Stores, ID-Regenerierung und die Handhabung von Ablaufzeiten. Sie sollten jeden dieser Mechanismen dennoch verstehen – genau dafür ist dieser Guide da –, aber Sie sollten nicht die einzige Person sein, die sich Gedanken darüber gemacht hat.
Falls Sie dennoch eine eigene Lösung bauen, sieht die Mindest-Checkliste so aus: Eine CSPRNG-ID von mindestens 128 Bit, ein Constant-Time-Lookup, HttpOnly, Secure und SameSite Cookies, ID-Rotation beim Login, serverseitiges Expiry und ein Pfad zum Löschen beim Logout. Wenn einer dieser Punkte fehlt, haben Sie eine Sicherheitslücke und kein Session-System.
Testen der Session-Authentifizierung
Die Authentifizierung ist sicherheitskritisch und verdient daher Tests, die nicht nur den „Happy Path“, sondern gezielt auch Negativfälle prüfen.
import request from "supertest";
import app from "../app.js";
test("protected route rejects anonymous requests", async () => {
await request(app).get("/api/me").expect(401);
});
test("login sets an HttpOnly session cookie", async () => {
const res = await request(app)
.post("/login")
.send({ email: "[email protected]", password: "correct-horse" })
.expect(200);
const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
});
test("logout invalidates the session", async () => {
const agent = request.agent(app);
await agent.post("/login").send(credentials).expect(200);
await agent.get("/api/me").expect(200);
await agent.post("/logout").expect(204);
await agent.get("/api/me").expect(401);
});
Die Verwendung eines Agents, der Cookies beibehält, ermöglicht es einem einzelnen Test, das Verhalten eines echten Browsers zu simulieren. Überprüfen Sie, dass sich die Session-ID nach dem Login ändert, dass eine abgelaufene Session abgelehnt wird und dass manipulierte Cookies ignoriert werden. Diese Tests finden Regressionen, die bei einem manuellen Durchklicken übersehen würden.
Cookie-Attribute, auf die es ankommt
Die Session-ID ist nur so sicher wie das Cookie, das sie transportiert. Diese Attribute machen den Unterschied zwischen einem soliden Design und einer Sicherheitslücke.
- HttpOnly — das Cookie ist für
document.cookieunsichtbar. Dies ist das wichtigste Flag, da es den Diebstahl von Sessions via XSS neutralisiert. Setzen Sie es immer. - Secure — der Browser sendet das Cookie nur über HTTPS. Ohne dieses Flag kann ein Angreifer im Netzwerk die ID auslesen. Setzen Sie es in der Production immer und beachten Sie, dass
SecureCookies bei einfachem HTTP verworfen werden, was die lokale Entwicklung beeinflusst. - SameSite — steuert das Senden über verschiedene Seiten hinweg.
Strictsendet das Cookie niemals cross-site, was am sichersten ist, aber eingehende Links unterbricht, die den Nutzer sonst eingeloggt halten würden.Laxsendet es bei Top-Level-Navigationen, wie etwa beim Klicken auf einen Link, und ist für die meisten Apps der richtige Standard.Nonesendet es überall und erfordertSecure. - Path — beschränkt das Cookie auf ein URL-Präfix.
Path=/ist typisch. Die Beschränkung auf/appreduziert die Angriffsfläche geringfügig, hilft aber selten genug, um ein unerwartetes Verhalten zu rechtfertigen. - Domain — steuert, welche Hosts das Cookie erhalten. Wenn man dieses Attribut weglässt, bleibt das Cookie auf dem exakten Host, was die sicherere Wahl ist. Das Setzen einer Parent-Domain teilt die Session über Subdomains hinweg, was den Schadensradius einer kompromittierten Subdomain vergrößert.
- Max-Age und Expires — legen fest, wann das Cookie verworfen werden soll. Ein Session-Cookie ohne diese Attribute bleibt so lange bestehen, bis der Browser geschlossen wird, was auf Mobilgeräten oft nicht den Erwartungen der Nutzer entspricht. Ein explizites
Max-Ageist klarer. __Host--Präfix — wenn ein Cookie__Host-sidgenannt wird, erzwingt diesSecure,Path=/und keinDomain, was verhindert, dass eine Subdomain Ihr Session-Cookie überschreibt.
HTTP/1.1 200 OK
Set-Cookie: __Host-sid=s%3A9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
Auswahl eines Session-Stores
Die Entscheidung, wo Sessions gespeichert werden, hat den größten Einfluss darauf, wie Ihre App skaliert.
In-process memory ist in vielen Frameworks der Standard und eignet sich gut für eine App mit einer einzigen Instanz oder für einen Prototyp. Die Daten gehen beim Neustart verloren und können nicht geteilt werden, weshalb dieser Ansatz scheitert, sobald Sie mehr als einen Prozess betreiben.
Redis ist die Standardwahl für die Produktion. Es ist schnell, unterstützt nativ das Ablaufdatum von Keys und wird von jeder Instanz gemeinsam genutzt. Sessions sind kleine Key-Value-Datensätze, was genau der Stärke von Redis entspricht. Ein managed Redis ist kostengünstig und nimmt Ihnen die betriebliche Last ab.
Eine relationale Datenbank funktioniert gut, wenn Sie bereits PostgreSQL oder MySQL einsetzen und möchten, dass Sessions auch bei einem Redis-Ausfall erhalten bleiben. Sie erhalten Persistenz und eine einfache Überprüfung mittels SQL, allerdings auf Kosten einer langsameren Abfrage, sofern die Tabelle nicht klein ist und über die ID indiziert wurde.
Eine dedizierte Session-Tabelle ist in Frameworks wie Django und Rails üblich. Der Store ist eine Tabelle mit einem Session-Key, einem serialisierten Payload und einer Spalte für das Ablaufdatum. Ein Index auf dem Key und ein periodischer Cleanup-Job sind die einzigen erforderlichen Wartungsmaßnahmen.
CREATE TABLE sessions (
id text PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
data jsonb NOT NULL DEFAULT '{}'::jsonb,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_expires_idx ON sessions (expires_at);
Speicher skaliert nicht
Es ist wichtig, klar zu benennen, warum In-Memory-Sessions in der Produktion ein Bug sind, da das daraus resultierende Versagen sporadisch auftritt und verwirrend ist.
Stellen Sie sich zwei App-Instanzen hinter einem Load Balancer ohne gemeinsamen Store vor. Ein Benutzer loggt sich ein und landet auf Instanz A, welche die Session im eigenen Speicher ablegt. Die nächste Anfrage wird an Instanz B geroutet, die keinen Eintrag zu dieser ID hat – für den Benutzer sieht es also so aus, als wäre er ausgeloggt. Wenn er sich erneut einloggt, springt er möglicherweise zwischen den beiden Instanzen hin und her. Der Benutzer erlebt zufällige Logouts; die Logs zeigen eine Session, die auf einem Node existiert, auf dem anderen jedoch nicht.
Man kann dies mit Sticky Sessions übertünchen, bei denen der Load Balancer einen Client an eine bestimmte Instanz bindet. Das hilft zwar, ist aber fragil: Ein Deployment, ein Absturz oder ein Autoscaling-Event löscht den Node und damit jede darauf befindliche Session. Sticky Sessions behindern zudem die horizontale Skalierung und erschweren Canary Releases.
Die ehrliche Lösung ist ein gemeinsamer Store. Sobald Sessions in Redis oder einer Datenbank liegen, kann jede Instanz jede Anfrage bedienen, Deployments werden für angemeldete Benutzer unsichtbar und Sie können die Kapazität beliebig erweitern. Nutzen Sie den Speicher nur für die lokale Entwicklung und machen Sie den Store konfigurierbar, damit die Produktion ihn niemals versehentlich verwendet.
Session Fixation und Rotation
Session Fixation ist ein Angriff, bei dem der Angreifer eine Session-ID erwirkt, die das Opfer anschließend verwendet. Der Angreifer wartet dann, bis sich das Opfer authentifiziert, und nutzt die nun privilegierte Session aus. Die klassische Methode ist ein manipulierter Link wie ?sid=attacker-known-id auf einer Seite, die eine vom Client bereitgestellte Session-ID akzeptiert.
Die Verteidigung ist einfach und zwingend erforderlich: Regenerieren Sie die Session-ID jedes Mal, wenn sich die Berechtigungsstufe ändert. Das bedeutet beim Login, nach einem Passwort-Reset, beim Wechsel in eine Admin-Ansicht und nach jeder Step-up-Authentifizierung. Die ID vor dem Login wird verworfen, wodurch die Kopie des Angreifers wertlos wird.
req.session.regenerate((err) => {
if (err) return next(err);
// A brand new id now identifies this session.
req.session.userId = user.id;
res.json({ ok: true });
});
Die Rotation bietet einen zweiten Vorteil: Sie verhindert die Wiederverwendung von Session-IDs über einen längeren Zeitraum. Eine ID, die über Monate hinweg gültig ist, ist ein weitaus attraktiveres Ziel als eine, die bei jedem Login gewechselt wird. Einige Frameworks führen die Rotation zudem über einen Timer durch, was das Zeitfenster einschränkt, in dem eine geleakte ID nutzbar ist.
Akzeptieren Sie niemals eine Session-ID aus einem Query-String, einem Request-Body oder einem benutzerdefinierten Header. Die einzige Quelle sollte das Cookie sein, das der Server selbst gesetzt hat – und erst nachdem validiert wurde, dass die ID im Store existiert.
Ablauf- und Idle-Timeouts
Sessions sollten nicht ewig bestehen bleiben. Dabei sind zwei Zeitintervalle entscheidend, und gute Systeme setzen beide ein.
Ein Idle-Timeout lässt eine Session nach einer Zeit der Inaktivität ablaufen – bei sensiblen Anwendungen typischerweise nach 15 bis 60 Minuten. Jede Anfrage, die vor Ablauf des Timeouts eingeht, aktualisiert das Ablaufdatum. So bleibt ein aktiver Nutzer angemeldet, während ein vergessenes Laptop den Zugriff verliert. Dies ist die primäre Verteidigung gegen gestohlene Cookies auf einem Gerät.
Eine absolute Laufzeit begrenzt das maximale Alter einer Session unabhängig von der Aktivität, üblicherweise auf 8 bis 24 Stunden (bei Hochrisiko-Apps kürzer). Sie erzwingt eine periodische Re-Authentifizierung und begrenzt die Zeitspanne, in der eine kompromittierte Session missbraucht werden kann, selbst wenn sie aktiv gehalten wird.
Speichern Sie das Ablaufdatum zusammen mit der Session und prüfen Sie es bei jedem Abruf, nicht nur im Cookie. Das Max-Age eines Cookies ist lediglich ein clientseitiger Hinweis, den ein Angreifer ignorieren kann; das serverseitige Ablaufdatum ist die eigentliche Regel. Löschen Sie beim Logout den Datensatz vollständig, anstatt ihn nur als abgelaufen zu markieren, damit der Store nicht mit toten Einträgen überläuft.
const session = await store.get(id);
if (!session || session.expiresAt < Date.now()) {
await store.delete(id);
return next(); // anonymous
}
CSRF: der Preis der Cookie-Authentifizierung
Da Browser Cookies automatisch anhängen, kann eine Seite einer anderen Origin Ihren Browser dazu veranlassen, eine authentifizierte Anfrage ohne Ihr Wissen zu senden. Das ist Cross-Site Request Forgery: Eine bösartige Seite sendet ein Formular an https://bank.example/transfer und Ihr Session-Cookie wird mitgeschickt, wodurch die Anfrage legitim erscheint.
SameSite ist die erste Verteidigungslinie. SameSite=Lax verhindert, dass Cookies bei Cross-Site-POST-Anfragen gesendet werden, was den klassischen Angriff blockiert. Aber SameSite allein ist keine vollständige Strategie: Ältere Browser, bestimmte Subdomain-Konfigurationen und legitime Cross-Site-Flows benötigen weiterhin Schutz auf Anwendungsebene.
Zwei Patterns decken den Rest ab:
- Synchronizer Token. Der Server speichert ein zufälliges Token in der Session und rendert es in Formulare oder stellt es in einem Response-Header bereit. Unsichere Anfragen müssen das Token zurückgeben, und der Server vergleicht diese in Constant Time. Da die Seite eines Angreifers das Token nicht lesen kann, kann sie keine gültige Anfrage fälschen.
- Double-Submit Cookie. Der Server setzt einen Zufallswert in einem Non-HttpOnly-Cookie und der Client sendet denselben Wert in einem Header. Der Server prüft, ob die beiden übereinstimmen. Dies ist zustandslos und einfach zu implementieren, hängt jedoch davon ab, dass der Angreifer keine Cookies für Ihre Domain setzen kann – kombinieren Sie es daher mit dem
__Host--Präfix.
const sent = req.get("x-csrf-token") ?? "";
const expected = req.session.csrfToken;
const ok =
sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return res.status(403).json({ error: "csrf_failed" });
Wenden Sie den CSRF-Schutz auf jede zustandsändernde Methode an — POST, PUT, PATCH, DELETE — und befreien Sie GET, HEAD und OPTIONS davon, da diese niemals den Zustand ändern dürfen. Prüfen Sie bei unsicheren Anfragen zudem den Origin-Header als einfaches zusätzliches Signal.
Signierte gegenüber verschlüsselten Cookies
Ein Cookie kann selbst Daten transportieren und nicht nur eine ID, sofern man es absichert. Hierfür gibt es zwei gängige Mechanismen, die unterschiedliche Probleme lösen.
Ein signiertes Cookie fügt einen keyed message authentication code hinzu, sodass der Server Manipulationen erkennen kann. Ein Benutzer kann role=member nicht in role=admin ändern, da die Signatur dann nicht mehr übereinstimmt. Eine Signierung verbirgt jedoch nichts: Die Payload ist base64-kodiert und für jeden, der das Cookie besitzt, lesbar. Signierung bietet Integrität, aber keine Vertraulichkeit.
Ein verschlüsseltes Cookie (oft als „sealed cookie“ bezeichnet) verbirgt die Payload und authentifiziert sie gleichzeitig. Der Server kann so eine kleine Menge an Session-Daten direkt im Cookie speichern, wodurch der Lookup im Store entfällt, während die Daten für den Client undurchsichtig bleiben. Frameworks wie cookie-session und die verschlüsselten Cookies von Rails verfolgen diesen Ansatz.
Der Trade-off ist derselbe wie bei JWTs: Ein eigenständiges Cookie lässt sich schwerer widerrufen und vergrößert bei jedem Aufruf die Request-Größe. Ein sinnvoller Mittelweg ist ein Hybrid-Ansatz: Nutzen Sie eine serverseitige Session für Identität und Berechtigungen und ein kurzlebiges signiertes Cookie für kleine, unkritische Flags. Egal wofür Sie sich entscheiden: Speichern Sie niemals Secrets, Rollen, die Sie nicht erneut prüfen, oder große Objekte in einem Cookie.
Sessions über Server hinweg skalieren
Sobald der Store gemeinsam genutzt wird, bleiben Performance und Betrieb die wichtigsten Themen.
Halten Sie Sessions klein. Eine Session sollte Identifikatoren enthalten, keine vollständigen Objekte. Wenn Sie eine Kopie des User-Dokuments speichern, muss jede Profiländerung jede einzelne Session aktualisieren, und veraltete Daten führen zu schwer nachvollziehbaren Bugs. Speichern Sie userId und laden Sie den User bei Bedarf oder cachen Sie diesen kurzzeitig.
Effizient lesen. Ein Redis GET pro Request dauert nur Mikrosekunden, ist aber bei hohem Volumen dennoch ein Netzwerk-Hop. Viele Stacks cachen die Session für die Lebensdauer eines einzelnen Requests, damit mehrere middlewares nicht jeweils den Store abfragen müssen.
Ausfälle des Stores handhaben. Entscheiden Sie, ob ein Redis-Ausfall alle Benutzer ausloggen sollte oder ob geschützte Routen im Sinne eines “Fail Closed”-Prinzips gesperrt werden sollen. Fail Closed ist sicherer: Behandeln Sie einen nicht erreichbaren Store bei authentifizierten Endpunkten als “keine Session” und geben Sie einen klaren Fehler zurück, anstatt stillschweigend Zugriff zu gewähren.
Aufräumen. Setzen Sie eine TTL für Redis-Keys und einen periodischen DELETE FROM sessions WHERE expires_at < now() für Datenbank-Stores. Ohne Bereinigung wächst der Store unbegrenzt an und die Abfragen werden langsamer.
Vermeiden Sie Sticky Sessions, wenn Sie einen Shared Store haben. Sie erhöhen die betriebliche Komplexität ohne Mehrwert und erschweren Zero-Downtime-Deployments. Wenn Sie wirklich keinen gemeinsamen Store nutzen können, sind Sticky Sessions eine Notlösung, kein Design.
Sessions versus JWTs
Dieser Vergleich taucht ständig auf, und die ehrliche Antwort ist, dass beide Ansätze überlappende Probleme mit unterschiedlichen Trade-offs lösen.
| Aspekt | Server-side session | JWT |
|---|---|---|
| Speicherort des Zustands | Server-Store | Innerhalb des Tokens |
| Widerruf (Revocation) | Sofort möglich | Schwierig bis zum Ablauf |
| Lookup pro Request | Erforderlich | Nicht notwendig |
| Sensible Daten im Client | Niemals | Vermeiden; Token ist lesbar |
| Skalierung | Benötigt Shared Store | Per Design zustandslos (stateless) |
| Browser CSRF-Risiko | Ja | Nur bei Cookie-Basis |
| Best Fit | First-Party Web-Apps | APIs, Services, Mobile |
Sessions gewinnen in puncto Kontrolle. Man kann ein einzelnes Gerät sperren, aktive Sessions auflisten, einen globalen Logout erzwingen und Berechtigungen ändern, die bereits beim nächsten Request greifen. JWTs gewinnen durch ihre Zustandslosigkeit: Jeder Service kann ein Token verifizieren, ohne auf einen gemeinsamen Store zugreifen zu müssen. Das ist besonders attraktiv für Microservices und APIs, bei denen Aussteller und Verifizierer unterschiedliche Systeme sind.
Eine gängige und sinnvolle Architektur nutzt beides. Der Browser erhält ein HttpOnly Session-Cookie, das auf eine server-side session verweist. Diese Session kann ein kurzlebiges JWT enthalten, das für Aufrufe interner Services genutzt wird. Für den Nutzer fühlt es sich wie ein normaler Login an, während die internen Aufrufe zustandslos bleiben. Für einen tieferen Einblick in die andere Seite lies den JWT Guide.
Auditierung und Observability
Sessions stellen eine Sicherheitsgrenze dar, und Grenzen müssen beobachtbar sein. Loggen Sie die relevanten Ereignisse, aber niemals Geheimnisse.
Protokollieren Sie erfolgreiche und fehlgeschlagene Logins zusammen mit der User ID, einer groben Quelle wie der IP und dem User Agent sowie einem Zeitstempel. Protokollieren Sie die Erstellung, Rotation und Zerstörung von Sessions. Loggen Sie niemals die Session ID selbst, das Passwort oder das CSRF Token; eine Session ID in einer Log-Datei ist gleichbedeutend mit einem Credential in einer Log-Datei. Wenn Sie eine Korrelation herstellen müssen, loggen Sie stattdessen einen kurzen Hash der ID.
logger.info({
event: "session.created",
userId: user.id,
ip: req.ip,
userAgent: req.get("user-agent"),
});
Geben Sie Nutzern die Möglichkeit, ihre aktiven Sessions einzusehen und zu widerrufen. Eine „Sessions“-Seite, die Geräte mit einem Sign-out-Button auflistet, macht einen vermuteten Kompromiss zu einem Fix mit einem Klick – und es ist ein Feature, das Nutzer zunehmend erwarten. Alarmieren Sie Administratoren bei „Impossible Travel“, einem sprunghaften Anstieg fehlgeschlagener Logins oder einer hohen Anzahl an Sessions, die von einer einzigen IP erstellt wurden.
Best Practices
- Generieren Sie Session-IDs mit einem kryptografisch sicheren Zufallsgenerator, niemals mit einem Zähler oder einem vom Benutzer bereitgestellten Wert.
- Setzen Sie
HttpOnly,Secure,SameSite=Laxund ein explizitesMax-Agefür das Session-Cookie; ziehen Sie das__Host--Präfix in Betracht. - Generieren Sie die Session-ID beim Login und nach jeder Änderung der Berechtigungen neu, um Session Fixation zu verhindern.
- Verwenden Sie in der Produktion einen gemeinsamen Store wie Redis oder eine Datenbank; verlassen Sie sich niemals auf den Prozessspeicher über verschiedene Instanzen hinweg.
- Erzwingen Sie sowohl ein Idle-Timeout als auch eine absolute Lebensdauer auf dem Server, nicht nur im Cookie.
- Löschen Sie den Datensatz beim Logout und bereinigen Sie abgelaufene Sessions in regelmäßigen Abständen.
- Fügen Sie jedem zustandsändernden Route einen CSRF-Schutz hinzu und verifizieren Sie den
Origin-Header. - Halten Sie Session-Payloads klein; speichern Sie IDs und laden Sie den Rest bei Bedarf.
- Implementieren Sie ein „Fail Closed“-Verhalten, wenn der Session-Store bei geschützten Routen nicht erreichbar ist.
- Fordern Sie für sensible Aktionen, wie das Ändern eines Passworts oder das Hinzufügen einer Zahlungsmethode, eine erneute Authentifizierung an.
Häufige Fehler
- Speichern von Sessions im Prozessspeicher bei gleichzeitigem Betrieb von mehr als einer Instanz.
- Vergessen von
HttpOnly, wodurch die Session-ID für XSS anfällig wird. - Verwendung von
SameSite=NoneohneSecure, sodass Browser das Cookie stillschweigend verwerfen. - Wiederverwendung der Session-ID nach dem Login anstatt einer Rotation.
- Akzeptieren einer Session-ID aus einem Query-Parameter oder dem Request-Body.
- Speichern von Rollen und Berechtigungen im Cookie und blindes Vertrauen ohne erneute Prüfung.
- Setzen eines Cookies
Max-Age, ohne den entsprechenden serverseitigen Datensatz jemals ablaufen zu lassen. - Rückgabe eines 200-Status mit einem Error-Body bei einer nicht authentifizierten Anfrage anstatt eines 401-Status.
- Verzicht auf CSRF-Schutz mit der Begründung, dass “SameSite das bereits erledigt”.
- Unkontrolliertes Wachstum des Session-Stores durch fehlende TTL oder fehlende Cleanup-Jobs.
Wie geht es weiter?
Die Session-Authentifizierung vermittelt die Grundlagen, auf denen jedes andere Authentifizierungsschema aufbaut: ein Credential, ein Lookup, ein Ablaufdatum und eine Möglichkeit zum Widerruf. Der natürliche Vergleich dazu ist JWT, bei dem die Claims beim Client mitgeführt werden und der Widerruf die eigentliche Herausforderung darstellt. Wenn Sie Third-Party-Logins oder delegierten Zugriff benötigen, ist OAuth 2.0 der Standard. Um das Cookie selbst zu verstehen – Set-Cookie, SameSite und die Header-Regeln – lesen Sie den HTTP-Guide. Und um die middleware in einen echten Server einzubinden, behandelt der Node.js-Guide die zugrunde liegende Runtime.