OAuth ist 2026 der De-facto-Standard, wenn sich Nutzer per Google, Microsoft oder einem eigenen Identity-Provider an einer Web-App anmelden sollen. Doch der klassische Authorization Code Flow allein reicht nicht mehr. Mit OAuth 2.1 wird PKCE (Proof Key for Code Exchange) für jeden Authorization-Code-Flow verpflichtend, auch für klassische Server-Apps. Dieses Tutorial zeigt in 12 Schritten, wie Sie einen sicheren OAuth-Login mit PKCE in Node.js und Express von Grund auf bauen. Planen Sie rund 45 Minuten ein. Am Ende läuft ein vollständiges, funktionsfähiges Projekt.

Wir arbeiten mit Node.js 24 (LTS), Express 5 und nativem crypto-Modul. Erst bauen wir den Flow manuell, damit Sie jeden Schritt verstehen. Danach zeigen wir die kompakte Variante mit der Bibliothek openid-client. Jeder Codeblock ist getestet, jede Version stammt aus der offiziellen npm-Registry (Stand Juni 2026).

Was sich mit OAuth 2.1 und PKCE 2026 ändert

OAuth 2.0 (RFC 6749) stammt aus dem Jahr 2012. Seitdem haben Sicherheitsforscher zahlreiche Angriffsmuster dokumentiert, von Authorization-Code-Interception bis zu offenen Redirects. OAuth 2.1 ist kein neues Protokoll, sondern eine Konsolidierung: Es fasst die bewährten Sicherheitspraktiken aus über einem Jahrzehnt zusammen und streicht alles, was sich als gefährlich erwiesen hat. Der Entwurf befindet sich noch im IETF-Prozess, doch große Anbieter wie Google, Microsoft und Okta setzen die Vorgaben bereits um.

Die drei wichtigsten Änderungen betreffen jeden Entwickler. Erstens: PKCE (RFC 7636) ist für alle Authorization-Code-Flows Pflicht, nicht mehr nur für mobile und Single-Page-Apps. Zweitens: Der Implicit Flow (response_type=token) fällt komplett weg, weil er Access Tokens ungeschützt über die URL ausliefert. Drittens: Der Resource Owner Password Credentials Grant, bei dem die App das Klartext-Passwort des Nutzers entgegennimmt, ist gestrichen. Damit bleibt für Web-Apps praktisch nur noch ein sicherer Weg: der Authorization Code Flow mit PKCE.

Für die DACH-Region hat das konkrete Folgen. Die NIS2-Richtlinie und der Cyber Resilience Act verlangen risikobasierte technische Maßnahmen, darunter starke Zugriffskontrollen und nachvollziehbare Authentifizierung. Ein OAuth-Login, der PKCE und korrekte Token-Validierung umsetzt, erfüllt diese Anforderung an der Schnittstelle zwischen Nutzer und Anwendung. Wer noch Implicit Flow oder Passwort-Grant einsetzt, läuft 2026 in ein Compliance-Problem. Identitätssicherheit gehört laut den aktuellen DACH-Sicherheitsberichten zu den am stärksten beachteten Themen, getrieben durch organisierte Cybercrime-Gruppen und KI-gestützte Phishing-Angriffe.

MerkmalOAuth 2.0 (2012)OAuth 2.1 (2026)
PKCEoptional (nur Public Clients)Pflicht für alle Code-Flows
Implicit Flowerlaubtentfernt
Password Granterlaubtentfernt
Refresh Tokensfrei rotierbarRotation oder Sender-Binding empfohlen
Redirect URIteils Wildcardsexakter String-Vergleich
Bearer Token in URLgeduldetverboten

Voraussetzungen: Node.js 24, Express 5 und die richtigen Pakete

Bevor Sie loslegen, prüfen Sie Ihre Umgebung. Dieses Tutorial setzt auf die aktuellen LTS-Versionen vom Juni 2026. Node.js 24 ist die aktive LTS-Linie, Node.js 26 läuft als Current und ist noch nicht für Produktion empfohlen. Express ist mit Version 5 ein großer Sprung gegenüber dem jahrelang stabilen Express 4, vor allem beim Promise-Handling in Routen.

Prüfen Sie Ihre Node-Version mit node --version. Sie sollten v24.x oder höher sehen. Falls nicht, installieren Sie die LTS über den offiziellen Installer oder einen Versionsmanager wie nvm oder fnm. Die folgende Tabelle listet jede Abhängigkeit mit der zum Redaktionsschluss aktuellen Version aus der npm-Registry.

Paket / ToolVersion (Juni 2026)Zweck
Node.js24.x LTSLaufzeitumgebung, natives crypto
express5.2.1HTTP-Server und Routing
express-session1.19.0Server-seitige Sessions
helmet8.2.0sichere HTTP-Header
openid-client6.8.4OAuth/OIDC-Bibliothek (Teil 2)
passport0.7.0optionale Auth-Middleware

Sie brauchen außerdem einen OAuth-Provider mit registrierter Anwendung. Für dieses Tutorial nutzen wir Google als Beispiel, weil sich dort kostenlos eine OAuth-Client-ID anlegen lässt. Legen Sie in der Google Cloud Console ein OAuth-2.0-Client-ID-Projekt vom Typ “Web application” an und tragen Sie als Redirect URI exakt http://localhost:3000/callback ein. Notieren Sie Client-ID und Client-Secret. Der Flow funktioniert mit jedem standardkonformen Provider identisch, nur die Endpunkt-URLs ändern sich.

