Implementierung von Zugriffs- und Erneuerungstokens zusammen in Node.js
Kurzlebige Zugriffstoken zusammen mit rotierenden Erneuerungstoken in Node.js: Der JWT-Fluss, wo jedes Token gespeichert werden sollte, und wie die Rotation gestohlene Erneuerungstoken aufdeckt.
Quellen: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both
Wenn Sie Ihren ersten Authentifizierungsfluss in Node.js erstellen, besteht der übliche Ansatz darin, sich auf ein einzelnes JSON Web Token (JWT) zu verlassen. Der Benutzer gibt E-Mail-Adresse und Passwort ein, der Server überprüft diese, signiert ein Token und gibt es zurück. Ab diesem Zeitpunkt enthält die Frontend-Anwendung dieses Token in dem Authorization-Header bei jeder Anfrage.
Diese Konfiguration funktioniert gut, solange Sie auf localhost testen. Sobald jedoch echte Benutzer die Anwendung nutzen, tritt ein strukturelles Problem auf.
Setzen Sie die Gültigkeitsdauer des Tokens auf einen kurzen Zeitraum, wie zum Beispiel 15 Minuten – dann werden Benutzer mitten in einer Aufgabe ausgeloggt, vielleicht während sie einen Kommentar tippen oder mitten in einem Formular sind. Setzen Sie die Gültigkeitsdauer hingegen auf einen langen Zeitraum, wie 30 Tage – dann bleiben die Benutzer eingeloggt, doch es entsteht gleichzeitig eine ernsthafte Sicherheitslücke: Wenn das Token gestohlen wird, gibt es keine Möglichkeit, es zu widerrufen, ohne alle Tokens aller Benutzer im gesamten System ungültig zu machen.
Dieser Spannungsverhältnis zwischen Bequemlichkeit für die Benutzer und Sicherheit ist genau der Grund, warum produktionstaugliche Systeme nicht auf ein einzelnes Token angewiesen sind – sie verwenden zwei: ein Zugriffstoken und ein Auffrischungstoken.
Lassen Sie uns untersuchen, warum dieses Zwei-Token-Modell existiert, welche Rolle jedes Token spielt und wie man es korrekt in einem Node.js-Backend implementiert.
Das zentrale Problem der Authentifizierung mit einem einzigen Token
Bevor erklärt wird, warum zwei Tokens notwendig sind, hilft es, zunächst noch einmal zu betrachten, was JWTs ursprünglich einzigartig macht: sie sind stateless.
Wenn Ihr Node.js-Server ein JWT erhält, muss er nicht auf eine Datenbank zugreifen, um zu überprüfen, ob eine Sitzung existiert. Er prüft einfach die kryptografische Signatur des Tokens gegen einen geheimen Schlüssel:
Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No -> 401 Unauthorized
Die Statelessness ist es, die JWT-basierte APIs schnell und leicht skalierbar macht, da bei jeder eingehenden Anfrage nicht die Datenbank abgefragt werden muss. Doch diese Eigenschaft hat auch einen Nachteil: sobald ein Token ausgestellt wurde, hat der Server keine einfache Möglichkeit, es frühzeitig zu ungültig zu machen.
Betrachten Sie diese Abfolge von Ereignissen:
- Ihr einziges JWT ist so konfiguriert, dass es 30 Tage gültig ist.
- Ein Angreifer kommt daran – vielleicht durch einen XSS-Angriff oder indem er den Verkehr in einem unsicheren Netzwerk abfängt.
Eine Lösungsmöglichkeit besteht darin, eine Schwarzliste mit widerrufenen Tokenn in einem System wie Redis zu pflegen, doch die Überprüfung dieser Liste bei jeder API-Aufruf macht das ursprünglich „stateless“ konzipierte Token-System wieder zu einem stateful System.
Genau dieses Problem soll der Zwei-Token-Ansatz beheben.
Was sind Zugriffs-Token und Erneuerungs-Token?
Anstatt ein einziges Token für den regulären API-Zugriff sowie die langfristige Anmeldepersistenz verantwortlich zu machen, wird die Arbeit auf zwei Token aufgeteilt:
+------------------+-----------------------------+-----------------------------+
| Feature | Access Token | Refresh Token |
+------------------+-----------------------------+-----------------------------+
| Purpose | Authenticate API requests | Obtain new access tokens |
| Lifetime | Short (5 to 15 minutes) | Long (7 to 30 days) |
| Storage | Memory or short-lived state | HttpOnly, Secure Cookie |
| Database lookup | No (purely stateless) | Yes (checked on refresh) |
| Exposure risk | High (sent on every request)| Low (sent only to /refresh) |
+------------------+-----------------------------+-----------------------------+
1. Das Zugriffs-Token
Betrachten Sie den Zugriffstoken als einen kurzfristig gültigen Pass. Der Client fügt ihn den Headern normaler API-Anfragen hinzu – wie z. B. GET /api/orders oder POST /api/profile.
Da er nur für einen kurzen Zeitraum (in der Regel 10 bis 15 Minuten) gültig ist, sind mögliche Schäden durch einen Datenleck begrenzt. Der Server kann ihn ausschließlich durch signaturbasierte Überprüfungen unter Verwendung der CPU validieren, ohne die Datenbank anzufassen, wodurch die API weiterhin reaktiv bleibt.
2. Der Erneuerungstoken
Der Erneuerungstoken funktioniert wie ein langfristig gültiger Erneuerungscode. Er wird niemals zusammen mit regulären Geschäfts-API-Aufrufen gesendet – seine einzige Aufgabe besteht darin, mit einem speziellen Endpunkt zu kommunizieren, wie z. B. POST /api/auth/refresh.
Sobald das kurzlebige Zugriffstoken abläuft, ruft der Client still diese Aktualisierungsroute auf. Der Server prüft anschließend die Datenbank, um zu bestätigen, dass das Konto des Benutzers weiterhin aktiv und autorisiert ist, und gibt anschließend ein neues Zugriffstoken aus.
Weil dieser Aktualisierungsaufruf nur etwa alle 15 Minuten stattfindet und nicht bei jeder Benutzerinteraktion, hat das Hinzufügen einer Datenbankabfrage kaum Auswirkungen auf die Leistung.
Der Lebenszyklus der Anfrage
So interagieren die drei Parteien – Client, Server und Datenbank – im Laufe der Zeit:
Client Server Database
│ │ │
├─────── 1. Login (credentials) ──>│ │
│ ├─── Verify credentials ─────────>│
│ │<── User record valid ───────────┤
│ ├─── Store Refresh Token Hash ───>│
│<────── 2. Return Tokens ─────────┤ │
│ (Access: 15m, Refresh: 7d) │ │
│ │ │
│─────── 3. GET /api/dashboard ───>│ │
│ [Bearer Access Token] ├─── Verify signature (Stateless) │
│<────── 4. 200 OK + Data ─────────┤ │
│ │ │
│ ... 15 minutes pass ... │ │
│ │ │
│─────── 5. GET /api/dashboard ───>│ │
│ [Expired Access Token] ├─── Token expired! │
│<────── 6. 401 Unauthorized ──────┤ │
│ │ │
│─────── 7. POST /api/auth/refresh>│ │
│ [Cookie: Refresh Token] ├─── Check if token in DB ───────>│
│ │<── Token valid & not revoked ───┤
│ ├─── Generate new Access Token │
│<────── 8. Return new Access Token┤ │
│ │ │
│─────── 9. Retry GET /dashboard ─>│ │
│ [New Access Token] ├─── Verify signature │
│<────── 10. 200 OK + Data ────────┤ │
Vom Standpunkt des Benutzers aus ist kein Teil dieses Erneuerungsprozesses sichtbar. Seine Sitzung funktioniert einfach im Hintergrund weiter, ohne dass störende „Session Expired“-Meldungen die aktive Nutzung unterbrechen.
In der Praxis mit Node.js umsetzen
Lassen Sie uns nun eine funktionierende Express-Einrichtung erstellen, die zeigt, wie Tokens ausgestellt, überprüft und erneuert werden.
Fangen Sie damit an, die erforderlichen Pakete zu installieren:
npm install express jsonwebtoken cookie-parser dotenv
1. Erstellen der Tokens
Erstellen Sie ein Hilfsmodul, das beide Token erzeugt, wobei jedes mit seinem eigenen Geheimnis signiert wird:
// tokenService.js
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
function generateAccessToken(userId) {
// Short lifespan: 15 minutes
return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
}
function generateRefreshToken(userId) {
// Long lifespan: 7 days
return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}
module.exports = {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
};
Wichtig: Geben Sie dem Zugriffstoken und dem Erneuerungstoken stets getrennte Geheimnisschlüssel. Wenn sie denselben Schlüssel teilen, könnte jemand ein abgelaufenes Erneuerungstoken verwenden und es dort einreichen, wo eigentlich ein Zugriffstoken erwartet wird, wodurch die von Ihnen beabsichtigte Ablaufzeit umgangen wird.
2. Verarbeitung des Anmeldens und Speicherung des Erneuerungstokens
Die Anmelde-Route muss das Erneuerungstoken – oder besser noch seinen SHA-256-Hash – in Ihrer Datenbank speichern, verbunden mit diesem Benutzer, damit Sie es später abrufen können:
// server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
} = require('./tokenService');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Simulated database table for refresh tokens
const refreshTokenDatabase = new Set();
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// In a real app: verify email and hashed password from DB
const mockUserId = 'usr_987654';
const accessToken = generateAccessToken(mockUserId);
const refreshToken = generateRefreshToken(mockUserId);
// Save the refresh token to our database so we can revoke it later
refreshTokenDatabase.add(refreshToken);
// Send refresh token inside an HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Prevents JavaScript from reading the cookie (protects from XSS)
secure: process.env.NODE_ENV === 'production', // Requires HTTPS
sameSite: 'strict', // Protects against CSRF attacks
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Send access token in JSON body for frontend memory storage
res.json({ accessToken });
});
3. Schutz der Geschäftsendpunkte
Eine Middleware-Funktion überprüft das Zugriffstoken, das aus dem Authorization-Header abgerufen wird, bevor sie eine Anfrage durchlässt:
function authenticateAccessToken(req, res, next) {
const authHeader = req.headers['authorization'];
// The header format is "Bearer <token>"
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token missing' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) {
// 403 signals to the client that the token is invalid or expired
return res.status(403).json({ error: 'Invalid or expired access token' });
}
req.userId = decoded.sub;
next();
});
}
// Protected route
app.get('/api/orders', authenticateAccessToken, (req, res) => {
res.json({
message: `Orders retrieved for user ${req.userId}`,
data: [{ id: 1, item: 'Mechanical Keyboard' }]
});
});
4. Erstellung der Refresh-Route
Sobald der Client eine 403-Antwort bei einer Anfrage wie /api/orders erhält, wendet er sich an diesen Endpunkt, um ein neues Zugriffstoken zu erhalten:
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
// Check if token exists in the database
if (!refreshTokenDatabase.has(refreshToken)) {
return res.status(403).json({ error: 'Refresh token revoked or unknown' });
}
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
// Remove invalid token from database
refreshTokenDatabase.delete(refreshToken);
return res.status(403).json({ error: 'Refresh token expired' });
}
// Token is valid! Issue a new access token
const newAccessToken = generateAccessToken(decoded.sub);
res.json({ accessToken: newAccessToken });
});
});
5. Abmelden und Entzug des Zugriffs
Das Abmelden eines Benutzers ist einfach: Entfernen Sie den Refresh-Token aus Ihrer Datenbank und löschen Sie die Cookie, das ihn enthält. Der Access-Token erfordert keine besondere Behandlung – er läuft einfach nach einigen Minuten von selbst ab.
app.post('/api/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
refreshTokenDatabase.delete(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
Wo sollten Tokens auf der Client-Seite gespeichert werden?
Eine Frage, die ständig aufkommt, ist, wo der Frontend diese Tokens speichern sollte.
Ein häufiger Fehler besteht darin, beide Token in localStorage zu speichern.
Das Problem ist, dass jedes Skript, das auf Ihrer Seite ausgeführt wird – egal ob es von einer kompromittierten npm-Abhängigkeit oder einem eingefügten Inline-Tag stammt – localStorage mit nur localStorage.getItem('token') lesen kann.
Ein sichereres Muster sieht so aus:
- Bewahren Sie den Zugriffstoken nur im Speicher auf. Speichern Sie ihn im Laufzeitzustand Ihrer Anwendung – in React Context, einem Pinia-Store oder sogar in einer einfachen Variablen. Da er schnell abläuft und im Moment des Schließens der Registerkarte verschwindet, stellt selbst eine erfolgreiche Skriptinjektion nur ein kurzes Zeitfenster des Risikos dar.
- Bewahren Sie den Erneuerungstoken in einem
HttpOnly-Cookie auf. JavaScript hat überhaupt keinen Zugriff aufHttpOnly-Cookies. Der Browser fügt ihn automatisch hinzu, sobald der Client/api/auth/refreshaufruft, was bedeutet, dass Ihre lang lebenden Zugangsdaten niemals Code berühren, den ein Angreifer kontrollieren könnte.
Sicherheit durch Erneuerungstokenrotation stärken
Auch bei der oben beschriebenen Konfiguration weist ein einfaches Refresh-Token-Verfahren eine anhaltende Schwäche auf: Wenn es einem Angreifer gelingt, das Refresh-Token zu erlangen – sei es aus dem Cookie-Speicher oder durch das Abfangen von Netzwerkverkehr – kann er solange neue Zugriffstoken erstellen, wie das Refresh-Token gültig ist, potenziell über die gesamte 7-tägige Frist hinweg.
Produktionsysteme schließen diese Lücke durch Refresh-Token-Rotation.
Beim Rotieren:
- Jedes Mal, wenn ein Refresh-Token für ein neues Zugriffstoken eingelöst wird, tötet der Server das alte Refresh-Token ab und gibt ein völlig neues aus.
- Sowohl die Zugriffstoken als auch die Refresh-Tokens werden bei jeder Anfrage gemeinsam ausgetauscht.
Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
Automatische Erkennung von Sicherheitsverletzungen
Durch die Rotation der Refresh-Tokens wird ein mächtiger Schutzmechanismus namens Wiederverwendungserkennung aktiviert.
Stellen Sie sich eine Situation vor, in der es einem Angreifer gelingt, Refresh Token A zu kopieren.
- Der echte Benutzer einlöst zunächst
Refresh Token A, und der Server antwortet mitRefresh Token B. - Eine Weile später versucht der Angreifer, das nun abgelaufene
Refresh Token Aeinzulösen. - Der Server prüft seine Aufzeichnungen, stellt fest, dass
Refresh Token Abereits verwendet wurde, und markiert dies als Anzeichen für eine Sicherheitsverletzung. - Daraufhin entzieht er die Gültigkeit der gesamten Token-Kette – einschließlich
Refresh Token B– wodurch sowohl der Angreifer als auch der legitime Benutzer zur Anmeldeseite zurückgezwungen werden.
Anstatt dass ein Angreifer im Hintergrund weiterhin unbemerkt vorgeht, reagiert das System sofort und sperrt alle aus, bis eine erneute Authentifizierung stattfindet.
Fehler, die man vermeiden sollte
Achten Sie auf diese vier häufigen Fehler:
- Einschließen sensibler Informationen im Zugriffstoken. Der Payload eines JWTs wird nur Base64-kodiert, nicht verschlüsselt, sodass jeder, der ihn abfängt, den Inhalt direkt lesen kann. Halten Sie Passwörter, Kartennummern und andere private Daten vollständig außerhalb des Payloads.
- Zulassen, dass sich die Einträge der Erneuerungstoken endlos ansammeln. Ohne ein Ablaufdatum-Feld und eine Reinigungsroutine wird Ihre Tabelle der Erneuerungstoken unbegrenzt wachsen. Fügen Sie ein
expires_at-Feld hinzu und planen Sie einen Job ein, der regelmäßig abgelaufene Einträge löscht.
403. Wenn die Frontend-Anwendung daraufhin mehrere parallele /refresh-Aufrufe sendet, interpretiert die Rotationlogik dies als Wiederverwendung und beendet die Sitzung. Schützen Sie sich davor, indem Sie Ausgangsanfragen hinter einen einzigen laufenden Refresh-Aufruf in eine Warteschlange einreihen.Situationen, in denen ein Token ausreichen könnte
Der Ansatz mit zwei Tokens ist die Standardwahl für moderne Web- und Mobilclienten, ist aber nicht überall zwingend erforderlich:
- Interne Service-zu-Service-Kommunikation: Maschinen, die miteinander kommunizieren, verlassen sich in der Regel auf Client-Zertifizierungsflüsse, kurzlebige Tokens oder Mutual TLS – wofür kein interaktiver Refresh-Zyklus notwendig ist.
- Einfache serverseitig generierte Anwendungen: Bei einer traditionellen, serverseitig generierten Anwendung – beispielsweise einer mit klassischen Express-Vorlagen erstellten – ist es in der Regel einfacher, eine einfache Session-Cookie mit Unterstützung eines In-Memory-Speichers wie Redis zu verwalten, und die Sicherheit ist vergleichbar.
Wählen Sie das Access-and-Refresh-Token-Muster insbesondere dann, wenn Sie Single-Page-Anwendungen (React, Vue oder mobile Clients) entwickeln, die mit stateless REST- oder GraphQL-Backends kommunizieren.
Zusammenfassung
Die Verwendung eines einzigen JWT für alles erzwingt einen Kompromiss, den man eigentlich vermeiden sollte: Entweder man hält die Sessions kurz und stört die Benutzer mit häufigen Anmeldungen, oder man hält sie lang und setzt sich ernsthaften Sicherheitsrisiken aus.
Durch die Aufteilung der Authentifizierung in zwei Token wird dieses Dilemma gelöst:
- Zugriffstoken sorgen dafür, dass jede Anfrage schnell und zustandslos abgewickelt wird, wodurch Ihre Datenbank vor ständigen Abfragen geschützt bleibt.
- Aktualisierungs-Token gewährleisten eine sichere Langzeitspeicherung von Sitzungen und ermöglichen es Ihnen, kompromittierte Sitzungen sofort zu widerrufen.
Durch die Anstrengung, beide Token korrekt einzurichten, erhalten Sie eine reibungslose Benutzererfahrung sowie ein System, das den hier beschriebenen Sicherheitsproblemen gut standhält.