Erstellung einer für die Produktion bereiten JWT-Autentifizierung in Node.js-APIs
Wie man Passwörter hasht, kurzlebige JWTs ausstellt, Refresh-Tokens hinzufügt sowie deren Revokation ermöglicht, Autorisierung von Berechtigungen trennt, Angriffe durch Brute Force abwehrt und überprüft, ob die Authentifizierung ordnungsgemäß fehlschlägt.
Quellen: JWT Authentication in Node.js: What I Learned Building Secure APIs
In einem Anforderungsdokument lässt sich die Authentifizierung in einer Zeile beschreiben: Ein Benutzer meldet sich mit E-Mail und Passwort an, und die API gewährt Zugriff. Sobald man sie umsetzt, verwandelt sich diese Zeile in Dutzende von Fragen. Wo werden Passwörter gespeichert und wie werden sie überprüft? Wie erkennt die API bei jeder Anfrage den Aufrufer? Was passiert, wenn ein Token abläuft, wie funktionieren Refresh-Tokens, und was sollte das System nach einer Reihe fehlgeschlagener Anmeldungen tun? Im Kern steht die wichtigste Frage: Wie verhindert man, dass das Anmeldeverfahren selbst zum schwächsten Punkt der Anwendung wird?
Dieser Leitfaden beantwortet diese Fragen im Zusammenhang mit einer Node.js- und TypeScript-API, die von PostgreSQL unterstützt wird, wobei die Authentifizierung nicht als einzelner Endpunkt, sondern als Sicherheitsschicht betrachtet wird, die die gesamte Anwendung umhüllt. Am Ende haben Sie ein klares Modell jeder Schicht, die damit verbundenen Fallstricke sowie eine Checkliste, um zu entscheiden, wann das System tatsächlich für die Produktion bereit ist.
Speichern Sie Hashes von Passwörtern, niemals die Passwörter selbst
Die erste Regel lautet, dass ein Passwort keine gewöhnlichen Daten sind. Es darf auf keinen Fall als reiner Text gespeichert werden. Ein Benutzerdatensatz wie der untenstehende ist inakzeptabel, denn ein einziger Datenbankleck würde das Passwort jedes Benutzers preisgeben, und da Menschen ihre Passwörter wieder verwenden, würden dadurch auch ihre Konten an anderen Orten gefährdet.
email: sachin@example.com
password: MyPassword123
Stattdessen sollte jedes Passwort vor der Speicherung durch einen absichtlich langsamen Algorithmus zur Hashierung von Passwörtern gejagt werden. Mit dem bcrypt-Paket importiert man die Bibliothek:
import bcrypt from "bcrypt";
und hashiert das Passwort während der Registrierung. Der zweite Parameter, 12, ist der Kostenfaktor: Jeder Anstieg verdoppelt die erforderliche Rechenleistung, wodurch das Abgreifen von Passwörtern offline gegen eine gestohlene Hash-Tabelle deutlich aufwendiger wird, während die Geschwindigkeit für einen einzelnen Anmeldevorgang ausreicht.
const passwordHash = await bcrypt.hash(password, 12);
Beim Anmelden wird nichts entschlüsselt. Der Server nimmt das von dem Benutzer gerade eingegebene Passwort und fragt bcrypt, ob es mit dem gespeicherten Hash übereinstimmt. bcrypt.compare liest das im Hash enthaltene Salz sowie den Kostenfaktor, wiederholt die Berechnung und gibt ein Boolesches Wert zurück.
const isValid = await bcrypt.compare(
password,
user.passwordHash
);
Der entscheidende Unterschied besteht darin, dass Passwörter gehässt und nicht verschlüsselt werden, um sie später wieder abrufen zu können. Sobald die Registrierung abgeschlossen ist, sollte Ihre Anwendung niemals das ursprüngliche Passwort wiederherstellen müssen oder können. Zwei praktische Hinweise: bcrypt berücksichtigt nur die ersten 72 Bytes der Eingabe, wodurch sehr lange Passwörter stillschweigend abgeschnitten werden, und Algorithmen wie Argon2 sind eine gängige Alternative, wenn Sie eine für den Prozessor rechenintensive Lösung wünschen.
Betrachten Sie das Anmelden als Workflow, nicht als Abfrage
Ein typischer Anmelde-Endpunkt folgt einer Abfolge von Schritten, von der Empfangnahme der Anfrage über die Validierung, die Abfrage, den Vergleich bis hin zur Erstellung eines Tokens:
Client
↓
POST /api/auth/login
↓
Validate input
↓
Find user
↓
Compare password
↓
Create authentication tokens
↓
Return authentication response
Wichtig ist, dass jeder Schritt fehlschlagen kann und jedes Versagen einer gezielten Reaktion bedarf. Die E-Mail könnte fehlerhaft sein – es könnte kein Benutzer damit übereinstimmen. Das Passwort könnte falsch sein. Der Account könnte gesperrt sein. Die Datenbank könnte unerreichbar sein. Es handelt sich um einen sicherheitskritischen Workflow, weshalb jeder Pfad angemessen gestaltet, protokolliert und getestet werden sollte.
Was ein JWT ist und was es nicht ist
JSON Web Tokens sind eine gängige Methode, um in einer API eine authentifizierte Identität darzustellen. Nach einem erfolgreichen Anmelden gibt der Server ein signiertes Token aus, das den Benutzer beschreibt. Ein minimales Payload kann lediglich einen Subjekt- und Rollenwert enthalten; sub ist die Standardangabe für die Benutzeridentifikation.
{
"sub": "user-id",
"role": "user"
}
Die Nutzlast ist kodiert, nicht verschlüsselt, sodass jeder, der den Token besitzt, ihn lesen kann. Beschränken Sie sich auf Identifikatoren und grobe Angaben, niemals auf Geheimnisse oder personenbezogene Daten. Bei nachfolgenden Anfragen fügt der Client den Token im Authorization-Header unter Verwendung des Bearer-Verfahrens ein:
Authorization: Bearer <access-token>
Vor der Bereitstellung einer geschützten Ressource überprüft der Backend die Signatur und Gültigkeitsdauer des Tokens. Die gesamte Abfolge sieht wie folgt aus:
Login
↓
Credentials verified
↓
Access token issued
↓
Client sends token
↓
Server verifies token
↓
Protected API is allowed
Die wichtige Lektion ist, dass ein JWT eine Anwendung an sich nicht sicher macht. Die Sicherheit hängt genauso stark davon ab, wie der Token erstellt wird, wo der Client ihn speichert, wie streng er validiert wird (einschließlich Festlegung des zulässigen Signieralgorithmus), wann er abläuft, ob er widerrufen werden kann und wie er über das Netzwerk übertragen wird (immer über HTTPS).
Halten Sie Zugriffstoken kurzlebig
Access-Tokens sollten nicht unbegrenzt gültig sein. Wenn ein monatelang gültiger Token durch eine Protokolldatei oder ein kompromittiertes Gerät durchsickert, kann ein Angreifer ihn monatelang nutzen.
In dem untenstehenden Beispiel wird jsonwebtoken verwendet, um einen Token zu signieren, der die Benutzer-ID und -Rolle zusammen mit einem aus der Umgebung abgerufenen Geheimnis enthält, wobei expiresIn auf fünfzehn Minuten gesetzt wird. Das ! nach process.env.JWT_SECRET ist die Nicht-Null-Aussage von TypeScript; in Produktionscode ist es sicherer, bei Startzeit zu überprüfen, ob die Variable vorhanden ist, anstatt sie an jedem Aufrufort zu behaupten.
const accessToken = jwt.sign(
{
sub: user.id,
role: user.role
},
process.env.JWT_SECRET!,
{
expiresIn: "15m"
}
);
Die richtige Gültigkeitsdauer hängt von Ihren Sicherheitsanforderungen ab, doch das Prinzip gilt überall: Beschränken Sie die Dauer, für die ein Zugangsdatensatz, der auf geschützte Ressourcen zugreift, nutzbar bleibt. Eine kurze Gültigkeitsdauer bedeutet, dass ein gestohlener Token schnell wertlos wird.
Refresh-Tokens lösen das Nutzbarkeitsproblem
Falls Zugriffstoken alle fünfzehn Minuten ablaufen, wären die Benutzer gezwungen, ständig erneut einzuloggen, was niemand erträgt. Refresh-Tokens lösen dieses Problem. Beim Anmelden erhält der Client zwei Zugangsdaten: ein kurz gültiges Zugriffstoken, das bei API-Aufrufen verwendet wird, sowie ein länger gültiges Refresh-Token, dessen einziger Zweck es ist, ein neues Zugriffstoken zu erhalten, wenn das alte abläuft.
┌─────────────────┐
│ Login │
└────────┬────────┘
↓
┌─────────────────┐
│ Access Token │
│ Short Lifetime │
└────────┬────────┘
│
API Requests
│
↓
Token expires
│
↓
┌─────────────────┐
│ Refresh Token │
│ Longer Lifetime │
└────────┬────────┘
↓
New Access Token
Diese Aufteilung ermöglicht es, dass die Zugriffstoken kurz bleiben, während die Sitzungen dennoch kontinuierlich erscheinen. Gleichzeitig entsteht eine neue Verantwortung, da das Erneuerungstoken nun das wertvollere Authentifizierungsmerkmal ist. Es muss sorgfältig gespeichert werden (üblicherweise in einem httpOnly, Secure-Cookie im Web anstelle in einem für JavaScript zugänglichen Speicher), ausschließlich an den Erneuerungspunkt gesendet werden und streng vom Server überprüft werden. Für eine ausführlichere Diskussion zum Design siehe unseren Leitfaden zur Erneuerungstoken-Strategie für Node.js-Authentifizierungssysteme.
Planung zur Aufhebung
Eine Eigenschaft selbstständiger Tokens, die viele Teams überrascht, ist, dass sie weiterhin gültig bleiben, auch wenn sich etwas auf dem Server ändert. Man denke an einen Benutzer, der sich ausloggt, einen Administrator, der ein Konto deaktiviert, ein Refresh-Token, von dem bekannt ist, dass es kompromittiert wurde, oder einen Sicherheitsvorfall, der die Beendigung jeder Sitzung erfordert. Wenn das Design kein Widerrufsmechanismus bietet, ändert das Löschen oder Aktualisieren einer Datenbankzeile nichts an einem bereits ausgestellten Token; es bleibt weiterhin funktionsfähig, bis es abläuft.
Deshalb speichern viele Authentifizierungssysteme Serverseitige Aufzeichnungen von Refresh-Tokens oder Sitzungen – oft eine pro Gerät –, die mit jedem Benutzer verknüpft sind:
User
├── Refresh Token A
├── Refresh Token B
└── Refresh Token C
Sobald diese Regelung vorhanden ist, kann der Server einzelne Token als widerrufen markieren, alle Sessions eines Benutzers beenden und die Erstellung neuer Zugriffstoken aus einem widerrufenen Erneuerungstoken verweigern. Dies zeigt einen ehrlichen Kompromiss auf: JWTs verringern die Menge an Sitzungsinformationen, die der Server speichern muss, doch eine sichere Authentifizierung erfordert in der Regel weiterhin einen gewissen Serverseitigen Zustand. Kurze Lebensdauern der Zugriffstoken in Kombination mit widerrufbaren Erneuerungstokens sind eine gängige Methode, um beides auszugleichen.
Authentifizierung im Middleware durchsetzen
Die wiederholte Überprüfung der Token in jedem Controller ist fehleranfällig; eine vergessene Überprüfung lässt einen Endpunkt ungeschützt. Middleware zentralisiert die Logik. In Express platziert ein geschützter Pfad einfach eine authenticate-Funktion vor seinem Controller:
app.get(
"/api/profile",
authenticate,
profileController
);
Diese Middleware hat eine kleine, klar definierte Aufgabe:
- Den
Authorization-Header lesen.
Der resultierende Ablauf ist leicht nachvollziehbar: Ein ungültiges oder fehlendes Token führt zu einem 401-Fehler, nur ein gültiges Token erreicht den Controller.
Request
↓
Authentication Middleware
↓
Token valid?
↙ ↘
No Yes
↓ ↓
401 Controller
↓
Response
Authentifizierung und Autorisierung sind unterschiedliche Überprüfungen
Diese beiden Begriffe werden oft austauschbar verwendet, beantworten aber unterschiedliche Fragen. Die Authentifizierung fragt nach der Identität des Anrufers, während die Autorisierung klärt, was diesem Anrufer erlaubt ist. Unser Artikel zu dem Platz von Authentifizierung und Autorisierung in Ihrem Code geht dieser Unterscheidung genauer nach.
Stellen Sie sich eine Anwendung mit drei Rollen vor:
Admin
Staff
User
Mitglieder aller drei Rollen können vollständig authentifiziert werden, doch ihre Berechtigungen unterscheiden sich stark. Ein normaler Benutzer darf möglicherweise sein Profil einsehen, Probleme auflisten und neue erstellen:
GET /api/profile
GET /api/issues
POST /api/issues
Ein Administrator wiederum könnte zusätzlich das Recht haben, Benutzerkonten zu ändern und Probleme zu löschen:
PATCH /api/users/:id
DELETE /api/issues/:id
Das Besitzen eines gültigen JWT beweist lediglich die Identität; es gewährt jedoch keine Berechtigung für jede Operation. Ein sicheres Backend benötigt beide Ebenen – in der Regel ein Authentifizierungs-Middleware, gefolgt von Rollen- oder Berechtigungsprüfungen pro Route, und idealerweise auch Eigentumsprüfungen – damit ein Benutzer nur seine eigenen Einträge bearbeiten kann und nicht die eines anderen.
Schutz vor wiederholten fehlgeschlagenen Anmeldungen
Die Architektur der Authentifizierung muss auch Angreifer berücksichtigen, nicht nur legitime Benutzer. Ein automatisiertes Skript kann unbegrenzt Passwortsuchen durchführen, es sei denn, etwas hält es auf. Die einfachste Verteidigungsmethode ist ein Zähler, der nach mehreren aufeinanderfolgenden Fehlversuchen einen vorübergehenden Sperrmechanismus auslöst:
Attempt 1 → Failed
Attempt 2 → Failed
Attempt 3 → Failed
Attempt 4 → Failed
Attempt 5 → Failed
↓
Temporary lock
In der Praxis würden Sie je nach Anforderungen mehrere Maßnahmen kombinieren:
- Rate Limiting für Anfragen an den Anmeldepunkt.
- Vorübergehende Sperrung von Konten.
Wählen Sie sorgfältig, denn eine Abwehrmaßnahme kann zu einem Vektor für Denial-of-Service-Angriffe werden. Wenn bereits jemand ein Konto durch fünf falsche Eingaben per E-Mail sperren kann, können Angreifer echte Benutzer absichtlich ausschließen. Das Ziel besteht nicht nur darin, nach fehlgeschlagenen Versuchen zu blockieren, sondern auch darin, automatisierte Angriffe teuer zu machen, während legitime Benutzer weiterhin Zugang erhalten – beispielsweise durch Kombination von Verzögerungen pro Konto mit Bandbreitenbeschränkungen pro IP-Adresse anstelle von reinen Sperrmaßnahmen.
Vertrauen Sie niemals der vom Client bereitgestellten Identität
Ein gefährliches Muster ist es, Daten einfach deshalb zu glauben, weil die Benutzeroberfläche sie übermittelt hat. Angenommen, der Anfragekörper gibt die Benutzer-ID und Rolle des Aufrufers an:
{
"userId": "123",
"role": "admin"
}
Nichts hindert einen bösartigen Client daran, diese Felder zu ändern. Der Backend darf niemals annehmen, dass sie korrekt sind. Die Identität muss aus von dem Server selbst überprüften Anmeldeinformationen stammen, was bedeutet, dass Benutzer-ID und Rolle aus dem validierten Token (oder aus der Datenbank unter Verwendung des Subjekts des Tokens) abgerufen werden, und die Berechtigungsprüfungen basieren darauf:
Request
↓
Verify JWT
↓
Read authenticated user ID
↓
Check permissions
↓
Access resource
Der Server ist die endgültige Autorität. Dieses Prinzip erstreckt sich weit über die Authentifizierung hinaus: Der Client kann das Erlebnis verbessern, indem er Schaltflächen versteckt, die ein Benutzer nicht verwenden kann, aber man darf sich niemals darauf verlassen, dass er Sicherheitsregeln durchsetzt.
Vermeiden Sie das Leaken von Informationen in Fehlermeldungen
Sicherheit betrifft auch Details wie die Formulierung von Fehlern. Vergleichen Sie eine Antwort, die angibt, dass die E-Mail nicht existiert:
Email does not exist.
mit einer, die absichtlich vage bleibt:
Invalid email or password.
Der erste Punkt informiert Angreifer darüber, welche E-Mails registriert sind, was eine Auflistung der Konten ermöglicht und das gezielte Ausprobieren von Passwörtern erleichtert. Der zweite Punkt gibt einem legitimen Benutzer die Möglichkeit, es erneut zu versuchen, ohne etwas bestätigen zu müssen. Bedenken Sie, dass eine Auflistung der Konten auch durch Reaktionszeiten oder über Endpunkte für Registrierung und Passwort zurücksetzen möglich ist, weshalb dieselbe Vorsicht dort angebracht ist.
Die allgemeinere Regel gilt auch für Datenbankfehler und unverarbeitete Ausnahmen. Clients benötigen Fehler, auf die sie reagieren können; sie brauchen keinen Stacktrace, keine SQL-Nachrichten oder interne Identifikatoren.
Halten Sie Geheimnisse aus dem Quellcode raus
Die Authentifizierung hängt von Geheimnissen ab, wie beispielsweise einer Signierungsdatei, einer Datenbankverbindungszeichenkette und API-Schlüsseln Dritter:
JWT_SECRET
DATABASE_URL
API_KEY
Diese dürfen auf keinen Fall in ein Repository eingefügt werden. Ein so hardcodierter Schlüssel stellt eine Gefahr dar, sobald jemand anderes den Code lesen kann:
const secret = "my-super-secret-key";
Das Auslesen aus der Umgebung ist die minimale Verbesserung:
const secret = process.env.JWT_SECRET;
In der Produktion sollte man noch weiter gehen und Geheimnisse über ein geeignetes Konfigurations- oder Secret-Management-System bereitstellen. Dies ist besonders wichtig für öffentliche Repositorien, wo ein versehentlich eingefügter Schlüssel fast sofort zu einem ernsten Vorfall führen kann; falls das passiert, sollte der Schlüssel ausgetauscht werden anstatt nur der Commit gelöscht zu werden.
Testen, dass die Authentifizierung korrekt fehlschlägt
Manuelles Testen eignet sich schlecht für die Authentifizierung, denn die wichtigen Fälle sind jene, in denen es nicht funktionieren sollte. Ein grundlegender automatisierter Testumfang sollte sowohl erfolgreiche als auch abgelehnte Abläufe in jedem Schritt des Flusses abdecken.
Registrierung
Gültige Registrierungen sind erfolgreich, während fehlerhafte E-Mails, ungültige Eingaben und doppelte Konten abgelehnt werden:
Valid registration → Success
Invalid email → Rejected
Weak/invalid input → Rejected
Duplicate account → Rejected
Anmelden
Korrekte Anmeldeinformationen führen zum Erfolg; falsche Passwörter, unbekannte Konten und gesperrte Konten werden abgelehnt:
Correct password → Success
Wrong password → Rejected
Unknown account → Rejected
Locked account → Rejected
Schutzbedürftige Routen
Fehlende, ungültige und abgelaufene Token erzeugen jeweils 401; ein gültiges Token wird durchgelassen, während ein gültiges Token ohne die erforderliche Rolle 403 auslöst. Beachten Sie den Unterschied: 401 bedeutet „Wir wissen nicht, wer Sie sind“, während 403 bedeutet „Wir wissen, wer Sie sind – und die Antwort ist nein.“
No token → 401
Invalid token → 401
Expired token → 401
Valid token → Allowed
Valid token + insufficient role → 403
Tests wie diese bestätigen nicht nur, dass die Authentifizierung funktioniert. Sie zeigen außerdem, wie sie auf die richtige Weise versagt – und genau dort verbergen sich die meisten tatsächlichen Schwachstellen.
Ein schichtweises Modell der Authentifizierung
Es hilft, die Authentifizierung als eine Reihe von getrennten Schichten vorzustellen, wobei jede Schicht eine bestimmte Aufgabe hat – von den Anmeldeinformationen über die Passwortüberprüfung, die Ausstellung von Tokenn, deren Validierung und Autorisierung bis hin zum geschützten Ressourcen selbst:
┌─────────────────────────────┐
│ User Credentials │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Password Verification │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Issuance │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Token Validation │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Authorization │
└──────────────┬──────────────┘
↓
┌─────────────────────────────┐
│ Protected Resource │
└─────────────────────────────┘
Um die gesamte Struktur herum befindet sich eine durchgängige Schicht: Überwachung, Protokollierung, Rate Limiting, sichere Konfiguration und Testing.
Checkliste für die Produktreife
Bevor man ein Authentifizierungssystem für fertig hält, sollte man sicherstellen, dass:
- Passwörter mit einem starken, langsamen Algorithmus gehässelt werden.
- Zugriffstoken eine angemessene, kurze Gültigkeitsdauer haben.
- Auffrischungstoken sicher gespeichert, übertragen und validiert werden.
- Jeder geschützte Pfad die Authentifizierung überprüft.
- Die Autorisierung als separates Schritt von der Authentifizierung durchgeführt wird.
Diese Liste ist ein weitaus besseres Kriterium als die Frage, ob die Anmeldung funktioniert.
Haupterkenntnisse
Die Authentifizierung ist nicht etwas, das am Ende eines Projekts hinzugefügt werden kann. Sie prägt die Datenbankgestaltung, die API-Struktur, das Middleware, das Verhalten der Benutzeroberfläche, die Fehlerbehandlung, das Logging, die Tests sowie den Deployment-Prozess – und vor allem die Sicherheit. Ein Anmeldepunkt benötigt nur wenige Zeilen Code; ein System, das auch dann sicher funktioniert, wenn echte Benutzer, Angreifer, abgelaufene Token, fehlerhafte Anfragen sowie Produktionsfehler auftreten, stellt ein weitaus komplexeres ingenieurtechnisches Problem dar.
Der nächste logische Schritt ist eine eingehende Betrachtung der Sicherheit von Erneuerungstoken: Wo diese gespeichert werden sollten, ob und wie sie erneuert werden müssen, wie eine Wiederverwendung alter Token erkannt werden kann, wie die Revokation in der Praxis aussieht und wie das Abmelden auf mehreren Geräten funktionieren sollte. Jede dieser Entscheidungen folgt demselben Prinzip wie alles zuvor: Die Sicherheit bleibt gewährleistet, wenn sie Teil jeder Gestaltungsentscheidung ist und nicht als zusätzliche Schicht hinzugefügt wird.