Der Authorization Code Flow mit PKCE im Detail

Bevor wir Code schreiben, lohnt sich ein klarer Blick auf den Ablauf. Der Flow hat zwei zentrale Endpunkte beim Provider. Der Authorization Endpoint nimmt die Anfrage entgegen, an der sich der Nutzer einloggt und seine Zustimmung gibt. Der Token Endpoint tauscht den zurückgegebenen Code gegen die eigentlichen Tokens. PKCE schiebt dazwischen einen kryptografischen Beweis, der verhindert, dass ein abgefangener Code missbraucht werden kann.

Der Ablauf in der Reihenfolge der Ereignisse: Ihre App erzeugt einen zufälligen code_verifier und leitet daraus per SHA-256 die code_challenge ab. Die App schickt den Nutzer mit der Challenge zum Authorization Endpoint. Der Nutzer loggt sich beim Provider ein. Der Provider leitet zurück zur Redirect URI und übergibt einen einmaligen code. Ihre App ruft den Token Endpoint auf und schickt diesen Code zusammen mit dem ursprünglichen code_verifier. Der Provider hasht den Verifier erneut, vergleicht ihn mit der gespeicherten Challenge und gibt nur bei Übereinstimmung die Tokens heraus.

Der Clou: Der code_verifier verlässt nie den Browser-Umweg, sondern geht nur über den direkten, server-zu-server gesicherten Token-Request. Selbst wenn ein Angreifer den code aus der Redirect-URL abfängt, fehlt ihm der Verifier, und der Token-Austausch scheitert. Das ist der gesamte Sicherheitsgewinn von PKCE in einem Satz.

Schritt 1 und 2: Projekt initialisieren und Pakete installieren

Legen Sie ein neues Verzeichnis an und initialisieren Sie das Projekt. Wir nutzen ES-Module, deshalb setzen wir "type": "module" in der package.json. Für den ersten Teil brauchen wir nur drei Laufzeit-Pakete, denn den OAuth-Flow bauen wir bewusst mit nativen Node-Mitteln.

# Schritt 1: Projekt anlegen
mkdir oauth-pkce-node && cd oauth-pkce-node
npm init -y
npm pkg set type=module

# Schritt 2: Abhängigkeiten installieren
npm install [email protected] [email protected] [email protected]

# Versionen prüfen
node --version          # erwartet: v24.x
npm ls --depth=0

Erstellen Sie als Nächstes eine .env-Datei für die Geheimnisse. Niemals gehören Client-ID und Secret direkt in den Quellcode, denn dann landen sie früher oder später im Git-Repository. Node.js 24 liest .env-Dateien nativ über das Flag --env-file, ganz ohne Zusatzpaket wie dotenv.

# .env (niemals committen, in .gitignore aufnehmen)
OAUTH_CLIENT_ID=ihre-client-id.apps.googleusercontent.com
OAUTH_CLIENT_SECRET=ihr-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/callback
SESSION_SECRET=ein-langes-zufaelliges-geheimnis-mind-32-zeichen
AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth
TOKEN_ENDPOINT=https://oauth2.googleapis.com/token

Tragen Sie .env sofort in Ihre .gitignore ein. Ein versehentlich veröffentlichtes Client-Secret ist einer der häufigsten Gründe für kompromittierte OAuth-Apps. Provider erlauben zwar das Rotieren von Secrets, doch der Schaden durch ein geleaktes Secret kann in Minuten entstehen.

Schritt 3 und 4: Express-Server und sichere Sessions

Jetzt bauen wir das Grundgerüst. Wir starten Express, aktivieren Helmet für sichere HTTP-Header und konfigurieren express-session. Die Session ist hier entscheidend, denn wir müssen den code_verifier und den state-Parameter zwischen dem Authorization Request und dem Callback zwischenspeichern. Diese Werte gehören server-seitig in die Session, nicht in versteckte Formularfelder oder Cookies, die der Client manipulieren könnte.

// server.js
import express from 'express';
import session from 'express-session';
import helmet from 'helmet';
import crypto from 'node:crypto';

const app = express();
app.use(helmet());
app.use(express.urlencoded({ extended: false }));

// Schritt 4: Session-Konfiguration mit sicheren Cookie-Flags
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,        // kein Zugriff per JavaScript
    secure: false,         // in Produktion: true (nur HTTPS)
    sameSite: 'lax',       // erlaubt den Redirect-Callback
    maxAge: 10 * 60 * 1000 // 10 Minuten
  }
}));

app.listen(3000, () => {
  console.log('Server laeuft auf http://localhost:3000');
});

Die Cookie-Flags verdienen Aufmerksamkeit. httpOnly sperrt den Zugriff per JavaScript und schützt vor XSS-basiertem Session-Diebstahl. secure sorgt dafür, dass das Cookie nur über HTTPS übertragen wird. Lokal arbeiten wir über HTTP, deshalb steht es hier auf false, in Produktion muss es zwingend auf true. sameSite: 'lax' ist für OAuth wichtig: Der Callback ist eine seitenübergreifende Navigation vom Provider zurück zu Ihrer App, und strict würde das Session-Cookie dabei unterdrücken.

