OAuth 2.0 ist das meistgenutzte Autorisierungsprotokoll im Web. Über 90 Prozent aller modernen Web-APIs setzen auf OAuth 2.0 als Grundlage. Wer eine Node.js-Anwendung absichern will, die sich mit Google, GitHub oder einem eigenen Identity-Provider verbindet, kommt an OAuth 2.0 nicht vorbei. Dieses Tutorial zeigt den vollständigen Implementierungsprozess: von der Projektinitialisierung über den Authorization Code Flow mit PKCE bis hin zur Token-Revokation mit Redis. Alle Schritte sind auf Node.js mit Express ausgelegt und lassen sich in unter 45 Minuten umsetzen.
Was ist OAuth 2.0 und warum ist es relevant?
OAuth 2.0 ist ein Autorisierungsframework, das in RFC 6749 spezifiziert ist. Es ermöglicht einer Anwendung, im Auftrag eines Benutzers eingeschränkten Zugriff auf eine andere Anwendung zu erhalten, ohne dass der Benutzer sein Passwort direkt weitergeben muss. Das klassische Beispiel: Eine Webanwendung darf die Google-Kalendereinträge eines Benutzers lesen, aber nicht sein Google-Passwort kennen.
OAuth 2.0 ist kein Authentifizierungsprotokoll, sondern ein Autorisierungsprotokoll. Wer Authentifizierung (Identitätsprüfung) braucht, nutzt OpenID Connect (OIDC), das auf OAuth 2.0 aufbaut. In der Praxis werden beide oft kombiniert. Dieses Tutorial behandelt den OAuth 2.0 Authorization Code Flow, ergänzt durch PKCE (Proof Key for Code Exchange, spezifiziert in RFC 7636), da dieser als sicherster und modernster Flow gilt.
Die vier OAuth 2.0 Flows im Vergleich
| Flow | Einsatzbereich | PKCE nötig | Empfohlen 2026 |
|---|---|---|---|
| Authorization Code + PKCE | Webanwendungen, Native Apps, SPAs | Ja | Ja |
| Client Credentials | Server-zu-Server (kein User) | Nein | Ja |
| Device Code | TV-Apps, CLI-Tools | Nein | Ja |
| Implicit Flow | Alte SPAs (veraltet) | Entfällt | Nein, deprecated |
Der Implicit Flow gilt seit OAuth 2.1 als veraltet und soll nicht mehr eingesetzt werden. Für alle modernen Anwendungen ist der Authorization Code Flow mit PKCE die richtige Wahl, auch für vertrauliche Clients (Server-seitige Apps). Der Resource Owner Password Credentials (ROPC) Flow ist ebenfalls veraltet und wird in OAuth 2.1 entfernt, da er das Passwort des Benutzers an den Client weitergibt, was dem Kernprinzip von OAuth widerspricht.
Voraussetzungen
Bevor die Implementierung beginnt, müssen folgende Voraussetzungen erfüllt sein:
- Node.js 20.x oder neuer (LTS-Version, prüfen mit
node --version) - npm 10.x oder neuer (kommt mit Node.js, prüfen mit
npm --version) - Ein Google Cloud-Konto für OAuth 2.0-Credentials (kostenlos)
- Redis für Token-Revokation in Schritt 11 (optional, aber empfohlen)
- Grundkenntnisse in Express.js und HTTP-Grundlagen
- Ein Terminal mit Bash oder PowerShell
Wer noch keine Erfahrung mit JWT hat, sollte zuerst das Tutorial zu JWT Authentication in Node.js lesen, da dieses Tutorial darauf aufbaut. Grundwissen zu Session-Management vermittelt das Node.js Session Management Tutorial.
Schritt 1: Projektstruktur anlegen
Ein neues Node.js-Projekt wird initialisiert. Die Verzeichnisstruktur folgt einer klaren Trennung von Routen, Middleware und Konfiguration. Saubere Struktur erleichtert spätere Erweiterungen, zum Beispiel wenn weitere OAuth-Provider (GitHub, Microsoft) hinzukommen.
mkdir oauth2-nodejs-demo
cd oauth2-nodejs-demo
npm init -y
# Verzeichnisstruktur erstellen
mkdir -p src/routes src/middleware src/config src/utils
Die Projektstruktur sieht nach diesem Schritt so aus:
oauth2-nodejs-demo/
├── src/
│ ├── config/
│ │ ├── passport.js
│ │ └── redis.js
│ ├── middleware/
│ │ └── auth.js
│ ├── routes/
│ │ ├── auth.js
│ │ └── protected.js
│ ├── utils/
│ │ └── pkce.js
│ └── app.js
├── .env
├── .env.example
├── .gitignore
└── package.json
Wichtig: Die .env-Datei niemals in Git einchecken. Sie enthält Client-Secrets, die bei Verlust sofortige Sicherheitsrisiken erzeugen. Die .gitignore-Datei muss mindestens .env und node_modules/ enthalten. Der folgende Befehl erstellt eine .gitignore-Datei mit sinnvollen Standardwerten:
# .gitignore
node_modules/
.env
*.log
dist/
Schritt 2: Google OAuth 2.0-Anwendung einrichten
Google dient in diesem Tutorial als Identity Provider. Die Einrichtung dauert unter 5 Minuten und ist kostenlos:
- Google Cloud Console aufrufen und ein neues Projekt erstellen (oder ein bestehendes wählen)
- Unter APIs & Services > OAuth-Zustimmungsseite den Anwendungstyp “extern” wählen und App-Name eintragen
- Unter APIs & Services > Zugangsdaten auf Zugangsdaten erstellen > OAuth 2.0-Client-IDs klicken
- Anwendungstyp: Webanwendung auswählen
- Autorisierte JavaScript-Quellen:
http://localhost:3000 - Autorisierte Weiterleitungs-URIs:
http://localhost:3000/auth/google/callback - Auf Erstellen klicken und Client-ID sowie Client-Secret notieren
Die Redirect URI muss exakt übereinstimmen. Google und andere Authorization Server verwenden String-Matching ohne Wildcard-Unterstützung. Ein häufiger Fehler ist ein abschließender Slash (/callback/ statt /callback), der sofort zu einem redirect_uri_mismatch-Fehler führt. Für Produktionsumgebungen wird eine zweite Redirect URI für die echte Domain registriert.
Schritt 3: Dependencies installieren
Das Projekt benötigt mehrere npm-Pakete. Hier eine Übersicht der verwendeten Packages mit Begründung:
| Paket | Funktion | Kategorie |
|---|---|---|
| express | HTTP-Framework für Routing und Middleware | Core |
| passport | Authentifizierungs-Middleware mit Strategy-Pattern | Auth |
| passport-google-oauth20 | Google OAuth 2.0 Strategy für Passport | Auth |
| express-session | Session-Handling als Basis für Passport | Auth |
| cookie-parser | Cookie-Parsing für httpOnly-Token-Cookies | Auth |
| jsonwebtoken | JWT-Erstellung und -Validierung | Token |
| dotenv | Umgebungsvariablen aus .env-Datei laden | Config |
| crypto (built-in) | PKCE code_verifier generieren (kein npm nötig) | Security |
| ioredis | Redis-Client für Token-Revokation und -Blocklist | Storage |
npm install express passport passport-google-oauth20 express-session cookie-parser jsonwebtoken dotenv ioredis
# Development-Dependency für automatisches Neustarten
npm install --save-dev nodemon
Das Paket crypto ist in Node.js built-in und muss nicht separat installiert werden. Es wird für die PKCE-Implementierung in Schritt 8 benötigt. nodemon startet den Server bei Dateiänderungen automatisch neu, was die Entwicklung erheblich beschleunigt. Das package.json-Skript dazu:
// package.json (Ausschnitt)
{
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
}
}
Schritt 4: Umgebungsvariablen konfigurieren
Alle sensiblen Werte kommen in die .env-Datei. Niemals Secrets direkt im Code hinterlegen, da sie sonst in der Versionskontrolle landen und bei einem versehentlichen Public-Repository-Push für alle sichtbar werden.
# .env
GOOGLE_CLIENT_ID=deine-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=dein-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback
SESSION_SECRET=ein-starkes-zufaelliges-geheimnis-min-32-zeichen
JWT_SECRET=ein-anderes-starkes-geheimnis-min-64-zeichen
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
PORT=3000
REDIS_URL=redis://localhost:6379
NODE_ENV=development
Das SESSION_SECRET und JWT_SECRET müssen kryptographisch zufällig sein. Ein geeignetes Secret lässt sich so generieren:
# Secret generieren (in der Terminal ausführen)
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Ausgabe: a3f8c2d1e9b047... (128 Zeichen langer Hex-String)
Die .env.example-Datei enthält dieselbe Struktur ohne echte Werte und kann sicher in Git eingecheckt werden, damit andere Entwickler wissen, welche Variablen gesetzt werden müssen. Die Trennung von SESSION_SECRET und JWT_SECRET ist bewusst: Selbst wenn ein Secret kompromittiert wird, sind nicht beide Token-Typen betroffen.
Schritt 5: Express-App und Middleware-Stack aufbauen
Die Hauptdatei src/app.js verdrahtet alle Komponenten. Express-Session wird als Basis für Passport benötigt. Die Reihenfolge der Middleware ist entscheidend: Session muss vor Passport initialisiert werden, da Passport die Session für die User-Serialisierung nutzt.
// src/app.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const passport = require('passport');
require('./config/passport')(passport);
const authRoutes = require('./routes/auth');
const protectedRoutes = require('./routes/protected');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// Reihenfolge ist kritisch: session() vor passport.initialize()
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 15 * 60 * 1000 // 15 Minuten
}
}));
app.use(passport.initialize());
app.use(passport.session());
app.use('/auth', authRoutes);
app.use('/api', protectedRoutes);
// Globaler Fehlerhandler
app.use((err, req, res, next) => {
console.error(err.message);
res.status(500).json({ error: 'Interner Serverfehler' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
});
module.exports = app;
Die Session-Cookie-Konfiguration ist sicherheitsrelevant: httpOnly: true verhindert den Zugriff durch JavaScript und schützt vor XSS-Angriffen. secure: true in Produktion erzwingt HTTPS und verhindert, dass der Cookie über unverschlüsselte Verbindungen übertragen wird. Die kurze Session-Laufzeit von 15 Minuten deckt sich mit der empfohlenen Access-Token-Laufzeit. Mehr zu CSRF-Schutz in Express zeigt das CSRF Protection in Node.js Tutorial.
Schritt 6: Passport mit Google Strategy konfigurieren
Passport.js ist eine Middleware für Node.js mit über 500 verfügbaren Strategien für verschiedene Anbieter und Protokolle. Die Google-Strategy übernimmt den gesamten OAuth 2.0-Handshake mit Google, inklusive Code-Exchange und Profil-Abruf.
// src/config/passport.js
const GoogleStrategy = require('passport-google-oauth20').Strategy;
module.exports = (passport) => {
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
scope: ['profile', 'email'],
state: true // Aktiviert automatischen CSRF-Schutz via state-Parameter
},
async (accessToken, refreshToken, profile, done) => {
try {
// Benutzerprofile aus Google extrahieren
const user = {
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0]?.value
};
// Hier könnte eine Datenbankabfrage erfolgen:
// const dbUser = await User.findOrCreate({ googleId: profile.id }, user);
// return done(null, dbUser);
return done(null, user);
} catch (err) {
return done(err, null);
}
}));
// Benutzer in Session serialisieren (minimale Daten)
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user, done) => {
done(null, user);
});
};
Der Parameter state: true aktiviert den CSRF-Schutz im OAuth-Flow. Passport generiert dabei automatisch einen zufälligen State-Parameter, der in der Session gespeichert und im Callback verifiziert wird. Ohne State-Parameter ist der Flow anfällig für CSRF-Angriffe. Die Scopes profile und email sind auf das Minimum reduziert: Eine App soll nur Zugriff auf das anfordern, was sie wirklich braucht.
Schritt 7: Auth-Routen implementieren
Die Auth-Routen steuern den vollständigen OAuth 2.0-Flow: Login-Initiierung, Callback-Verarbeitung, Token-Ausgabe und Logout. Nach erfolgreichem OAuth-Handshake werden eigene JWTs ausgestellt statt die Google-Tokens direkt weiterzuverwenden.
// src/routes/auth.js
const express = require('express');
const passport = require('passport');
const jwt = require('jsonwebtoken');
const router = express.Router();
// Schritt 1 des OAuth-Flows: Benutzer zu Google weiterleiten
router.get('/google',
passport.authenticate('google', {
scope: ['profile', 'email'],
accessType: 'offline', // Google Refresh Token anfordern
prompt: 'consent' // Refresh Token bei jedem Login erzwingen
})
);
// Schritt 3 des OAuth-Flows: Google sendet Authorization Code zurück
router.get('/google/callback',
passport.authenticate('google', {
failureRedirect: '/auth/failure',
session: false // Keine Session für API-Token-Flow
}),
(req, res) => {
// Eigene JWTs ausstellen (Google-Token nicht direkt weiterverwenden)
const accessToken = jwt.sign(
{
userId: req.user.googleId,
email: req.user.email,
name: req.user.name
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRES || '15m' }
);
const refreshToken = jwt.sign(
{ userId: req.user.googleId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES || '7d' }
);
// Token als httpOnly-Cookie setzen (NICHT in localStorage!)
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60 * 1000 // 15 Minuten in Millisekunden
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth/refresh', // Nur für Refresh-Endpoint zugänglich
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 Tage
});
res.json({
message: 'Authentifizierung erfolgreich',
user: {
email: req.user.email,
name: req.user.name
}
});
}
);
router.get('/failure', (req, res) => {
res.status(401).json({ error: 'Authentifizierung fehlgeschlagen' });
});
router.post('/logout', (req, res) => {
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/auth/refresh' });
res.json({ message: 'Abgemeldet' });
});
module.exports = router;
Tokens werden als httpOnly-Cookies gesetzt, nicht als Authorization-Header im Frontend gespeichert. Das verhindert, dass JavaScript-Code (und damit XSS-Angriffe) die Tokens stehlen kann. Das sameSite: 'lax'-Attribut bietet zusätzlichen CSRF-Schutz. Der Refresh-Token-Cookie hat zusätzlich einen eingeschränkten path, sodass er nur an den /auth/refresh-Endpoint gesendet wird und nicht bei jeder API-Anfrage mitgeschickt wird.
Schritt 8: PKCE implementieren
PKCE (Proof Key for Code Exchange, ausgesprochen “pixie”) ist seit RFC 7636 standardisiert und schützt den Authorization Code Flow vor Code-Injection-Angriffen. Das Prinzip: Der Client generiert vor dem Authorization Request einen zufälligen code_verifier (43-128 Zeichen) und berechnet daraus einen code_challenge (SHA-256-Hash). Die Challenge wird im Authorization Request mitgesendet. Beim Token-Request schickt der Client den originalen Verifier, den der Server gegen die gespeicherte Challenge verifiziert.
Für Custom OAuth-Flows ohne Passport (z. B. bei einem eigenen Authorization Server oder einem Provider der kein Passport-Paket hat) sieht die PKCE-Implementierung in Node.js so aus:
// src/utils/pkce.js
const crypto = require('crypto');
function generateCodeVerifier() {
// RFC 7636: 43-128 Zeichen, URL-safe Base64 (base64url, kein +, /, =)
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(codeVerifier) {
// Nur S256-Methode verwenden, nie 'plain'
return crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
}
function buildAuthorizationUrl(params) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString('hex');
const url = new URL(params.authorizationEndpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scope);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
// codeVerifier und state in Session speichern für spätere Verifizierung
return { url: url.toString(), codeVerifier, state };
}
async function exchangeCodeForToken(params) {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: params.codeVerifier // Hier wird der Verifier mitgesendet
});
const response = await fetch(params.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
return response.json();
}
module.exports = { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, exchangeCodeForToken };
Der code_verifier muss serverseitig zwischen Authorization Request und Token Request sicher gespeichert werden, zum Beispiel in der Session. Der SHA-256-Algorithmus (S256) ist die einzig zulässige Methode nach aktuellem Stand. Der veraltete plain-Modus bietet keinen echten Schutz und darf nicht verwendet werden, da er keinen Schutz vor einem passiven Angreifer bietet, der die PKCE-Parameter mitlesen kann.
Schritt 9: Auth-Middleware und geschützte Routen
Eine Middleware prüft bei jeder Anfrage das JWT-Access-Token aus dem Cookie und hängt die dekodierten User-Daten an das Request-Objekt. Geschützte Routen verwenden diese Middleware als Guard.
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
async function authenticateToken(req, res, next) {
const token = req.cookies?.access_token;
if (!token) {
return res.status(401).json({ error: 'Kein Token vorhanden' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
// Issuer-Validierung verhindert Token-Confusion-Angriffe
// issuer: 'https://deine-app.com' // In Produktion aktivieren
});
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token abgelaufen',
code: 'TOKEN_EXPIRED'
});
}
return res.status(403).json({ error: 'Ungültiges Token' });
}
}
module.exports = { authenticateToken };
// src/routes/protected.js
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
router.get('/profile', authenticateToken, (req, res) => {
res.json({
userId: req.user.userId,
email: req.user.email,
name: req.user.name
});
});
router.get('/dashboard', authenticateToken, (req, res) => {
res.json({
message: `Willkommen, ${req.user.name}`,
tokenAusgestellt: new Date(req.user.iat * 1000).toISOString(),
tokenLaeuftAb: new Date(req.user.exp * 1000).toISOString()
});
});
module.exports = router;
Die Middleware unterscheidet explizit zwischen abgelaufenem Token (TokenExpiredError) und ungültigem Token. Das ermöglicht dem Client, automatisch ein neues Token anzufordern, wenn der Fehlercode TOKEN_EXPIRED zurückkommt, ohne den Benutzer zur erneuten Anmeldung zu zwingen. Ungültige Tokens (falsche Signatur, manipulierter Payload) werden mit 403 Forbidden abgelehnt.
Schritt 10: Refresh-Token-Mechanismus implementieren
Access Tokens mit kurzer Laufzeit (5-15 Minuten) erhöhen die Sicherheit erheblich, da ein kompromittiertes Token nur kurze Zeit nutzbar ist. Refresh Tokens ermöglichen es, neue Access Tokens zu holen, ohne den Benutzer erneut zur Anmeldung zu zwingen. Die Laufzeit von Refresh Tokens liegt typischerweise bei 7-30 Tagen.
// src/routes/auth.js (Erweiterung mit Refresh-Endpoint)
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'Kein Refresh Token vorhanden' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
// Neues Access Token mit voller Laufzeit ausstellen
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRES || '15m' }
);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 15 * 60 * 1000
});
res.json({ message: 'Token erfolgreich erneuert' });
} catch (err) {
// Bei ungültigem Refresh Token beide Cookies löschen (vollständiger Logout)
res.clearCookie('access_token');
res.clearCookie('refresh_token', { path: '/auth/refresh' });
return res.status(401).json({ error: 'Refresh Token ungültig oder abgelaufen' });
}
});
Ein kritischer Aspekt bei der Token-Erneuerung: Race Conditions. Wenn mehrere parallele API-Anfragen gleichzeitig ein abgelaufenes Token erkennen und alle gleichzeitig den Refresh-Endpoint aufrufen, kann das zu Fehlern führen (der zweite Refresh-Request schlägt fehl, wenn Refresh Token Rotation aktiv ist). In Produktionsumgebungen sollte ein “Single-Flight”-Mechanismus im Client implementiert werden: Nur die erste Anfrage führt den Refresh durch, alle anderen warten auf das Ergebnis.
Schritt 11: Token-Revokation mit Redis
JWTs sind zustandslos: Der Server kann ein ausgestelltes Token nicht direkt invalidieren, bevor es abläuft. Das ist ein bekanntes Problem, zum Beispiel nach einem Passwort-Wechsel, nach einer Sicherheitsverletzung oder wenn ein Benutzer sich aktiv abmeldet. Die Lösung ist eine Token-Blocklist in Redis, die revozierte Tokens bis zu ihrer Ablaufzeit speichert. Der Speicherbedarf ist gering, da Einträge automatisch nach Ablauf der Token-TTL entfernt werden.
// src/config/redis.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
lazyConnect: true,
maxRetriesPerRequest: 3
});
redis.on('error', (err) => {
console.error('Redis-Verbindungsfehler:', err.message);
});
redis.on('connect', () => {
console.log('Redis verbunden');
});
async function revokeToken(token, expiresAt) {
const secondsUntilExpiry = Math.floor(expiresAt - Date.now() / 1000);
if (secondsUntilExpiry > 0) {
// Token bis zu seinem natürlichen Ablauf auf Blocklist setzen
await redis.set(`revoked:${token}`, '1', 'EX', secondsUntilExpiry);
}
}
async function isTokenRevoked(token) {
const result = await redis.get(`revoked:${token}`);
return result !== null;
}
module.exports = { redis, revokeToken, isTokenRevoked };
Die Auth-Middleware wird um eine Redis-Prüfung erweitert:
// src/middleware/auth.js (erweitert mit Redis-Blocklist)
const jwt = require('jsonwebtoken');
const { isTokenRevoked } = require('../config/redis');
async function authenticateToken(req, res, next) {
const token = req.cookies?.access_token;
if (!token) {
return res.status(401).json({ error: 'Kein Token vorhanden' });
}
try {
// Zuerst Blocklist prüfen (vor JWT-Verify für schnelles Fail)
const revoked = await isTokenRevoked(token);
if (revoked) {
return res.status(401).json({ error: 'Token revoziert' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token abgelaufen', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ error: 'Ungültiges Token' });
}
}
module.exports = { authenticateToken };
Die TTL des Redis-Eintrags entspricht der verbleibenden Token-Laufzeit. Redis löscht den Eintrag automatisch, sobald das Token ohnehin abgelaufen wäre. Das hält die Redis-Datenbank sauber und der Speicherbedarf der Blocklist wächst nicht unkontrolliert. Für Redis-Betrieb in lokaler Entwicklung reicht ein Docker-Befehl: docker run -p 6379:6379 redis:alpine.
Schritt 12: Client Credentials Flow für Server-zu-Server-APIs
Wenn zwei Server miteinander kommunizieren (z. B. ein Microservice ruft einen anderen auf) und kein Benutzer beteiligt ist, kommt der Client Credentials Flow zum Einsatz. Dieser Flow benötigt kein PKCE und keine Redirect URI, da kein Authorization Code ausgetauscht wird. Der Client sendet Client-ID und Client-Secret direkt an den Token-Endpoint und erhält ein Access Token zurück.
// src/services/clientCredentials.js
async function getClientCredentialsToken(config) {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
scope: config.scope || ''
});
const response = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString()
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token-Request fehlgeschlagen: ${error.error_description || error.error}`);
}
const tokenData = await response.json();
return {
accessToken: tokenData.access_token,
tokenType: tokenData.token_type,
expiresIn: tokenData.expires_in, // Sekunden bis Ablauf
expiresAt: Date.now() + (tokenData.expires_in * 1000)
};
}
// Token-Cache um unnötige Anfragen zu vermeiden
let cachedToken = null;
async function getValidToken(config) {
if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
return cachedToken.accessToken; // 60 Sekunden Puffer vor Ablauf
}
cachedToken = await getClientCredentialsToken(config);
return cachedToken.accessToken;
}
module.exports = { getClientCredentialsToken, getValidToken };
Der Token-Cache verhindert unnötige Anfragen an den Token-Endpoint. Das Token wird 60 Sekunden vor seinem Ablauf als ungültig betrachtet (Puffer), damit kein Request mit einem fast-abgelaufenen Token starte. In Produktionsumgebungen mit mehreren Server-Instanzen sollte der Cache in Redis gespeichert werden, um Token-Endpoint-Anfragen über alle Instanzen zu reduzieren.
Ausgabe-Beispiele: Erwartetes Verhalten
Nach vollständiger Implementierung liefern die Endpoints folgende Ausgaben:
# Erfolgreicher Login nach OAuth-Callback
GET /auth/google/callback?code=4/P7q7W91a...&state=abc123
HTTP 200 OK
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiJ9...; HttpOnly; SameSite=Lax
Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiJ9...; HttpOnly; Path=/auth/refresh
{
"message": "Authentifizierung erfolgreich",
"user": {
"email": "[email protected]",
"name": "Max Mustermann"
}
}
# Geschützte Route mit gültigem Token
GET /api/profile
Cookie: access_token=eyJhbGciOiJIUzI1NiJ9...
HTTP 200 OK
{
"userId": "116029571234567890",
"email": "[email protected]",
"name": "Max Mustermann"
}
# Geschützte Route ohne Token
GET /api/profile
HTTP 401 Unauthorized
{ "error": "Kein Token vorhanden" }
# Abgelaufenes Token
GET /api/profile
Cookie: access_token=eyJhbGciOiJIUzI1NiJ9... (15 min vergangen)
HTTP 401 Unauthorized
{ "error": "Token abgelaufen", "code": "TOKEN_EXPIRED" }
# Token-Refresh
POST /auth/refresh
Cookie: refresh_token=eyJhbGciOiJIUzI1NiJ9...
HTTP 200 OK
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiJ9...(neu); HttpOnly
{ "message": "Token erfolgreich erneuert" }
5 häufige Fehler bei der OAuth 2.0-Implementierung
Diese Fallstricke begegnen Entwicklern am häufigsten beim Implementieren von OAuth 2.0 in Node.js:
Fehler 1: Tokens in localStorage speichern
Problem: Viele Tutorials empfehlen, Access Tokens im Browser-localStorage oder sessionStorage zu speichern. Das ist gefährlich, weil JavaScript jeder Herkunft auf localStorage zugreifen kann. Ein einziger XSS-Angriff reicht, um alle gespeicherten Tokens zu stehlen, und der Angreifer kann die Tokens dann von einem eigenen Server aus verwenden.
Lösung: Tokens ausschließlich als httpOnly-Cookies setzen. JavaScript kann auf httpOnly-Cookies grundsätzlich nicht zugreifen, auch nicht bei XSS. Die Cookie-Konfiguration in diesem Tutorial zeigt den korrekten Ansatz. Das Muster mit Two-Factor Authentication kombiniert erhöht die Sicherheit weiter.
Fehler 2: State-Parameter weglassen
Problem: Ohne den state-Parameter ist der OAuth-Flow anfällig für CSRF-Angriffe. Ein Angreifer könnte einen Benutzer dazu bringen, einen Authorization Code zu aktivieren, der dann an den Angreifer-Account gebunden wird (“Login CSRF”). Das ist ein reales Angriffsszenario mit dokumentierten Vorfällen.
Lösung: Immer einen kryptographisch zufälligen state-Parameter im Authorization Request mitsenden und im Callback gegen den gespeicherten Wert verifizieren. Passport.js übernimmt das automatisch mit state: true. Bei custom Implementierungen muss das explizit mit crypto.randomBytes(16).toString('hex') generiert werden.
Fehler 3: Redirect URI zu großzügig konfigurieren
Problem: Manche Entwickler registrieren Wildcards als Redirect URI (https://app.com/*) oder verwenden Regex-Matching, wenn der Authorization Server das anbietet. Das ermöglicht es einem Angreifer, den Authorization Code an eine beliebige URL unter der Domain umzuleiten.
Lösung: Redirect URIs immer als exakte Strings registrieren und aus Umgebungsvariablen laden. Für Entwicklung und Produktion separate, exakte URIs in der Providerkonsole eintragen. Der Authorization Server soll String-Matching ohne Pattern-Unterstützung erzwingen, wie es OAuth 2.1 vorschreibt.
Fehler 4: Google-Tokens direkt als App-Tokens verwenden
Problem: Der von Google erhaltene Access Token wird direkt an den Browser weitergegeben und für API-Anfragen verwendet. Das erzeugt eine starke Abhängigkeit von Google, und der Token hat Googles eigene Laufzeit und Scopes, nicht die der App. Außerdem kann der Token nicht kontrolliert revoziert werden.
Lösung: Nach erfolgreichem OAuth-Handshake eigene JWTs ausstellen (wie in Schritt 7 gezeigt). Der Google-Token wird nur beim Login verwendet, um die Identität zu prüfen. Danach arbeitet die App ausschließlich mit eigenen Tokens, die volle Kontrolle über Laufzeit, Scopes und Revokation bieten.
Fehler 5: Refresh Token für jeden Benutzer mehrfach ausstellen
Problem: Wenn bei jedem Login ein neuer Refresh Token ausgestellt wird ohne den alten zu invalidieren, können sich im Laufe der Zeit viele gültige Refresh Tokens für denselben Benutzer ansammeln. Ein gestohlener Token bleibt dauerhaft gültig, selbst wenn sich der Benutzer neu anmeldet.
Lösung: Refresh Token Rotation implementieren: Bei jedem Einsatz eines Refresh Tokens wird ein neues Refresh Token ausgestellt und das alte in der Blocklist gespeichert. Wenn ein bereits invalidierter Refresh Token verwendet wird, deutet das auf Token-Diebstahl hin, und alle Sessions des Benutzers werden gesperrt.
Weitere Pitfalls auf einen Blick
| Fehlermeldung / Problem | Ursache | Lösung |
|---|---|---|
redirect_uri_mismatch | URI stimmt nicht exakt überein | Trailing Slash, HTTP vs HTTPS, genaue URI in Konsole prüfen |
invalid_grant | Authorization Code bereits verwendet | Code ist einmalig, Seite neu laden und Flow neu starten |
PKCE invalid_code_verifier | Falsches Encoding des Verifiers | base64url verwenden, nicht base64 (kein =, +, /) |
| Session nach Callback leer | Cookie-Attribute verhindern Session | sameSite: 'lax' und secure-Flag in Produktion prüfen |
| Google gibt keinen Refresh Token | prompt: 'consent' fehlt | accessType: 'offline' + prompt: 'consent' setzen |
passport.session() Error | Falsche Middleware-Reihenfolge | session() vor passport.initialize() vor passport.session() |
| Token nicht in Cookie | cookie-parser fehlt | app.use(cookieParser()) vor Route-Middleware setzen |
Troubleshooting: 8 häufige Fehlermeldungen erklärt
- Error 400: redirect_uri_mismatch – Die Redirect URI im Code stimmt nicht exakt mit der in der Google Console registrierten URI überein. Leerzeichen, abschließende Slashes und HTTP vs. HTTPS prüfen. Umgebungsvariable
GOOGLE_CALLBACK_URLin der Konsole und im.envvergleichen. - Error 401: invalid_client – Client-ID oder Client-Secret ist falsch. Die
.env-Datei prüfen und sicherstellen, dassrequire('dotenv').config()die erste Zeile inapp.jsist, bevor andere Module geladen werden. - Error 400: invalid_grant – Der Authorization Code wurde bereits eingelöst oder ist abgelaufen (üblicherweise 10 Minuten Gültigkeitsfenster). Seite neu laden und OAuth-Flow vollständig neu starten.
- Error 403: access_denied – Der Benutzer hat die Zustimmung verweigert, oder die angeforderten Scopes sind für die App nicht verfügbar. Scopes in der Google Console prüfen und ggf. die OAuth-Zustimmungsseite korrekt konfigurieren.
- TokenExpiredError: jwt expired – Das JWT-Access-Token ist abgelaufen. Das ist erwartetes Verhalten. Der Client soll automatisch den
/auth/refresh-Endpoint aufrufen, wenncode: 'TOKEN_EXPIRED'in der Antwort steht. - JsonWebTokenError: invalid signature – Der JWT-Secret stimmt nicht mit dem Secret überein, mit dem das Token ausgestellt wurde. Alle ausgestellten Tokens werden sofort ungültig, wenn der Secret geändert wird. Secret niemals ohne Planung ändern.
- Error: Failed to serialize user into session – Passport’s
serializeUserist nicht korrekt konfiguriert oder wurde nicht registriert. Prüfen, obpassport.serializeUserundpassport.deserializeUserin der Passport-Konfiguration vorhanden sind. - ECONNREFUSED redis://localhost:6379 – Redis läuft nicht. Mit
redis-cli pingprüfen (Antwort:PONG). Für lokale Entwicklung Redis starten:docker run -d -p 6379:6379 redis:alpine.
OAuth 2.0 Sicherheits-Checkliste vor dem Go-Live
Vor dem Produktionseinsatz jede OAuth 2.0-Implementierung anhand dieser Checkliste prüfen. Jeder Punkt adressiert ein reales Sicherheitsrisiko:
| Prüfpunkt | Wichtigkeit | Betrifft |
|---|---|---|
| PKCE für alle Authorization Code Flows | Kritisch | Code-Injection-Schutz |
| State-Parameter für CSRF-Schutz | Kritisch | Login-CSRF-Schutz |
| httpOnly Cookies statt localStorage | Kritisch | XSS-Token-Diebstahl |
| Redirect URI exakt registriert (kein Wildcard) | Kritisch | Open-Redirect-Angriffe |
| Access Token Laufzeit max. 15 Minuten | Hoch | Token-Kompromittierung |
| Refresh Token Rotation implementiert | Hoch | Replay-Angriffe auf Refresh Tokens |
| Token-Blocklist für sofortige Revokation | Hoch | Kompromittierte Token invalidieren |
| Issuer-Validierung beim JWT-Verify | Hoch | Token-Confusion-Angriffe |
| Rate Limiting auf Auth-Endpoints | Mittel | Brute-Force-Angriffe |
| Minimale Scopes angefordert | Mittel | Minimierung des Angriffsradius |
| Tokens nicht in Server-Logs | Mittel | Token-Leak durch Logs |
| HTTPS in Produktion erzwungen | Kritisch | Man-in-the-Middle-Angriffe |
Fortgeschrittene Tipps für Produktionsumgebungen
Wer die Implementierung produktionsreif machen will, sollte folgende Aspekte beachten:
Rate Limiting für Auth-Endpoints
OAuth-Endpoints sind bevorzugte Ziele für automatisierte Angriffe. Der /auth/refresh-Endpoint sollte auf maximal 10 Anfragen pro Minute pro IP begrenzt werden. Der /auth/logout-Endpoint auf 5 Anfragen pro Minute. Das Rate Limiting in Node.js Tutorial zeigt die Implementierung mit express-rate-limit und Redis-Backing für verteilte Systeme.
DPoP (Demonstrating Proof of Possession)
Bearer-Tokens können von jedem verwendet werden, der sie besitzt. DPoP ist eine Erweiterung, die ein Access Token an den Client bindet, der es angefordert hat. Bei jeder API-Anfrage signiert der Client einen kleinen Proof-of-Possession-JWT mit seinem privaten Schlüssel. Der Server verifiziert diese Signatur und stellt sicher, dass der Absender derselbe Client ist, der das Token angefordert hat. Gestohlene Bearer-Tokens sind damit deutlich weniger wertvoll. Mehr über digitale Signaturen erklärt das Tutorial zu Digitale Signaturen in Node.js.
Monitoring und Anomalie-Erkennung
In Produktion sollten Auth-Endpoints auf Anomalien überwacht werden: ungewöhnlich viele fehlgeschlagene Login-Versuche, Token-Refreshes aus unbekannten IP-Adressen, oder Verwendung von bereits revozierten Tokens (deutet auf aktiven Angriff hin). Tools wie Prometheus und Grafana lassen sich direkt in Express-Apps integrieren. Jede Verwendung eines revozierten Tokens sollte einen Alert auslösen. Das Thema Zwei-Faktor-Authentifizierung als zusätzliche Schutzschicht behandelt das Tutorial zu Two-Factor Authentication in Node.js.
OAuth 2.1: Was sich gegenüber OAuth 2.0 ändert
OAuth 2.1 konsolidiert OAuth 2.0 und seine wichtigsten Sicherheitserweiterungen (PKCE, Token Rotation, Sender-Constrained Tokens) in einem einzigen Dokument. Entwickler, die heute mit PKCE und httpOnly-Cookies implementieren, sind bereits weitgehend OAuth 2.1-kompatibel. Die konkreten Änderungen:
- PKCE ist Pflicht für alle Authorization Code Flows, auch für vertrauliche (server-seitige) Clients
- Implicit Flow ist entfernt aus der Spezifikation, da er inherent unsicher ist
- ROPC-Flow (Resource Owner Password Credentials) ist entfernt, da er das Passwort an den Client weitergibt
- Refresh Token Rotation ist empfohlen für alle public clients
- Redirect URIs müssen exakt matchen, Wildcard-Matching ist explizit verboten
- Bearer-Token-Übertragung in URLs ist verboten, nur Header oder httpOnly-Cookies
Die offizielle OAuth 2.0-Website und die zugehörigen IETF-RFCs sind der maßgebliche Referenzpunkt für alle Implementierungsdetails und Sicherheitsrichtlinien.
Verwandte Artikel
Weiterführende Lektüre
- JWT Authentication in Node.js: 10 Schritte – Token-basierte Authentifizierung als Grundlage
- Two-Factor Authentication in Node.js: 11 Schritte – OAuth mit 2FA für maximale Sicherheit kombinieren
- CSRF Protection in Node.js: 12 Schritte – CSRF-Angriffe über OAuth hinaus abwehren
- Node.js Session Management: 11 Schritte – Session-basierte Alternative zu JWT
- Rate Limiting in Node.js: 12 Schritte – Auth-Endpoints vor Missbrauch schützen
- Digitale Signatur in Node.js: 11 Schritte – Kryptographische Grundlagen für Token-Signierung
FAQ: OAuth 2.0 in Node.js
Was ist der Unterschied zwischen OAuth 2.0 und OpenID Connect?
OAuth 2.0 ist ein Autorisierungsprotokoll, das regelt, welche Ressourcen eine App im Auftrag eines Benutzers zugreifen darf. OpenID Connect (OIDC) ist eine Identitätsschicht auf OAuth 2.0, die Authentifizierung hinzufügt, also die Identitätsprüfung des Benutzers. OIDC liefert neben dem Access Token auch ein ID Token (JWT) mit Benutzerinformationen wie E-Mail und Name. In der Praxis werden beide kombiniert: OAuth 2.0 für die Ressourcen-Autorisierung, OIDC für den Login und die Benutzerprofil-Übertragung.
Warum ist PKCE auch für server-seitige Apps empfohlen?
Ursprünglich wurde PKCE nur für public clients (Browser-Apps, Native Apps) entwickelt, die kein Client-Secret sicher speichern können. Die OAuth 2.1-Spezifikation empfiehlt PKCE aber auch für vertrauliche (server-seitige) Clients, weil es eine zusätzliche Schutzschicht gegen Authorization Code Interception bietet. Ein abgefangener Authorization Code ist ohne den code_verifier wertlos, selbst wenn der Angreifer auch das Client-Secret kennt.
Wie lange sollten Access Tokens gültig sein?
Die allgemeine Empfehlung liegt bei 5-15 Minuten für Access Tokens. Kurze Laufzeiten begrenzen den Schaden erheblich, wenn ein Token kompromittiert wird. Refresh Tokens können 7-30 Tage gültig sein, sollten aber mit Token Rotation kombiniert werden. Bei besonders sensiblen Anwendungen (Banking, Gesundheit) werden Access Tokens oft auf 5 Minuten oder weniger begrenzt. Die Wahl der Laufzeit ist ein Kompromiss zwischen Sicherheit und Nutzerkomfort.
Was passiert, wenn jemand den Authorization Code abfängt?
Ohne PKCE kann ein abgefangener Authorization Code direkt gegen ein Access Token eingetauscht werden. Mit PKCE ist das nicht möglich, weil der Angreifer den code_verifier nicht kennt, der auf dem Client gespeichert ist und für den Token-Request benötigt wird. Der Authorization Code selbst ist ohne den passenden Verifier wertlos. Das ist der Kernvorteil von PKCE und der Grund, warum es für alle Flows empfohlen wird.
Kann OAuth 2.0 ohne externe Identity Provider implementiert werden?
Ja. Ein eigener Authorization Server kann mit Node.js-Paketen wie @node-oauth/oauth2-server oder oidc-provider implementiert werden. Das ist jedoch erheblich komplexer und sicherheitskritischer als die Nutzung von Google, GitHub oder einem Identity-as-a-Service-Anbieter. Fehler in eigenen Authorization-Server-Implementierungen können schwerwiegende Sicherheitslücken erzeugen. Für die meisten Anwendungen ist ein externer Provider die sicherere und wartungsärmere Wahl.
Welche OWASP-Sicherheitsrisiken adressiert eine korrekte OAuth 2.0-Implementierung?
Eine korrekte OAuth 2.0-Implementierung adressiert direkt mehrere OWASP Top 10-Risiken: A01 (Broken Access Control) durch korrekte Token-Validierung, A02 (Cryptographic Failures) durch sichere Token-Signierung mit starken Secrets, A07 (Identification and Authentication Failures) durch State-Parameter und PKCE sowie A10 (Server-Side Request Forgery) durch exakte Redirect-URI-Validierung. Die Kombination aller Maßnahmen in diesem Tutorial deckt diese Risiken vollständig ab.
Wie wird OAuth 2.0 mit CSRF-Schutz kombiniert?
OAuth 2.0 enthält bereits einen integrierten CSRF-Schutz durch den state-Parameter. Darüber hinaus schützt das sameSite: 'lax'-Cookie-Attribut vor CSRF-Angriffen auf die eigene App. Für vollständigen CSRF-Schutz auf nicht-OAuth-Routen (Formulare, API-Endpoints) sollte zusätzlich das csurf-Muster oder das Double-Submit-Cookie-Muster eingesetzt werden. Das CSRF Protection in Node.js Tutorial behandelt dieses Thema ausführlich.
Welches Passport-Paket ist 2026 empfohlen?
Für Google OAuth 2.0 in Node.js ist passport-google-oauth20 die meistgenutzte Wahl. Für andere OpenID Connect-Provider gibt es passport-openidconnect. Wer mehr Flexibilität und PKCE-Unterstützung braucht, greift auf openid-client zurück, das als OpenID-zertifizierte Bibliothek gilt. Die offizielle Passport.js-Website listet alle verfügbaren Strategien. Wer einen eigenen Authorization Server baut, nutzt @node-oauth/oauth2-server oder oidc-provider.




