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

FlowEinsatzbereichPKCE nötigEmpfohlen 2026
Authorization Code + PKCEWebanwendungen, Native Apps, SPAsJaJa
Client CredentialsServer-zu-Server (kein User)NeinJa
Device CodeTV-Apps, CLI-ToolsNeinJa
Implicit FlowAlte SPAs (veraltet)EntfälltNein, 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:

  1. Google Cloud Console aufrufen und ein neues Projekt erstellen (oder ein bestehendes wählen)
  2. Unter APIs & Services > OAuth-Zustimmungsseite den Anwendungstyp “extern” wählen und App-Name eintragen
  3. Unter APIs & Services > Zugangsdaten auf Zugangsdaten erstellen > OAuth 2.0-Client-IDs klicken
  4. Anwendungstyp: Webanwendung auswählen
  5. Autorisierte JavaScript-Quellen: http://localhost:3000
  6. Autorisierte Weiterleitungs-URIs: http://localhost:3000/auth/google/callback
  7. 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:

PaketFunktionKategorie
expressHTTP-Framework für Routing und MiddlewareCore
passportAuthentifizierungs-Middleware mit Strategy-PatternAuth
passport-google-oauth20Google OAuth 2.0 Strategy für PassportAuth
express-sessionSession-Handling als Basis für PassportAuth
cookie-parserCookie-Parsing für httpOnly-Token-CookiesAuth
jsonwebtokenJWT-Erstellung und -ValidierungToken
dotenvUmgebungsvariablen aus .env-Datei ladenConfig
crypto (built-in)PKCE code_verifier generieren (kein npm nötig)Security
ioredisRedis-Client für Token-Revokation und -BlocklistStorage
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 / ProblemUrsacheLösung
redirect_uri_mismatchURI stimmt nicht exakt übereinTrailing Slash, HTTP vs HTTPS, genaue URI in Konsole prüfen
invalid_grantAuthorization Code bereits verwendetCode ist einmalig, Seite neu laden und Flow neu starten
PKCE invalid_code_verifierFalsches Encoding des Verifiersbase64url verwenden, nicht base64 (kein =, +, /)
Session nach Callback leerCookie-Attribute verhindern SessionsameSite: 'lax' und secure-Flag in Produktion prüfen
Google gibt keinen Refresh Tokenprompt: 'consent' fehltaccessType: 'offline' + prompt: 'consent' setzen
passport.session() ErrorFalsche Middleware-Reihenfolgesession() vor passport.initialize() vor passport.session()
Token nicht in Cookiecookie-parser fehltapp.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_URL in der Konsole und im .env vergleichen.
  • Error 401: invalid_client – Client-ID oder Client-Secret ist falsch. Die .env-Datei prüfen und sicherstellen, dass require('dotenv').config() die erste Zeile in app.js ist, 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, wenn code: '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 serializeUser ist nicht korrekt konfiguriert oder wurde nicht registriert. Prüfen, ob passport.serializeUser und passport.deserializeUser in der Passport-Konfiguration vorhanden sind.
  • ECONNREFUSED redis://localhost:6379 – Redis läuft nicht. Mit redis-cli ping prü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üfpunktWichtigkeitBetrifft
PKCE für alle Authorization Code FlowsKritischCode-Injection-Schutz
State-Parameter für CSRF-SchutzKritischLogin-CSRF-Schutz
httpOnly Cookies statt localStorageKritischXSS-Token-Diebstahl
Redirect URI exakt registriert (kein Wildcard)KritischOpen-Redirect-Angriffe
Access Token Laufzeit max. 15 MinutenHochToken-Kompromittierung
Refresh Token Rotation implementiertHochReplay-Angriffe auf Refresh Tokens
Token-Blocklist für sofortige RevokationHochKompromittierte Token invalidieren
Issuer-Validierung beim JWT-VerifyHochToken-Confusion-Angriffe
Rate Limiting auf Auth-EndpointsMittelBrute-Force-Angriffe
Minimale Scopes angefordertMittelMinimierung des Angriffsradius
Tokens nicht in Server-LogsMittelToken-Leak durch Logs
HTTPS in Produktion erzwungenKritischMan-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

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.