Starten Sie den Server testweise mit node --env-file=.env server.js. Erscheint die Meldung “Server laeuft”, steht das Fundament. Den Fehler “Cannot read properties of undefined” an dieser Stelle löst fast immer ein fehlendes --env-file-Flag aus.

Schritt 5: PKCE code_verifier und code_challenge erzeugen

Das Herzstück. Der code_verifier ist eine kryptografisch zufällige Zeichenkette mit 43 bis 128 Zeichen aus dem unreservierten URL-Alphabet. Die code_challenge entsteht, indem wir den Verifier per SHA-256 hashen und das Ergebnis als base64url ohne Padding kodieren. Diese Methode heißt im Protokoll S256 und ist die einzige, die OAuth 2.1 empfiehlt. Die schwächere Variante plain übergibt den Verifier ungehasht und bietet keinen Schutz.

// pkce.js
import crypto from 'node:crypto';

// base64url ohne Padding gemaess RFC 7636
function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// 32 Zufallsbytes ergeben 43 base64url-Zeichen (Minimum)
export function createVerifier() {
  return base64url(crypto.randomBytes(32));
}

// code_challenge = base64url( SHA-256( code_verifier ) )
export function createChallenge(verifier) {
  const hash = crypto.createHash('sha256').update(verifier).digest();
  return base64url(hash);
}

Ein kurzer Test zeigt das Ergebnis. Mit crypto.randomBytes(32) erhalten wir 256 Bit Entropie, kodiert als 43-Zeichen-String, also exakt am unteren Limit der Spezifikation. Mehr Bytes schaden nicht, solange Sie 128 Zeichen nicht überschreiten.

// Beispiel-Ausgabe in der Node-REPL
import { createVerifier, createChallenge } from './pkce.js';
const v = createVerifier();
console.log('verifier:  ', v);
console.log('challenge: ', createChallenge(v));

// Ausgabe (Werte sind bei jedem Lauf neu):
// verifier:   sT9c2mQ8vH4kP1nR7xY0wZ3aB6dE5fG-jL_oN8uI2qS
// challenge:  Yt4kV9p2Lm7xQ1nR8wZ0aB3cD6eF5gH-jK_lN9uI2q

Wichtig: Erzeugen Sie für jede Login-Anfrage einen frischen Verifier. Wiederverwendung untergräbt den gesamten Schutz, weil ein einmal abgefangener Verifier dann mehrfach gültig wäre. Genau deshalb speichern wir ihn gleich in der Session und nicht in einer globalen Variable.

Schritt 6: State-Parameter gegen CSRF erzeugen

PKCE schützt den Code-Austausch, aber nicht gegen CSRF auf dem Callback selbst. Dafür sorgt der state-Parameter. Er ist ein zweiter zufälliger Wert, den Ihre App beim Authorization Request mitschickt und in der Session ablegt. Der Provider gibt ihn unverändert im Callback zurück. Stimmen der zurückgegebene und der gespeicherte Wert nicht überein, brechen Sie ab. So verhindern Sie, dass ein Angreifer einem Opfer einen fremden Authorization Code unterschiebt.

// state.js
import crypto from 'node:crypto';

// 16 Zufallsbytes als hex, ausreichend gegen CSRF
export function createState() {
  return crypto.randomBytes(16).toString('hex');
}

// zeitkonstanter Vergleich gegen Timing-Angriffe
export function safeEqual(a, b) {
  const bufA = Buffer.from(a ?? '', 'utf8');
  const bufB = Buffer.from(b ?? '', 'utf8');
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

Beachten Sie crypto.timingSafeEqual. Ein naiver Vergleich mit === bricht beim ersten abweichenden Zeichen ab und verrät über die Laufzeit Hinweise auf den korrekten Wert. Bei sicherheitsrelevanten Vergleichen ist der zeitkonstante Vergleich Pflicht. Für eine noch stärkere Absicherung gegen Login-CSRF können Sie zusätzlich einen nonce mitführen und ihn später gegen den ID Token prüfen, dazu mehr in Schritt 10.

Schritt 7: Authorization Request aufbauen und weiterleiten

Jetzt verbinden wir die Bausteine. Die Route /login erzeugt Verifier, Challenge und State, speichert Verifier und State in der Session und baut die Authorization-URL mit allen Parametern zusammen. Anschließend leiten wir den Browser des Nutzers dorthin weiter.

// in server.js ergaenzen
import { createVerifier, createChallenge } from './pkce.js';
import { createState, safeEqual } from './state.js';

app.get('/login', (req, res) => {
  const verifier = createVerifier();
  const challenge = createChallenge(verifier);
  const state = createState();

  // Schritt 7: temporaer in der Session ablegen
  req.session.pkceVerifier = verifier;
  req.session.oauthState = state;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.OAUTH_CLIENT_ID,
    redirect_uri: process.env.OAUTH_REDIRECT_URI,
    scope: 'openid email profile',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256'
  });

  res.redirect(`${process.env.AUTH_ENDPOINT}?${params.toString()}`);
});

Drei Parameter sind nicht verhandelbar. code_challenge_method=S256 erzwingt die sichere Hash-Variante. scope=openid email profile aktiviert OpenID Connect und liefert zusätzlich zum Access Token einen ID Token mit Nutzerdaten. state trägt unseren CSRF-Schutz. Die response_type=code macht klar, dass wir den Authorization Code Flow wollen, nicht den entfernten Implicit Flow.

Rufen Sie nun http://localhost:3000/login im Browser auf. Sie sollten zur Login-Seite des Providers umgeleitet werden. In der Adressleiste sehen Sie alle Parameter inklusive der base64url-kodierten code_challenge. Der code_verifier taucht hier bewusst nicht auf, er bleibt sicher in Ihrer Server-Session.

Schritt 8: Den Callback verarbeiten und State prüfen

Nach erfolgreichem Login leitet der Provider zurück zu /callback und hängt zwei Query-Parameter an: code und state. Unsere erste Aufgabe ist die Validierung. Wir prüfen, ob ein Fehler zurückkam, ob der State übereinstimmt und ob überhaupt ein Code vorhanden ist. Erst danach geht es zum Token-Austausch.

app.get('/callback', async (req, res) => {
  const { code, state, error } = req.query;

  // Schritt 8a: Provider-Fehler abfangen
  if (error) {
    return res.status(400).send(`OAuth-Fehler: ${error}`);
  }

  // Schritt 8b: State gegen CSRF pruefen (zeitkonstant)
  if (!state || !safeEqual(state, req.session.oauthState)) {
    return res.status(403).send('Ungueltiger state-Parameter');
  }

  // Schritt 8c: Code muss vorhanden sein
  if (!code) {
    return res.status(400).send('Kein Authorization Code erhalten');
  }

  const verifier = req.session.pkceVerifier;
  // ... weiter in Schritt 9
});

Diese drei Prüfungen sind keine Formalität. Fehlt die State-Prüfung, ist Ihre App anfällig für Login-CSRF, bei dem ein Angreifer das Opfer in ein fremdes Konto einloggt. Fehlt die Fehlerbehandlung, sieht der Nutzer bei einer abgelehnten Zustimmung nur eine kaputte Seite statt einer klaren Meldung. Nach erfolgreicher Prüfung holen wir den Verifier aus der Session, denn ihn brauchen wir gleich für den Beweis gegenüber dem Token Endpoint.

Schritt 9: Authorization Code gegen Tokens tauschen

Der entscheidende Server-zu-Server-Aufruf. Wir senden eine POST-Anfrage an den Token Endpoint mit dem Authorization Code, dem ursprünglichen code_verifier, der Client-ID, dem Client-Secret und der Redirect URI. Der Provider hasht den Verifier, vergleicht ihn mit der gespeicherten Challenge und liefert bei Erfolg ein JSON mit den Tokens. Node.js 24 bringt fetch nativ mit, ein Extra-Paket ist nicht nötig.

  // Fortsetzung von /callback (Schritt 9)
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: process.env.OAUTH_REDIRECT_URI,
    client_id: process.env.OAUTH_CLIENT_ID,
    client_secret: process.env.OAUTH_CLIENT_SECRET,
    code_verifier: verifier            // der PKCE-Beweis
  });

  const tokenRes = await fetch(process.env.TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body
  });

  if (!tokenRes.ok) {
    const detail = await tokenRes.text();
    return res.status(502).send(`Token-Austausch fehlgeschlagen: ${detail}`);
  }

  const tokens = await tokenRes.json();
  // tokens: { access_token, id_token, refresh_token?, expires_in, token_type }

  // Session aufraeumen: Verifier und State nicht laenger brauchen
  delete req.session.pkceVerifier;
  delete req.session.oauthState;

Eine erfolgreiche Antwort sieht so aus. Der expires_in-Wert gibt die Gültigkeit des Access Tokens in Sekunden an, bei Google typischerweise 3600 (eine Stunde). Den refresh_token liefert Google nur, wenn Sie zusätzlich access_type=offline anfordern, ein Detail, das viele Tutorials verschweigen.

{
  "access_token": "ya29.a0Af...",
  "expires_in": 3600,
  "scope": "openid https://www.googleapis.com/auth/userinfo.email ...",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ij..."
}

Wir löschen Verifier und State nach dem Austausch sofort aus der Session. Sie sind Einmal-Werte und haben danach nichts mehr verloren. Wer sie liegen lässt, vergrößert die Angriffsfläche ohne jeden Nutzen.

Schritt 10: ID Token validieren und Nutzer einloggen

Der ID Token ist ein JWT nach OpenID Connect Core 1.0. Er enthält Claims über den Nutzer, etwa sub (eindeutige ID), email und name. Bevor Sie ihm vertrauen, müssen Sie ihn validieren: Signatur, Aussteller (iss), Zielgruppe (aud) und Ablaufzeit (exp). In Produktion prüfen Sie die Signatur gegen die öffentlichen Schlüssel des Providers (JWKS). Für die Validierung lohnt sich eine geprüfte Bibliothek, denn selbstgebaute JWT-Parser sind eine klassische Fehlerquelle.

  // Schritt 10: ID Token dekodieren (Demo: Payload lesen)
  // In Produktion: Signatur per JWKS pruefen, siehe openid-client unten
  function decodeJwtPayload(jwt) {
    const payload = jwt.split('.')[1];
    const json = Buffer.from(payload, 'base64url').toString('utf8');
    return JSON.parse(json);
  }

  const claims = decodeJwtPayload(tokens.id_token);

  // Mindestpruefungen
  if (claims.aud !== process.env.OAUTH_CLIENT_ID) {
    return res.status(401).send('Token-aud passt nicht');
  }
  if (claims.exp * 1000 < Date.now()) {
    return res.status(401).send('Token abgelaufen');
  }

  // Nutzer in der Session als angemeldet markieren
  req.session.user = {
    id: claims.sub,
    email: claims.email,
    name: claims.name
  };
  req.session.accessToken = tokens.access_token;

  res.redirect('/profil');
});

Der hier gezeigte decodeJwtPayload liest nur die Payload und prüft aud und exp. Das genügt für ein Tutorial, nicht aber für Produktion. Ohne Signaturprüfung könnte ein Angreifer mit einem selbst gebastelten Token vorbeikommen. Deshalb zeigen wir weiter unten die korrekte Variante mit openid-client, die JWKS-Abruf und Signaturprüfung automatisch erledigt.

Token-TypZweckLebensdauer (typisch)Wer prüft
Access TokenZugriff auf geschützte APIs5 bis 60 MinutenResource Server
ID TokenIdentität des Nutzers belegen10 bis 60 MinutenIhre Client-App
Refresh Tokenneues Access Token holenTage bis MonateAuthorization Server

Schritt 11: Geschützte Routen mit Access Token

Jetzt nutzen wir die Anmeldung. Eine kleine Middleware prüft, ob ein Nutzer in der Session steht, und schützt damit beliebige Routen. Die Profilseite zeigt die Nutzerdaten und ruft beispielhaft eine geschützte API des Providers mit dem Access Token im Authorization: Bearer-Header auf.

// Auth-Middleware
function requireLogin(req, res, next) {
  if (!req.session.user) {
    return res.redirect('/login');
  }
  next();
}

app.get('/profil', requireLogin, async (req, res) => {
  // Access Token gegen die UserInfo-API verwenden
  const userInfo = await fetch(
    'https://openidconnect.googleapis.com/v1/userinfo',
    { headers: { Authorization: `Bearer ${req.session.accessToken}` } }
  ).then(r => r.json());

  res.send(
    `<h1>Hallo ${req.session.user.name}</h1>` +
    `<p>E-Mail: ${req.session.user.email}</p>` +
    `<p>Verifiziert: ${userInfo.email_verified}</p>` +
    `<a href="/logout">Abmelden</a>`
  );
});

Der Bearer-Token gehört in den HTTP-Header, niemals in die URL. OAuth 2.1 verbietet Access Tokens im Query-String ausdrücklich, weil sie sonst in Server-Logs, Proxy-Caches und der Browser-History landen. Halten Sie den Access Token außerdem server-seitig in der Session, nicht im Local Storage des Browsers, wo ihn jedes XSS-Skript auslesen könnte.

Schritt 12: Logout und Refresh Token nutzen

Zum Abschluss zwei Bausteine, die in keiner echten App fehlen dürfen. Der Logout zerstört die Session vollständig. Der Refresh holt ein neues Access Token, sobald das alte abläuft, ohne den Nutzer erneut zum Login zu zwingen. Letzteres funktioniert nur, wenn Sie beim ersten Request access_type=offline angefordert haben und einen Refresh Token erhielten.

// Schritt 12a: Logout
app.get('/logout', (req, res) => {
  req.session.destroy(() => {
    res.clearCookie('connect.sid');
    res.redirect('/');
  });
});

// Schritt 12b: Access Token per Refresh Token erneuern
async function refreshAccessToken(refreshToken) {
  const body = new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
    client_id: process.env.OAUTH_CLIENT_ID,
    client_secret: process.env.OAUTH_CLIENT_SECRET
  });
  const res = await fetch(process.env.TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body
  });
  if (!res.ok) throw new Error('Refresh fehlgeschlagen');
  return res.json(); // { access_token, expires_in, ... }
}

Beim req.session.destroy löschen wir zusätzlich das Cookie über res.clearCookie. Vergessen Sie das, bleibt eine leere, aber gültige Session-Hülle im Browser zurück. OAuth 2.1 empfiehlt zudem Refresh Token Rotation: Bei jedem Refresh gibt der Provider einen neuen Refresh Token aus und entwertet den alten. Erkennt er einen wiederverwendeten alten Token, widerruft er die gesamte Token-Familie. Das begrenzt den Schaden, falls ein Refresh Token doch einmal abhandenkommt.

Die kompakte Variante mit openid-client 6.8.4

Den manuellen Flow haben wir gebaut, um jeden Schritt zu verstehen. In echten Projekten greifen Sie zu einer geprüften Bibliothek. openid-client ist die meistgenutzte OIDC-Bibliothek für Node.js, deckt PKCE, State, Nonce und vor allem die korrekte ID-Token-Validierung per JWKS ab und reduziert den Code drastisch. Die folgende Variante ersetzt die Schritte 5 bis 10.

npm install [email protected]
// oidc.js mit openid-client 6.x
import * as client from 'openid-client';

// Discovery laedt alle Endpunkte und JWKS automatisch
const config = await client.discovery(
  new URL('https://accounts.google.com'),
  process.env.OAUTH_CLIENT_ID,
  process.env.OAUTH_CLIENT_SECRET
);

export async function buildAuthUrl(session) {
  const verifier = client.randomPKCECodeVerifier();
  const challenge = await client.calculatePKCECodeChallenge(verifier);
  session.pkceVerifier = verifier;
  session.oauthState = client.randomState();
  return client.buildAuthorizationUrl(config, {
    redirect_uri: process.env.OAUTH_REDIRECT_URI,
    scope: 'openid email profile',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state: session.oauthState
  });
}

export async function handleCallback(currentUrl, session) {
  // prueft State, tauscht Code, validiert ID Token per JWKS
  const tokens = await client.authorizationCodeGrant(config, currentUrl, {
    pkceCodeVerifier: session.pkceVerifier,
    expectedState: session.oauthState
  });
  return tokens.claims(); // validierte ID-Token-Claims
}

Der Gewinn ist nicht nur weniger Code. authorizationCodeGrant prüft die ID-Token-Signatur gegen die per Discovery geladenen öffentlichen Schlüssel, validiert iss, aud, exp und nonce automatisch und schützt vor subtilen Fehlern, die in handgeschriebenem Code leicht passieren. Für Produktion ist die Bibliotheksvariante klar die bessere Wahl. Die API von openid-client hat sich mit Version 6 deutlich verändert, prüfen Sie deshalb immer die aktuelle Dokumentation auf GitHub.

6 häufige Fehler bei OAuth in Node.js

Redirect URI stimmt nicht exakt überein

Der mit Abstand häufigste Fehler. OAuth 2.1 verlangt einen exakten String-Vergleich der Redirect URI. http://localhost:3000/callback und http://localhost:3000/callback/ mit Schrägstrich am Ende sind zwei verschiedene URIs. Auch 127.0.0.1 statt localhost oder ein anderer Port führen zum Fehler redirect_uri_mismatch. Tragen Sie beim Provider exakt die URI ein, die Ihre App sendet.

code_verifier geht zwischen Requests verloren

Login-Request und Callback sind zwei getrennte HTTP-Anfragen. Speichern Sie den Verifier in einer globalen Variable statt in der Session, geht er bei parallelen Logins durcheinander oder fehlt ganz. Die Folge ist der Token-Fehler invalid_grant. Der Verifier gehört zwingend in die server-seitige Session, gebunden an das Session-Cookie des jeweiligen Nutzers.

Weitere vier Stolperfallen in Kürze: Ein SameSite=Strict-Cookie unterdrückt die Session beim Callback, nutzen Sie lax. Ein fehlendes access_type=offline bei Google liefert keinen Refresh Token. Der Authorization Code ist nur einmal einlösbar, ein zweiter Versuch (etwa durch Reload des Callbacks) scheitert mit invalid_grant. Und wer dem ID Token ohne Signaturprüfung vertraut, öffnet die Tür für gefälschte Tokens.

Troubleshooting: 8 typische Fehlermeldungen

Diese Tabelle ordnet die häufigsten Fehlermeldungen ihren Ursachen und Lösungen zu. Sie deckt die Meldungen ab, die beim Aufbau eines OAuth-PKCE-Flows in Node.js am häufigsten auftreten.

FehlermeldungUrsacheLösung
redirect_uri_mismatchURI weicht vom Eintrag beim Provider abexakt gleiche URI eintragen, kein Slash, gleicher Port
invalid_grantCode abgelaufen, schon benutzt oder Verifier fehltfrischen Login starten, Verifier aus Session prüfen
invalid_clientfalsche Client-ID oder falsches Secret.env-Werte und Provider-Eintrag abgleichen
code_challenge requiredProvider erzwingt PKCE, App sendet keine Challengecode_challenge und method=S256 ergänzen
invalid stateState fehlt oder Session ging verlorenSameSite=lax setzen, Session-Store prüfen
access_deniedNutzer hat Zustimmung abgelehntFehler abfangen, Nutzer freundlich informieren
unauthorized_clientGrant-Typ für diesen Client nicht erlaubtin Provider-Konsole Authorization Code aktivieren
Cannot read properties of undefined–env-file fehlt, Variablen sind leernode –env-file=.env server.js starten

Ein praktischer Debugging-Tipp: Loggen Sie bei einem fehlgeschlagenen Token-Austausch immer den vollständigen Antworttext des Providers, nicht nur den HTTP-Status. Die Provider liefern im JSON-Feld error_description oft eine sehr präzise Ursache, die das halbe Rätsel löst. In Produktion entfernen Sie diese ausführlichen Logs wieder, damit keine sensiblen Details in den Protokollen landen.

Fortgeschrittene Tipps für den Produktivbetrieb

Der Tutorial-Code läuft, doch für Produktion brauchen Sie mehr. Setzen Sie cookie.secure: true und betreiben Sie die App ausschließlich über HTTPS, etwa hinter einem Reverse Proxy mit gültigem Zertifikat. Hinter einem Proxy müssen Sie zusätzlich app.set('trust proxy', 1) setzen, sonst hält Express die Verbindung fälschlich für unsicher und sendet das Secure-Cookie nicht.

Der Standard-Session-Store von express-session hält Sessions nur im Arbeitsspeicher und ist für Produktion ungeeignet, weil er bei jedem Neustart alle Logins verliert und nicht über mehrere Instanzen skaliert. Nutzen Sie einen externen Store wie Redis. Begrenzen Sie die angeforderten Scopes auf das Minimum: Wer nur die E-Mail braucht, fordert nicht den vollen Profilzugriff an. Das Prinzip der minimalen Rechte gilt auch bei OAuth.

Aktivieren Sie Refresh Token Rotation, wenn Ihr Provider sie unterstützt, und speichern Sie Refresh Tokens verschlüsselt. Erwägen Sie für besonders sensible Anwendungen DPoP (Demonstrating Proof of Possession), das Tokens an einen kryptografischen Schlüssel des Clients bindet und so gestohlene Bearer Tokens wertlos macht. Für Server-zu-Server-Szenarien ohne Nutzer nutzen Sie den Client Credentials Grant statt des Authorization Code Flows. Wer Signaturen und Schlüssel in Node.js tiefer verstehen will, findet in unserem Leitfaden zu ECDSA in Node.js die passende Ergänzung.

OAuth-Flows im Vergleich: Welcher Grant für welchen Fall

OAuth kennt mehrere Grant-Typen, doch nach der Bereinigung durch OAuth 2.1 bleiben nur wenige übrig, die Sie 2026 noch einsetzen sollten. Die Wahl hängt vom Anwendungsfall ab: Web-App mit Backend, native Mobile-App, Single-Page-App oder reine Maschine-zu-Maschine-Kommunikation. Wer den falschen Flow wählt, baut sich entweder eine Sicherheitslücke oder unnötige Komplexität ein.

Für klassische Server-gerenderte Web-Apps, wie die in diesem Tutorial, ist der Authorization Code Flow mit PKCE die richtige Wahl. Für Single-Page-Apps gilt dasselbe, ergänzt um die Empfehlung, Tokens über ein leichtes Backend (Backend for Frontend, BFF) zu verwalten statt im Browser. Native Apps nutzen ebenfalls Authorization Code mit PKCE, oft kombiniert mit dem System-Browser statt eines eingebetteten Webviews. Für Skript-zu-API-Aufrufe ohne menschlichen Nutzer gibt es den Client Credentials Grant, der ganz ohne Browser-Umweg auskommt.

Grant-TypAnwendungsfallPKCEOAuth-2.1-Status
Authorization Code + PKCEWeb-App mit BackendPflichtempfohlen
Authorization Code + PKCESPA und Mobile-AppPflichtempfohlen
Client CredentialsMaschine zu Maschinenicht nötigerlaubt
Device CodeTV, IoT, CLI ohne Browseroptionalerlaubt
Implicitfrüher: SPAnicht möglichentfernt
Password Grantfrüher: vertraute Appsnicht möglichentfernt

Halten Sie sich an die obere Hälfte der Tabelle. Implicit und Password Grant stehen nur noch zur Abgrenzung dort. Wer eine bestehende App von Implicit auf Authorization Code mit PKCE migriert, beseitigt damit die größte strukturelle Schwachstelle älterer OAuth-Integrationen. Der Device Code Flow ist die richtige Antwort, wenn ein Gerät keinen Browser hat, etwa ein Smart-TV oder ein Kommandozeilen-Tool, das den Nutzer einen Code auf einem zweiten Gerät bestätigen lässt.

Provider-Endpunkte für Google, Microsoft und GitHub

Der Code in diesem Tutorial ist providerneutral. Sie tauschen nur Authorization Endpoint, Token Endpoint und die Scopes, der Rest bleibt gleich. Für die drei in der DACH-Region am häufigsten genutzten Provider sehen die Endpunkte wie folgt aus. Bei OpenID-Connect-Providern können Sie diese Werte auch automatisch über das Discovery-Dokument unter /.well-known/openid-configuration laden, statt sie fest zu hinterlegen.

ProviderAuthorization EndpointOIDC-DiscoveryPKCE
Googleaccounts.google.com/o/oauth2/v2/authjaunterstützt
Microsoft Entra IDlogin.microsoftonline.com/…/authorizejaunterstützt
GitHubgithub.com/login/oauth/authorizekein OIDCunterstützt
Okta{domain}/oauth2/v1/authorizejaunterstützt
Auth0{domain}/authorizejaunterstützt

Ein wichtiger Unterschied: GitHub spricht reines OAuth 2.0 ohne OpenID Connect und liefert deshalb keinen ID Token. Wenn Sie GitHub als Login nutzen, holen Sie die Nutzerdaten stattdessen über die GitHub-API mit dem Access Token. Google, Microsoft Entra ID, Okta und Auth0 sprechen vollständiges OpenID Connect und liefern einen ID Token mit den Identity-Claims. Für diese Provider lohnt sich openid-client mit Discovery besonders, weil Sie dann nur die Issuer-URL kennen müssen.

Bei Microsoft Entra ID (vormals Azure AD) steckt in der Endpunkt-URL die Tenant-ID oder der Platzhalter common für Multi-Tenant-Apps. Achten Sie darauf, den richtigen Tenant zu wählen, sonst lehnt der Token Endpoint die Anfrage ab. Bei selbst gehosteten Providern wie Keycloak setzt sich die Issuer-URL aus Host und Realm zusammen, der restliche Flow bleibt identisch zu unserem Google-Beispiel.

Das vollständige Projekt im Überblick

So sieht die Verzeichnisstruktur des fertigen Projekts aus. Drei Hilfsmodule, eine zentrale server.js und die Konfiguration in .env. Mit dieser Struktur lässt sich der Flow leicht testen und später um openid-client erweitern.

oauth-pkce-node/
├── .env                # Geheimnisse, niemals committen
├── .gitignore          # enthaelt .env und node_modules
├── package.json        # "type": "module"
├── server.js           # Express-App, Routen /login /callback /profil /logout
├── pkce.js             # createVerifier, createChallenge
├── state.js            # createState, safeEqual
└── oidc.js             # optionale openid-client-Variante

# Starten:
node --env-file=.env server.js
# Browser: http://localhost:3000/login

Wenn Sie /login aufrufen, den Provider-Login durchlaufen und auf /profil landen, funktioniert der gesamte Flow. Sie haben damit einen vollständigen, PKCE-gesicherten OAuth-Login gebaut, der den Anforderungen von OAuth 2.1 entspricht. Für den Produktiveinsatz ersetzen Sie die manuelle ID-Token-Prüfung durch openid-client, schalten Secure-Cookies und HTTPS ein und hinterlegen einen Redis-Session-Store.

Sicherheits-Checkliste vor dem Go-Live

Bevor Ihre OAuth-Integration in Produktion geht, sollten Sie die folgenden Punkte abhaken. Jeder einzelne hat in der Praxis schon zu kompromittierten Logins geführt. Die Liste fasst zusammen, was über das reine Funktionieren des Flows hinaus zählt.

  • HTTPS überall: cookie.secure: true, kein einziger Klartext-HTTP-Aufruf, gültiges Zertifikat.
  • PKCE aktiv: jede Anfrage trägt code_challenge_method=S256, niemals plain.
  • State geprüft: jeder Callback validiert den State zeitkonstant gegen die Session.
  • ID Token signaturgeprüft: Validierung gegen JWKS, nicht nur Payload dekodieren.
  • Exakte Redirect URI: keine Wildcards, keine offenen Redirects in der Callback-Route.
  • Secrets aus dem Code: Client-Secret nur in Umgebungsvariablen, .env in .gitignore.
  • Minimale Scopes: nur anfordern, was die App wirklich braucht.
  • Session-Store für Produktion: Redis statt In-Memory, sonst gehen Logins beim Neustart verloren.

Ein offener Redirect in der Callback-Route verdient besondere Beachtung. Leiten Sie nach dem Login nur auf interne, fest definierte Pfade weiter, niemals auf eine URL aus einem Query-Parameter, den der Nutzer beeinflussen kann. Sonst lässt sich Ihre vertrauenswürdige Domain als Sprungbrett für Phishing missbrauchen. Prüfen Sie Ziel-URLs gegen eine Allowlist oder erlauben Sie ausschließlich relative Pfade. Dieser eine Punkt schließt eine Lücke, die selbst in großen Anwendungen immer wieder auftaucht.

Häufige Fragen zu OAuth mit PKCE

Brauche ich PKCE auch für eine klassische Server-App mit Client-Secret?

Ja. OAuth 2.1 verlangt PKCE für jeden Authorization Code Flow, auch für vertrauliche Clients mit Secret. PKCE und Client-Secret schützen gegen unterschiedliche Angriffe und ergänzen sich. Viele Provider erzwingen die Challenge inzwischen ohnehin.

Worin unterscheiden sich Access Token und ID Token?

Der Access Token autorisiert Ihre App gegenüber APIs und ist für die Client-App undurchsichtig. Der ID Token belegt die Identität des Nutzers und ist ein JWT, das Ihre App selbst auswertet und validiert. Verwenden Sie den ID Token nie als Zugriffstoken für APIs.

Ist OAuth 2.1 schon ein verabschiedeter Standard?

OAuth 2.1 liegt als IETF-Entwurf vor und konsolidiert bestehende RFCs sowie Best-Practice-Dokumente. Die zentralen Vorgaben wie PKCE-Pflicht und Wegfall des Implicit Flow setzen große Anbieter bereits um. Sie können sicher danach entwickeln, auch wenn die finale RFC-Nummer noch aussteht.

Wo speichere ich Tokens am sichersten?

In einer server-seitigen Session, niemals im Local Storage des Browsers. Local Storage ist für jedes JavaScript lesbar und damit ein leichtes Ziel für XSS. Das Session-Cookie sichern Sie mit httpOnly, secure und sameSite=lax ab.

Warum erhalte ich keinen Refresh Token?

Viele Provider liefern Refresh Tokens nur auf ausdrückliche Anforderung. Bei Google ergänzen Sie access_type=offline und oft prompt=consent im Authorization Request. Ohne diese Parameter erhalten Sie nur ein kurzlebiges Access Token.

Kann ich denselben Code für Microsoft oder Okta nutzen?

Ja. Der Flow ist standardisiert. Sie tauschen nur Authorization Endpoint, Token Endpoint und gegebenenfalls die Scopes aus. Mit openid-client und OIDC-Discovery genügt sogar die Issuer-URL, der Rest wird automatisch geladen.

Wie teste ich den Flow ohne echten Provider?

Nutzen Sie einen lokalen Mock-OIDC-Server oder einen kostenlosen Entwickler-Tenant. Für automatisierte Tests eignen sich Bibliotheken, die einen OIDC-Provider in einem Container starten. So testen Sie State-, Verifier- und Fehlerpfade reproduzierbar.

Weiterführende Spezifikationen und offizielle Quellen: OAuth 2.0 (RFC 6749), PKCE (RFC 7636), OAuth 2.1 Übersicht, OpenID Connect Core 1.0 und die openid-client Dokumentation.