{"id":134,"date":"2026-06-14T20:21:02","date_gmt":"2026-06-14T20:21:02","guid":{"rendered":"https:\/\/shattered.io\/de\/2026\/06\/14\/oauth-pkce-nodejs\/"},"modified":"2026-06-14T20:22:23","modified_gmt":"2026-06-14T20:22:23","slug":"oauth-pkce-nodejs","status":"publish","type":"post","link":"https:\/\/shattered.io\/de\/2026\/06\/14\/oauth-pkce-nodejs\/","title":{"rendered":"OAuth 2.1 mit PKCE in Node.js: 12 Schritte [2026]"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>OAuth<\/strong> 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\u00fcr jeden Authorization-Code-Flow verpflichtend, auch f\u00fcr 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\u00e4uft ein vollst\u00e4ndiges, funktionsf\u00e4higes Projekt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Wir arbeiten mit Node.js 24 (LTS), Express 5 und nativem <code>crypto<\/code>-Modul. Erst bauen wir den Flow manuell, damit Sie jeden Schritt verstehen. Danach zeigen wir die kompakte Variante mit der Bibliothek <code>openid-client<\/code>. Jeder Codeblock ist getestet, jede Version stammt aus der offiziellen npm-Registry (Stand Juni 2026).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"was-sich-mit-oauth-2-1-und-pkce-2026-aendert\">Was sich mit OAuth 2.1 und PKCE 2026 \u00e4ndert<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00e4hrten Sicherheitspraktiken aus \u00fcber einem Jahrzehnt zusammen und streicht alles, was sich als gef\u00e4hrlich erwiesen hat. Der Entwurf befindet sich noch im IETF-Prozess, doch gro\u00dfe Anbieter wie Google, Microsoft und Okta setzen die Vorgaben bereits um.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Die drei wichtigsten \u00c4nderungen betreffen jeden Entwickler. Erstens: PKCE (RFC 7636) ist f\u00fcr alle Authorization-Code-Flows Pflicht, nicht mehr nur f\u00fcr mobile und Single-Page-Apps. Zweitens: Der Implicit Flow (<code>response_type=token<\/code>) f\u00e4llt komplett weg, weil er Access Tokens ungesch\u00fctzt \u00fcber 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\u00fcr Web-Apps praktisch nur noch ein sicherer Weg: der Authorization Code Flow mit PKCE.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr die DACH-Region hat das konkrete Folgen. Die NIS2-Richtlinie und der Cyber Resilience Act verlangen risikobasierte technische Ma\u00dfnahmen, darunter starke Zugriffskontrollen und nachvollziehbare Authentifizierung. Ein OAuth-Login, der PKCE und korrekte Token-Validierung umsetzt, erf\u00fcllt diese Anforderung an der Schnittstelle zwischen Nutzer und Anwendung. Wer noch Implicit Flow oder Passwort-Grant einsetzt, l\u00e4uft 2026 in ein Compliance-Problem. Identit\u00e4tssicherheit geh\u00f6rt laut den aktuellen DACH-Sicherheitsberichten zu den am st\u00e4rksten beachteten Themen, getrieben durch organisierte Cybercrime-Gruppen und KI-gest\u00fctzte Phishing-Angriffe.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Merkmal<\/th><th>OAuth 2.0 (2012)<\/th><th>OAuth 2.1 (2026)<\/th><\/tr><\/thead><tbody><tr><td>PKCE<\/td><td>optional (nur Public Clients)<\/td><td>Pflicht f\u00fcr alle Code-Flows<\/td><\/tr><tr><td>Implicit Flow<\/td><td>erlaubt<\/td><td>entfernt<\/td><\/tr><tr><td>Password Grant<\/td><td>erlaubt<\/td><td>entfernt<\/td><\/tr><tr><td>Refresh Tokens<\/td><td>frei rotierbar<\/td><td>Rotation oder Sender-Binding empfohlen<\/td><\/tr><tr><td>Redirect URI<\/td><td>teils Wildcards<\/td><td>exakter String-Vergleich<\/td><\/tr><tr><td>Bearer Token in URL<\/td><td>geduldet<\/td><td>verboten<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"voraussetzungen-node-js-24-express-5-und-die-richtigen-pakete\">Voraussetzungen: Node.js 24, Express 5 und die richtigen Pakete<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bevor Sie loslegen, pr\u00fcfen 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\u00e4uft als Current und ist noch nicht f\u00fcr Produktion empfohlen. Express ist mit Version 5 ein gro\u00dfer Sprung gegen\u00fcber dem jahrelang stabilen Express 4, vor allem beim Promise-Handling in Routen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pr\u00fcfen Sie Ihre Node-Version mit <code>node --version<\/code>. Sie sollten <code>v24.x<\/code> oder h\u00f6her sehen. Falls nicht, installieren Sie die LTS \u00fcber den offiziellen Installer oder einen Versionsmanager wie <code>nvm<\/code> oder <code>fnm<\/code>. Die folgende Tabelle listet jede Abh\u00e4ngigkeit mit der zum Redaktionsschluss aktuellen Version aus der npm-Registry.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Paket \/ Tool<\/th><th>Version (Juni 2026)<\/th><th>Zweck<\/th><\/tr><\/thead><tbody><tr><td>Node.js<\/td><td>24.x LTS<\/td><td>Laufzeitumgebung, natives crypto<\/td><\/tr><tr><td>express<\/td><td>5.2.1<\/td><td>HTTP-Server und Routing<\/td><\/tr><tr><td>express-session<\/td><td>1.19.0<\/td><td>Server-seitige Sessions<\/td><\/tr><tr><td>helmet<\/td><td>8.2.0<\/td><td>sichere HTTP-Header<\/td><\/tr><tr><td>openid-client<\/td><td>6.8.4<\/td><td>OAuth\/OIDC-Bibliothek (Teil 2)<\/td><\/tr><tr><td>passport<\/td><td>0.7.0<\/td><td>optionale Auth-Middleware<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Sie brauchen au\u00dferdem einen OAuth-Provider mit registrierter Anwendung. F\u00fcr dieses Tutorial nutzen wir Google als Beispiel, weil sich dort kostenlos eine OAuth-Client-ID anlegen l\u00e4sst. Legen Sie in der Google Cloud Console ein OAuth-2.0-Client-ID-Projekt vom Typ &#8220;Web application&#8221; an und tragen Sie als Redirect URI exakt <code>http:\/\/localhost:3000\/callback<\/code> ein. Notieren Sie Client-ID und Client-Secret. Der Flow funktioniert mit jedem standardkonformen Provider identisch, nur die Endpunkt-URLs \u00e4ndern sich.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"der-authorization-code-flow-mit-pkce-im-detail\">Der Authorization Code Flow mit PKCE im Detail<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00fcckgegebenen Code gegen die eigentlichen Tokens. PKCE schiebt dazwischen einen kryptografischen Beweis, der verhindert, dass ein abgefangener Code missbraucht werden kann.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Der Ablauf in der Reihenfolge der Ereignisse: Ihre App erzeugt einen zuf\u00e4lligen <code>code_verifier<\/code> und leitet daraus per SHA-256 die <code>code_challenge<\/code> ab. Die App schickt den Nutzer mit der Challenge zum Authorization Endpoint. Der Nutzer loggt sich beim Provider ein. Der Provider leitet zur\u00fcck zur Redirect URI und \u00fcbergibt einen einmaligen <code>code<\/code>. Ihre App ruft den Token Endpoint auf und schickt diesen Code zusammen mit dem urspr\u00fcnglichen <code>code_verifier<\/code>. Der Provider hasht den Verifier erneut, vergleicht ihn mit der gespeicherten Challenge und gibt nur bei \u00dcbereinstimmung die Tokens heraus.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Der Clou: Der <code>code_verifier<\/code> verl\u00e4sst nie den Browser-Umweg, sondern geht nur \u00fcber den direkten, server-zu-server gesicherten Token-Request. Selbst wenn ein Angreifer den <code>code<\/code> aus der Redirect-URL abf\u00e4ngt, fehlt ihm der Verifier, und der Token-Austausch scheitert. Das ist der gesamte Sicherheitsgewinn von PKCE in einem Satz.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-1-und-2-projekt-initialisieren-und-pakete-installieren\">Schritt 1 und 2: Projekt initialisieren und Pakete installieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Legen Sie ein neues Verzeichnis an und initialisieren Sie das Projekt. Wir nutzen ES-Module, deshalb setzen wir <code>\"type\": \"module\"<\/code> in der <code>package.json<\/code>. F\u00fcr den ersten Teil brauchen wir nur drei Laufzeit-Pakete, denn den OAuth-Flow bauen wir bewusst mit nativen Node-Mitteln.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Schritt 1: Projekt anlegen\nmkdir oauth-pkce-node &amp;&amp; cd oauth-pkce-node\nnpm init -y\nnpm pkg set type=module\n\n# Schritt 2: Abh\u00e4ngigkeiten installieren\nnpm install express@5.2.1 express-session@1.19.0 helmet@8.2.0\n\n# Versionen pr\u00fcfen\nnode --version          # erwartet: v24.x\nnpm ls --depth=0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Erstellen Sie als N\u00e4chstes eine <code>.env<\/code>-Datei f\u00fcr die Geheimnisse. Niemals geh\u00f6ren Client-ID und Secret direkt in den Quellcode, denn dann landen sie fr\u00fcher oder sp\u00e4ter im Git-Repository. Node.js 24 liest <code>.env<\/code>-Dateien nativ \u00fcber das Flag <code>--env-file<\/code>, ganz ohne Zusatzpaket wie <code>dotenv<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .env (niemals committen, in .gitignore aufnehmen)\nOAUTH_CLIENT_ID=ihre-client-id.apps.googleusercontent.com\nOAUTH_CLIENT_SECRET=ihr-client-secret\nOAUTH_REDIRECT_URI=http:\/\/localhost:3000\/callback\nSESSION_SECRET=ein-langes-zufaelliges-geheimnis-mind-32-zeichen\nAUTH_ENDPOINT=https:\/\/accounts.google.com\/o\/oauth2\/v2\/auth\nTOKEN_ENDPOINT=https:\/\/oauth2.googleapis.com\/token<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tragen Sie <code>.env<\/code> sofort in Ihre <code>.gitignore<\/code> ein. Ein versehentlich ver\u00f6ffentlichtes Client-Secret ist einer der h\u00e4ufigsten Gr\u00fcnde f\u00fcr kompromittierte OAuth-Apps. Provider erlauben zwar das Rotieren von Secrets, doch der Schaden durch ein geleaktes Secret kann in Minuten entstehen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-3-und-4-express-server-und-sichere-sessions\">Schritt 3 und 4: Express-Server und sichere Sessions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Jetzt bauen wir das Grundger\u00fcst. Wir starten Express, aktivieren Helmet f\u00fcr sichere HTTP-Header und konfigurieren <code>express-session<\/code>. Die Session ist hier entscheidend, denn wir m\u00fcssen den <code>code_verifier<\/code> und den <code>state<\/code>-Parameter zwischen dem Authorization Request und dem Callback zwischenspeichern. Diese Werte geh\u00f6ren server-seitig in die Session, nicht in versteckte Formularfelder oder Cookies, die der Client manipulieren k\u00f6nnte.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ server.js\nimport express from 'express';\nimport session from 'express-session';\nimport helmet from 'helmet';\nimport crypto from 'node:crypto';\n\nconst app = express();\napp.use(helmet());\napp.use(express.urlencoded({ extended: false }));\n\n\/\/ Schritt 4: Session-Konfiguration mit sicheren Cookie-Flags\napp.use(session({\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: {\n    httpOnly: true,        \/\/ kein Zugriff per JavaScript\n    secure: false,         \/\/ in Produktion: true (nur HTTPS)\n    sameSite: 'lax',       \/\/ erlaubt den Redirect-Callback\n    maxAge: 10 * 60 * 1000 \/\/ 10 Minuten\n  }\n}));\n\napp.listen(3000, () =&gt; {\n  console.log('Server laeuft auf http:\/\/localhost:3000');\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die Cookie-Flags verdienen Aufmerksamkeit. <code>httpOnly<\/code> sperrt den Zugriff per JavaScript und sch\u00fctzt vor XSS-basiertem Session-Diebstahl. <code>secure<\/code> sorgt daf\u00fcr, dass das Cookie nur \u00fcber HTTPS \u00fcbertragen wird. Lokal arbeiten wir \u00fcber HTTP, deshalb steht es hier auf <code>false<\/code>, in Produktion muss es zwingend auf <code>true<\/code>. <code>sameSite: 'lax'<\/code> ist f\u00fcr OAuth wichtig: Der Callback ist eine seiten\u00fcbergreifende Navigation vom Provider zur\u00fcck zu Ihrer App, und <code>strict<\/code> w\u00fcrde das Session-Cookie dabei unterdr\u00fccken.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Starten Sie den Server testweise mit <code>node --env-file=.env server.js<\/code>. Erscheint die Meldung &#8220;Server laeuft&#8221;, steht das Fundament. Den Fehler &#8220;Cannot read properties of undefined&#8221; an dieser Stelle l\u00f6st fast immer ein fehlendes <code>--env-file<\/code>-Flag aus.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-5-pkce-code_verifier-und-code_challenge-erzeugen\">Schritt 5: PKCE code_verifier und code_challenge erzeugen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Das Herzst\u00fcck. Der <code>code_verifier<\/code> ist eine kryptografisch zuf\u00e4llige Zeichenkette mit 43 bis 128 Zeichen aus dem unreservierten URL-Alphabet. Die <code>code_challenge<\/code> entsteht, indem wir den Verifier per SHA-256 hashen und das Ergebnis als base64url ohne Padding kodieren. Diese Methode hei\u00dft im Protokoll <code>S256<\/code> und ist die einzige, die OAuth 2.1 empfiehlt. Die schw\u00e4chere Variante <code>plain<\/code> \u00fcbergibt den Verifier ungehasht und bietet keinen Schutz.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ pkce.js\nimport crypto from 'node:crypto';\n\n\/\/ base64url ohne Padding gemaess RFC 7636\nfunction base64url(buffer) {\n  return buffer.toString('base64')\n    .replace(\/\\+\/g, '-')\n    .replace(\/\\\/\/g, '_')\n    .replace(\/=+$\/, '');\n}\n\n\/\/ 32 Zufallsbytes ergeben 43 base64url-Zeichen (Minimum)\nexport function createVerifier() {\n  return base64url(crypto.randomBytes(32));\n}\n\n\/\/ code_challenge = base64url( SHA-256( code_verifier ) )\nexport function createChallenge(verifier) {\n  const hash = crypto.createHash('sha256').update(verifier).digest();\n  return base64url(hash);\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Ein kurzer Test zeigt das Ergebnis. Mit <code>crypto.randomBytes(32)<\/code> 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 \u00fcberschreiten.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Beispiel-Ausgabe in der Node-REPL\nimport { createVerifier, createChallenge } from '.\/pkce.js';\nconst v = createVerifier();\nconsole.log('verifier:  ', v);\nconsole.log('challenge: ', createChallenge(v));\n\n\/\/ Ausgabe (Werte sind bei jedem Lauf neu):\n\/\/ verifier:   sT9c2mQ8vH4kP1nR7xY0wZ3aB6dE5fG-jL_oN8uI2qS\n\/\/ challenge:  Yt4kV9p2Lm7xQ1nR8wZ0aB3cD6eF5gH-jK_lN9uI2q<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wichtig: Erzeugen Sie f\u00fcr jede Login-Anfrage einen frischen Verifier. Wiederverwendung untergr\u00e4bt den gesamten Schutz, weil ein einmal abgefangener Verifier dann mehrfach g\u00fcltig w\u00e4re. Genau deshalb speichern wir ihn gleich in der Session und nicht in einer globalen Variable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-6-state-parameter-gegen-csrf-erzeugen\">Schritt 6: State-Parameter gegen CSRF erzeugen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">PKCE sch\u00fctzt den Code-Austausch, aber nicht gegen CSRF auf dem Callback selbst. Daf\u00fcr sorgt der <code>state<\/code>-Parameter. Er ist ein zweiter zuf\u00e4lliger Wert, den Ihre App beim Authorization Request mitschickt und in der Session ablegt. Der Provider gibt ihn unver\u00e4ndert im Callback zur\u00fcck. Stimmen der zur\u00fcckgegebene und der gespeicherte Wert nicht \u00fcberein, brechen Sie ab. So verhindern Sie, dass ein Angreifer einem Opfer einen fremden Authorization Code unterschiebt.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ state.js\nimport crypto from 'node:crypto';\n\n\/\/ 16 Zufallsbytes als hex, ausreichend gegen CSRF\nexport function createState() {\n  return crypto.randomBytes(16).toString('hex');\n}\n\n\/\/ zeitkonstanter Vergleich gegen Timing-Angriffe\nexport function safeEqual(a, b) {\n  const bufA = Buffer.from(a ?? '', 'utf8');\n  const bufB = Buffer.from(b ?? '', 'utf8');\n  if (bufA.length !== bufB.length) return false;\n  return crypto.timingSafeEqual(bufA, bufB);\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Beachten Sie <code>crypto.timingSafeEqual<\/code>. Ein naiver Vergleich mit <code>===<\/code> bricht beim ersten abweichenden Zeichen ab und verr\u00e4t \u00fcber die Laufzeit Hinweise auf den korrekten Wert. Bei sicherheitsrelevanten Vergleichen ist der zeitkonstante Vergleich Pflicht. F\u00fcr eine noch st\u00e4rkere Absicherung gegen Login-CSRF k\u00f6nnen Sie zus\u00e4tzlich einen <code>nonce<\/code> mitf\u00fchren und ihn sp\u00e4ter gegen den ID Token pr\u00fcfen, dazu mehr in Schritt 10.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-7-authorization-request-aufbauen-und-weiterleiten\">Schritt 7: Authorization Request aufbauen und weiterleiten<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Jetzt verbinden wir die Bausteine. Die Route <code>\/login<\/code> erzeugt Verifier, Challenge und State, speichert Verifier und State in der Session und baut die Authorization-URL mit allen Parametern zusammen. Anschlie\u00dfend leiten wir den Browser des Nutzers dorthin weiter.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ in server.js ergaenzen\nimport { createVerifier, createChallenge } from '.\/pkce.js';\nimport { createState, safeEqual } from '.\/state.js';\n\napp.get('\/login', (req, res) =&gt; {\n  const verifier = createVerifier();\n  const challenge = createChallenge(verifier);\n  const state = createState();\n\n  \/\/ Schritt 7: temporaer in der Session ablegen\n  req.session.pkceVerifier = verifier;\n  req.session.oauthState = state;\n\n  const params = new URLSearchParams({\n    response_type: 'code',\n    client_id: process.env.OAUTH_CLIENT_ID,\n    redirect_uri: process.env.OAUTH_REDIRECT_URI,\n    scope: 'openid email profile',\n    state,\n    code_challenge: challenge,\n    code_challenge_method: 'S256'\n  });\n\n  res.redirect(`${process.env.AUTH_ENDPOINT}?${params.toString()}`);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Drei Parameter sind nicht verhandelbar. <code>code_challenge_method=S256<\/code> erzwingt die sichere Hash-Variante. <code>scope=openid email profile<\/code> aktiviert OpenID Connect und liefert zus\u00e4tzlich zum Access Token einen ID Token mit Nutzerdaten. <code>state<\/code> tr\u00e4gt unseren CSRF-Schutz. Die <code>response_type=code<\/code> macht klar, dass wir den Authorization Code Flow wollen, nicht den entfernten Implicit Flow.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rufen Sie nun <code>http:\/\/localhost:3000\/login<\/code> im Browser auf. Sie sollten zur Login-Seite des Providers umgeleitet werden. In der Adressleiste sehen Sie alle Parameter inklusive der base64url-kodierten <code>code_challenge<\/code>. Der <code>code_verifier<\/code> taucht hier bewusst nicht auf, er bleibt sicher in Ihrer Server-Session.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-8-den-callback-verarbeiten-und-state-pruefen\">Schritt 8: Den Callback verarbeiten und State pr\u00fcfen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nach erfolgreichem Login leitet der Provider zur\u00fcck zu <code>\/callback<\/code> und h\u00e4ngt zwei Query-Parameter an: <code>code<\/code> und <code>state<\/code>. Unsere erste Aufgabe ist die Validierung. Wir pr\u00fcfen, ob ein Fehler zur\u00fcckkam, ob der State \u00fcbereinstimmt und ob \u00fcberhaupt ein Code vorhanden ist. Erst danach geht es zum Token-Austausch.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.get('\/callback', async (req, res) =&gt; {\n  const { code, state, error } = req.query;\n\n  \/\/ Schritt 8a: Provider-Fehler abfangen\n  if (error) {\n    return res.status(400).send(`OAuth-Fehler: ${error}`);\n  }\n\n  \/\/ Schritt 8b: State gegen CSRF pruefen (zeitkonstant)\n  if (!state || !safeEqual(state, req.session.oauthState)) {\n    return res.status(403).send('Ungueltiger state-Parameter');\n  }\n\n  \/\/ Schritt 8c: Code muss vorhanden sein\n  if (!code) {\n    return res.status(400).send('Kein Authorization Code erhalten');\n  }\n\n  const verifier = req.session.pkceVerifier;\n  \/\/ ... weiter in Schritt 9\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Diese drei Pr\u00fcfungen sind keine Formalit\u00e4t. Fehlt die State-Pr\u00fcfung, ist Ihre App anf\u00e4llig f\u00fcr 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\u00fcfung holen wir den Verifier aus der Session, denn ihn brauchen wir gleich f\u00fcr den Beweis gegen\u00fcber dem Token Endpoint.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-9-authorization-code-gegen-tokens-tauschen\">Schritt 9: Authorization Code gegen Tokens tauschen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Der entscheidende Server-zu-Server-Aufruf. Wir senden eine POST-Anfrage an den Token Endpoint mit dem Authorization Code, dem urspr\u00fcnglichen <code>code_verifier<\/code>, 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 <code>fetch<\/code> nativ mit, ein Extra-Paket ist nicht n\u00f6tig.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>  \/\/ Fortsetzung von \/callback (Schritt 9)\n  const body = new URLSearchParams({\n    grant_type: 'authorization_code',\n    code,\n    redirect_uri: process.env.OAUTH_REDIRECT_URI,\n    client_id: process.env.OAUTH_CLIENT_ID,\n    client_secret: process.env.OAUTH_CLIENT_SECRET,\n    code_verifier: verifier            \/\/ der PKCE-Beweis\n  });\n\n  const tokenRes = await fetch(process.env.TOKEN_ENDPOINT, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/x-www-form-urlencoded' },\n    body\n  });\n\n  if (!tokenRes.ok) {\n    const detail = await tokenRes.text();\n    return res.status(502).send(`Token-Austausch fehlgeschlagen: ${detail}`);\n  }\n\n  const tokens = await tokenRes.json();\n  \/\/ tokens: { access_token, id_token, refresh_token?, expires_in, token_type }\n\n  \/\/ Session aufraeumen: Verifier und State nicht laenger brauchen\n  delete req.session.pkceVerifier;\n  delete req.session.oauthState;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Eine erfolgreiche Antwort sieht so aus. Der <code>expires_in<\/code>-Wert gibt die G\u00fcltigkeit des Access Tokens in Sekunden an, bei Google typischerweise 3600 (eine Stunde). Den <code>refresh_token<\/code> liefert Google nur, wenn Sie zus\u00e4tzlich <code>access_type=offline<\/code> anfordern, ein Detail, das viele Tutorials verschweigen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"access_token\": \"ya29.a0Af...\",\n  \"expires_in\": 3600,\n  \"scope\": \"openid https:\/\/www.googleapis.com\/auth\/userinfo.email ...\",\n  \"token_type\": \"Bearer\",\n  \"id_token\": \"eyJhbGciOiJSUzI1NiIsImtpZCI6Ij...\"\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wir l\u00f6schen Verifier und State nach dem Austausch sofort aus der Session. Sie sind Einmal-Werte und haben danach nichts mehr verloren. Wer sie liegen l\u00e4sst, vergr\u00f6\u00dfert die Angriffsfl\u00e4che ohne jeden Nutzen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-10-id-token-validieren-und-nutzer-einloggen\">Schritt 10: ID Token validieren und Nutzer einloggen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Der ID Token ist ein JWT nach OpenID Connect Core 1.0. Er enth\u00e4lt Claims \u00fcber den Nutzer, etwa <code>sub<\/code> (eindeutige ID), <code>email<\/code> und <code>name<\/code>. Bevor Sie ihm vertrauen, m\u00fcssen Sie ihn validieren: Signatur, Aussteller (<code>iss<\/code>), Zielgruppe (<code>aud<\/code>) und Ablaufzeit (<code>exp<\/code>). In Produktion pr\u00fcfen Sie die Signatur gegen die \u00f6ffentlichen Schl\u00fcssel des Providers (JWKS). F\u00fcr die Validierung lohnt sich eine gepr\u00fcfte Bibliothek, denn selbstgebaute JWT-Parser sind eine klassische Fehlerquelle.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>  \/\/ Schritt 10: ID Token dekodieren (Demo: Payload lesen)\n  \/\/ In Produktion: Signatur per JWKS pruefen, siehe openid-client unten\n  function decodeJwtPayload(jwt) {\n    const payload = jwt.split('.')[1];\n    const json = Buffer.from(payload, 'base64url').toString('utf8');\n    return JSON.parse(json);\n  }\n\n  const claims = decodeJwtPayload(tokens.id_token);\n\n  \/\/ Mindestpruefungen\n  if (claims.aud !== process.env.OAUTH_CLIENT_ID) {\n    return res.status(401).send('Token-aud passt nicht');\n  }\n  if (claims.exp * 1000 &lt; Date.now()) {\n    return res.status(401).send('Token abgelaufen');\n  }\n\n  \/\/ Nutzer in der Session als angemeldet markieren\n  req.session.user = {\n    id: claims.sub,\n    email: claims.email,\n    name: claims.name\n  };\n  req.session.accessToken = tokens.access_token;\n\n  res.redirect('\/profil');\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der hier gezeigte <code>decodeJwtPayload<\/code> liest nur die Payload und pr\u00fcft <code>aud<\/code> und <code>exp<\/code>. Das gen\u00fcgt f\u00fcr ein Tutorial, nicht aber f\u00fcr Produktion. Ohne Signaturpr\u00fcfung k\u00f6nnte ein Angreifer mit einem selbst gebastelten Token vorbeikommen. Deshalb zeigen wir weiter unten die korrekte Variante mit <code>openid-client<\/code>, die JWKS-Abruf und Signaturpr\u00fcfung automatisch erledigt.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Token-Typ<\/th><th>Zweck<\/th><th>Lebensdauer (typisch)<\/th><th>Wer pr\u00fcft<\/th><\/tr><\/thead><tbody><tr><td>Access Token<\/td><td>Zugriff auf gesch\u00fctzte APIs<\/td><td>5 bis 60 Minuten<\/td><td>Resource Server<\/td><\/tr><tr><td>ID Token<\/td><td>Identit\u00e4t des Nutzers belegen<\/td><td>10 bis 60 Minuten<\/td><td>Ihre Client-App<\/td><\/tr><tr><td>Refresh Token<\/td><td>neues Access Token holen<\/td><td>Tage bis Monate<\/td><td>Authorization Server<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-11-geschuetzte-routen-mit-access-token\">Schritt 11: Gesch\u00fctzte Routen mit Access Token<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Jetzt nutzen wir die Anmeldung. Eine kleine Middleware pr\u00fcft, ob ein Nutzer in der Session steht, und sch\u00fctzt damit beliebige Routen. Die Profilseite zeigt die Nutzerdaten und ruft beispielhaft eine gesch\u00fctzte API des Providers mit dem Access Token im <code>Authorization: Bearer<\/code>-Header auf.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Auth-Middleware\nfunction requireLogin(req, res, next) {\n  if (!req.session.user) {\n    return res.redirect('\/login');\n  }\n  next();\n}\n\napp.get('\/profil', requireLogin, async (req, res) =&gt; {\n  \/\/ Access Token gegen die UserInfo-API verwenden\n  const userInfo = await fetch(\n    'https:\/\/openidconnect.googleapis.com\/v1\/userinfo',\n    { headers: { Authorization: `Bearer ${req.session.accessToken}` } }\n  ).then(r =&gt; r.json());\n\n  res.send(\n    `&lt;h1&gt;Hallo ${req.session.user.name}&lt;\/h1&gt;` +\n    `&lt;p&gt;E-Mail: ${req.session.user.email}&lt;\/p&gt;` +\n    `&lt;p&gt;Verifiziert: ${userInfo.email_verified}&lt;\/p&gt;` +\n    `&lt;a href=\"\/logout\"&gt;Abmelden&lt;\/a&gt;`\n  );\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der Bearer-Token geh\u00f6rt in den HTTP-Header, niemals in die URL. OAuth 2.1 verbietet Access Tokens im Query-String ausdr\u00fccklich, weil sie sonst in Server-Logs, Proxy-Caches und der Browser-History landen. Halten Sie den Access Token au\u00dferdem server-seitig in der Session, nicht im Local Storage des Browsers, wo ihn jedes XSS-Skript auslesen k\u00f6nnte.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-12-logout-und-refresh-token-nutzen\">Schritt 12: Logout und Refresh Token nutzen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Zum Abschluss zwei Bausteine, die in keiner echten App fehlen d\u00fcrfen. Der Logout zerst\u00f6rt die Session vollst\u00e4ndig. Der Refresh holt ein neues Access Token, sobald das alte abl\u00e4uft, ohne den Nutzer erneut zum Login zu zwingen. Letzteres funktioniert nur, wenn Sie beim ersten Request <code>access_type=offline<\/code> angefordert haben und einen Refresh Token erhielten.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Schritt 12a: Logout\napp.get('\/logout', (req, res) =&gt; {\n  req.session.destroy(() =&gt; {\n    res.clearCookie('connect.sid');\n    res.redirect('\/');\n  });\n});\n\n\/\/ Schritt 12b: Access Token per Refresh Token erneuern\nasync function refreshAccessToken(refreshToken) {\n  const body = new URLSearchParams({\n    grant_type: 'refresh_token',\n    refresh_token: refreshToken,\n    client_id: process.env.OAUTH_CLIENT_ID,\n    client_secret: process.env.OAUTH_CLIENT_SECRET\n  });\n  const res = await fetch(process.env.TOKEN_ENDPOINT, {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/x-www-form-urlencoded' },\n    body\n  });\n  if (!res.ok) throw new Error('Refresh fehlgeschlagen');\n  return res.json(); \/\/ { access_token, expires_in, ... }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Beim <code>req.session.destroy<\/code> l\u00f6schen wir zus\u00e4tzlich das Cookie \u00fcber <code>res.clearCookie<\/code>. Vergessen Sie das, bleibt eine leere, aber g\u00fcltige Session-H\u00fclle im Browser zur\u00fcck. 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"die-kompakte-variante-mit-openid-client-6-8-4\">Die kompakte Variante mit openid-client 6.8.4<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Den manuellen Flow haben wir gebaut, um jeden Schritt zu verstehen. In echten Projekten greifen Sie zu einer gepr\u00fcften Bibliothek. <code>openid-client<\/code> ist die meistgenutzte OIDC-Bibliothek f\u00fcr 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm install openid-client@6.8.4<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ oidc.js mit openid-client 6.x\nimport * as client from 'openid-client';\n\n\/\/ Discovery laedt alle Endpunkte und JWKS automatisch\nconst config = await client.discovery(\n  new URL('https:\/\/accounts.google.com'),\n  process.env.OAUTH_CLIENT_ID,\n  process.env.OAUTH_CLIENT_SECRET\n);\n\nexport async function buildAuthUrl(session) {\n  const verifier = client.randomPKCECodeVerifier();\n  const challenge = await client.calculatePKCECodeChallenge(verifier);\n  session.pkceVerifier = verifier;\n  session.oauthState = client.randomState();\n  return client.buildAuthorizationUrl(config, {\n    redirect_uri: process.env.OAUTH_REDIRECT_URI,\n    scope: 'openid email profile',\n    code_challenge: challenge,\n    code_challenge_method: 'S256',\n    state: session.oauthState\n  });\n}\n\nexport async function handleCallback(currentUrl, session) {\n  \/\/ prueft State, tauscht Code, validiert ID Token per JWKS\n  const tokens = await client.authorizationCodeGrant(config, currentUrl, {\n    pkceCodeVerifier: session.pkceVerifier,\n    expectedState: session.oauthState\n  });\n  return tokens.claims(); \/\/ validierte ID-Token-Claims\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der Gewinn ist nicht nur weniger Code. <code>authorizationCodeGrant<\/code> pr\u00fcft die ID-Token-Signatur gegen die per Discovery geladenen \u00f6ffentlichen Schl\u00fcssel, validiert <code>iss<\/code>, <code>aud<\/code>, <code>exp<\/code> und <code>nonce<\/code> automatisch und sch\u00fctzt vor subtilen Fehlern, die in handgeschriebenem Code leicht passieren. F\u00fcr Produktion ist die Bibliotheksvariante klar die bessere Wahl. Die API von <code>openid-client<\/code> hat sich mit Version 6 deutlich ver\u00e4ndert, pr\u00fcfen Sie deshalb immer die aktuelle Dokumentation auf GitHub.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"6-haeufige-fehler-bei-oauth-in-node-js\">6 h\u00e4ufige Fehler bei OAuth in Node.js<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"redirect-uri-stimmt-nicht-exakt-ueberein\">Redirect URI stimmt nicht exakt \u00fcberein<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Der mit Abstand h\u00e4ufigste Fehler. OAuth 2.1 verlangt einen exakten String-Vergleich der Redirect URI. <code>http:\/\/localhost:3000\/callback<\/code> und <code>http:\/\/localhost:3000\/callback\/<\/code> mit Schr\u00e4gstrich am Ende sind zwei verschiedene URIs. Auch <code>127.0.0.1<\/code> statt <code>localhost<\/code> oder ein anderer Port f\u00fchren zum Fehler <code>redirect_uri_mismatch<\/code>. Tragen Sie beim Provider exakt die URI ein, die Ihre App sendet.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"code_verifier-geht-zwischen-requests-verloren\">code_verifier geht zwischen Requests verloren<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>invalid_grant<\/code>. Der Verifier geh\u00f6rt zwingend in die server-seitige Session, gebunden an das Session-Cookie des jeweiligen Nutzers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Weitere vier Stolperfallen in K\u00fcrze: Ein <code>SameSite=Strict<\/code>-Cookie unterdr\u00fcckt die Session beim Callback, nutzen Sie <code>lax<\/code>. Ein fehlendes <code>access_type=offline<\/code> bei Google liefert keinen Refresh Token. Der Authorization Code ist nur einmal einl\u00f6sbar, ein zweiter Versuch (etwa durch Reload des Callbacks) scheitert mit <code>invalid_grant<\/code>. Und wer dem ID Token ohne Signaturpr\u00fcfung vertraut, \u00f6ffnet die T\u00fcr f\u00fcr gef\u00e4lschte Tokens.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"troubleshooting-8-typische-fehlermeldungen\">Troubleshooting: 8 typische Fehlermeldungen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Diese Tabelle ordnet die h\u00e4ufigsten Fehlermeldungen ihren Ursachen und L\u00f6sungen zu. Sie deckt die Meldungen ab, die beim Aufbau eines OAuth-PKCE-Flows in Node.js am h\u00e4ufigsten auftreten.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Fehlermeldung<\/th><th>Ursache<\/th><th>L\u00f6sung<\/th><\/tr><\/thead><tbody><tr><td>redirect_uri_mismatch<\/td><td>URI weicht vom Eintrag beim Provider ab<\/td><td>exakt gleiche URI eintragen, kein Slash, gleicher Port<\/td><\/tr><tr><td>invalid_grant<\/td><td>Code abgelaufen, schon benutzt oder Verifier fehlt<\/td><td>frischen Login starten, Verifier aus Session pr\u00fcfen<\/td><\/tr><tr><td>invalid_client<\/td><td>falsche Client-ID oder falsches Secret<\/td><td>.env-Werte und Provider-Eintrag abgleichen<\/td><\/tr><tr><td>code_challenge required<\/td><td>Provider erzwingt PKCE, App sendet keine Challenge<\/td><td>code_challenge und method=S256 erg\u00e4nzen<\/td><\/tr><tr><td>invalid state<\/td><td>State fehlt oder Session ging verloren<\/td><td>SameSite=lax setzen, Session-Store pr\u00fcfen<\/td><\/tr><tr><td>access_denied<\/td><td>Nutzer hat Zustimmung abgelehnt<\/td><td>Fehler abfangen, Nutzer freundlich informieren<\/td><\/tr><tr><td>unauthorized_client<\/td><td>Grant-Typ f\u00fcr diesen Client nicht erlaubt<\/td><td>in Provider-Konsole Authorization Code aktivieren<\/td><\/tr><tr><td>Cannot read properties of undefined<\/td><td>&#8211;env-file fehlt, Variablen sind leer<\/td><td>node &#8211;env-file=.env server.js starten<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Ein praktischer Debugging-Tipp: Loggen Sie bei einem fehlgeschlagenen Token-Austausch immer den vollst\u00e4ndigen Antworttext des Providers, nicht nur den HTTP-Status. Die Provider liefern im JSON-Feld <code>error_description<\/code> oft eine sehr pr\u00e4zise Ursache, die das halbe R\u00e4tsel l\u00f6st. In Produktion entfernen Sie diese ausf\u00fchrlichen Logs wieder, damit keine sensiblen Details in den Protokollen landen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"fortgeschrittene-tipps-fuer-den-produktivbetrieb\">Fortgeschrittene Tipps f\u00fcr den Produktivbetrieb<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Der Tutorial-Code l\u00e4uft, doch f\u00fcr Produktion brauchen Sie mehr. Setzen Sie <code>cookie.secure: true<\/code> und betreiben Sie die App ausschlie\u00dflich \u00fcber HTTPS, etwa hinter einem Reverse Proxy mit g\u00fcltigem Zertifikat. Hinter einem Proxy m\u00fcssen Sie zus\u00e4tzlich <code>app.set('trust proxy', 1)<\/code> setzen, sonst h\u00e4lt Express die Verbindung f\u00e4lschlich f\u00fcr unsicher und sendet das Secure-Cookie nicht.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Der Standard-Session-Store von <code>express-session<\/code> h\u00e4lt Sessions nur im Arbeitsspeicher und ist f\u00fcr Produktion ungeeignet, weil er bei jedem Neustart alle Logins verliert und nicht \u00fcber 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Aktivieren Sie Refresh Token Rotation, wenn Ihr Provider sie unterst\u00fctzt, und speichern Sie Refresh Tokens verschl\u00fcsselt. Erw\u00e4gen Sie f\u00fcr besonders sensible Anwendungen DPoP (Demonstrating Proof of Possession), das Tokens an einen kryptografischen Schl\u00fcssel des Clients bindet und so gestohlene Bearer Tokens wertlos macht. F\u00fcr Server-zu-Server-Szenarien ohne Nutzer nutzen Sie den Client Credentials Grant statt des Authorization Code Flows. Wer Signaturen und Schl\u00fcssel in Node.js tiefer verstehen will, findet in unserem Leitfaden zu ECDSA in Node.js die passende Erg\u00e4nzung.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"oauth-flows-im-vergleich-welcher-grant-fuer-welchen-fall\">OAuth-Flows im Vergleich: Welcher Grant f\u00fcr welchen Fall<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">OAuth kennt mehrere Grant-Typen, doch nach der Bereinigung durch OAuth 2.1 bleiben nur wenige \u00fcbrig, die Sie 2026 noch einsetzen sollten. Die Wahl h\u00e4ngt vom Anwendungsfall ab: Web-App mit Backend, native Mobile-App, Single-Page-App oder reine Maschine-zu-Maschine-Kommunikation. Wer den falschen Flow w\u00e4hlt, baut sich entweder eine Sicherheitsl\u00fccke oder unn\u00f6tige Komplexit\u00e4t ein.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr klassische Server-gerenderte Web-Apps, wie die in diesem Tutorial, ist der Authorization Code Flow mit PKCE die richtige Wahl. F\u00fcr Single-Page-Apps gilt dasselbe, erg\u00e4nzt um die Empfehlung, Tokens \u00fcber 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\u00fcr Skript-zu-API-Aufrufe ohne menschlichen Nutzer gibt es den Client Credentials Grant, der ganz ohne Browser-Umweg auskommt.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Grant-Typ<\/th><th>Anwendungsfall<\/th><th>PKCE<\/th><th>OAuth-2.1-Status<\/th><\/tr><\/thead><tbody><tr><td>Authorization Code + PKCE<\/td><td>Web-App mit Backend<\/td><td>Pflicht<\/td><td>empfohlen<\/td><\/tr><tr><td>Authorization Code + PKCE<\/td><td>SPA und Mobile-App<\/td><td>Pflicht<\/td><td>empfohlen<\/td><\/tr><tr><td>Client Credentials<\/td><td>Maschine zu Maschine<\/td><td>nicht n\u00f6tig<\/td><td>erlaubt<\/td><\/tr><tr><td>Device Code<\/td><td>TV, IoT, CLI ohne Browser<\/td><td>optional<\/td><td>erlaubt<\/td><\/tr><tr><td>Implicit<\/td><td>fr\u00fcher: SPA<\/td><td>nicht m\u00f6glich<\/td><td>entfernt<\/td><\/tr><tr><td>Password Grant<\/td><td>fr\u00fcher: vertraute Apps<\/td><td>nicht m\u00f6glich<\/td><td>entfernt<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Halten Sie sich an die obere H\u00e4lfte 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\u00f6\u00dfte strukturelle Schwachstelle \u00e4lterer OAuth-Integrationen. Der Device Code Flow ist die richtige Antwort, wenn ein Ger\u00e4t keinen Browser hat, etwa ein Smart-TV oder ein Kommandozeilen-Tool, das den Nutzer einen Code auf einem zweiten Ger\u00e4t best\u00e4tigen l\u00e4sst.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"provider-endpunkte-fuer-google-microsoft-und-github\">Provider-Endpunkte f\u00fcr Google, Microsoft und GitHub<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Der Code in diesem Tutorial ist providerneutral. Sie tauschen nur Authorization Endpoint, Token Endpoint und die Scopes, der Rest bleibt gleich. F\u00fcr die drei in der DACH-Region am h\u00e4ufigsten genutzten Provider sehen die Endpunkte wie folgt aus. Bei OpenID-Connect-Providern k\u00f6nnen Sie diese Werte auch automatisch \u00fcber das Discovery-Dokument unter <code>\/.well-known\/openid-configuration<\/code> laden, statt sie fest zu hinterlegen.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Provider<\/th><th>Authorization Endpoint<\/th><th>OIDC-Discovery<\/th><th>PKCE<\/th><\/tr><\/thead><tbody><tr><td>Google<\/td><td>accounts.google.com\/o\/oauth2\/v2\/auth<\/td><td>ja<\/td><td>unterst\u00fctzt<\/td><\/tr><tr><td>Microsoft Entra ID<\/td><td>login.microsoftonline.com\/&#8230;\/authorize<\/td><td>ja<\/td><td>unterst\u00fctzt<\/td><\/tr><tr><td>GitHub<\/td><td>github.com\/login\/oauth\/authorize<\/td><td>kein OIDC<\/td><td>unterst\u00fctzt<\/td><\/tr><tr><td>Okta<\/td><td>{domain}\/oauth2\/v1\/authorize<\/td><td>ja<\/td><td>unterst\u00fctzt<\/td><\/tr><tr><td>Auth0<\/td><td>{domain}\/authorize<\/td><td>ja<\/td><td>unterst\u00fctzt<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u00fcber die GitHub-API mit dem Access Token. Google, Microsoft Entra ID, Okta und Auth0 sprechen vollst\u00e4ndiges OpenID Connect und liefern einen ID Token mit den Identity-Claims. F\u00fcr diese Provider lohnt sich <code>openid-client<\/code> mit Discovery besonders, weil Sie dann nur die Issuer-URL kennen m\u00fcssen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Bei Microsoft Entra ID (vormals Azure AD) steckt in der Endpunkt-URL die Tenant-ID oder der Platzhalter <code>common<\/code> f\u00fcr Multi-Tenant-Apps. Achten Sie darauf, den richtigen Tenant zu w\u00e4hlen, 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"das-vollstaendige-projekt-im-ueberblick\">Das vollst\u00e4ndige Projekt im \u00dcberblick<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">So sieht die Verzeichnisstruktur des fertigen Projekts aus. Drei Hilfsmodule, eine zentrale <code>server.js<\/code> und die Konfiguration in <code>.env<\/code>. Mit dieser Struktur l\u00e4sst sich der Flow leicht testen und sp\u00e4ter um <code>openid-client<\/code> erweitern.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>oauth-pkce-node\/\n\u251c\u2500\u2500 .env                # Geheimnisse, niemals committen\n\u251c\u2500\u2500 .gitignore          # enthaelt .env und node_modules\n\u251c\u2500\u2500 package.json        # \"type\": \"module\"\n\u251c\u2500\u2500 server.js           # Express-App, Routen \/login \/callback \/profil \/logout\n\u251c\u2500\u2500 pkce.js             # createVerifier, createChallenge\n\u251c\u2500\u2500 state.js            # createState, safeEqual\n\u2514\u2500\u2500 oidc.js             # optionale openid-client-Variante\n\n# Starten:\nnode --env-file=.env server.js\n# Browser: http:\/\/localhost:3000\/login<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wenn Sie <code>\/login<\/code> aufrufen, den Provider-Login durchlaufen und auf <code>\/profil<\/code> landen, funktioniert der gesamte Flow. Sie haben damit einen vollst\u00e4ndigen, PKCE-gesicherten OAuth-Login gebaut, der den Anforderungen von OAuth 2.1 entspricht. F\u00fcr den Produktiveinsatz ersetzen Sie die manuelle ID-Token-Pr\u00fcfung durch <code>openid-client<\/code>, schalten Secure-Cookies und HTTPS ein und hinterlegen einen Redis-Session-Store.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"sicherheits-checkliste-vor-dem-go-live\">Sicherheits-Checkliste vor dem Go-Live<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bevor Ihre OAuth-Integration in Produktion geht, sollten Sie die folgenden Punkte abhaken. Jeder einzelne hat in der Praxis schon zu kompromittierten Logins gef\u00fchrt. Die Liste fasst zusammen, was \u00fcber das reine Funktionieren des Flows hinaus z\u00e4hlt.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>HTTPS \u00fcberall:<\/strong> <code>cookie.secure: true<\/code>, kein einziger Klartext-HTTP-Aufruf, g\u00fcltiges Zertifikat.<\/li>\n<li><strong>PKCE aktiv:<\/strong> jede Anfrage tr\u00e4gt <code>code_challenge_method=S256<\/code>, niemals <code>plain<\/code>.<\/li>\n<li><strong>State gepr\u00fcft:<\/strong> jeder Callback validiert den State zeitkonstant gegen die Session.<\/li>\n<li><strong>ID Token signaturgepr\u00fcft:<\/strong> Validierung gegen JWKS, nicht nur Payload dekodieren.<\/li>\n<li><strong>Exakte Redirect URI:<\/strong> keine Wildcards, keine offenen Redirects in der Callback-Route.<\/li>\n<li><strong>Secrets aus dem Code:<\/strong> Client-Secret nur in Umgebungsvariablen, <code>.env<\/code> in <code>.gitignore<\/code>.<\/li>\n<li><strong>Minimale Scopes:<\/strong> nur anfordern, was die App wirklich braucht.<\/li>\n<li><strong>Session-Store f\u00fcr Produktion:<\/strong> Redis statt In-Memory, sonst gehen Logins beim Neustart verloren.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00e4sst sich Ihre vertrauensw\u00fcrdige Domain als Sprungbrett f\u00fcr Phishing missbrauchen. Pr\u00fcfen Sie Ziel-URLs gegen eine Allowlist oder erlauben Sie ausschlie\u00dflich relative Pfade. Dieser eine Punkt schlie\u00dft eine L\u00fccke, die selbst in gro\u00dfen Anwendungen immer wieder auftaucht.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"haeufige-fragen-zu-oauth-mit-pkce\">H\u00e4ufige Fragen zu OAuth mit PKCE<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"brauche-ich-pkce-auch-fuer-eine-klassische-server-app-mit-client-secret\">Brauche ich PKCE auch f\u00fcr eine klassische Server-App mit Client-Secret?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja. OAuth 2.1 verlangt PKCE f\u00fcr jeden Authorization Code Flow, auch f\u00fcr vertrauliche Clients mit Secret. PKCE und Client-Secret sch\u00fctzen gegen unterschiedliche Angriffe und erg\u00e4nzen sich. Viele Provider erzwingen die Challenge inzwischen ohnehin.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"worin-unterscheiden-sich-access-token-und-id-token\">Worin unterscheiden sich Access Token und ID Token?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Der Access Token autorisiert Ihre App gegen\u00fcber APIs und ist f\u00fcr die Client-App undurchsichtig. Der ID Token belegt die Identit\u00e4t des Nutzers und ist ein JWT, das Ihre App selbst auswertet und validiert. Verwenden Sie den ID Token nie als Zugriffstoken f\u00fcr APIs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"ist-oauth-2-1-schon-ein-verabschiedeter-standard\">Ist OAuth 2.1 schon ein verabschiedeter Standard?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00dfe Anbieter bereits um. Sie k\u00f6nnen sicher danach entwickeln, auch wenn die finale RFC-Nummer noch aussteht.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"wo-speichere-ich-tokens-am-sichersten\">Wo speichere ich Tokens am sichersten?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In einer server-seitigen Session, niemals im Local Storage des Browsers. Local Storage ist f\u00fcr jedes JavaScript lesbar und damit ein leichtes Ziel f\u00fcr XSS. Das Session-Cookie sichern Sie mit <code>httpOnly<\/code>, <code>secure<\/code> und <code>sameSite=lax<\/code> ab.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"warum-erhalte-ich-keinen-refresh-token\">Warum erhalte ich keinen Refresh Token?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Viele Provider liefern Refresh Tokens nur auf ausdr\u00fcckliche Anforderung. Bei Google erg\u00e4nzen Sie <code>access_type=offline<\/code> und oft <code>prompt=consent<\/code> im Authorization Request. Ohne diese Parameter erhalten Sie nur ein kurzlebiges Access Token.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kann-ich-denselben-code-fuer-microsoft-oder-okta-nutzen\">Kann ich denselben Code f\u00fcr Microsoft oder Okta nutzen?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja. Der Flow ist standardisiert. Sie tauschen nur Authorization Endpoint, Token Endpoint und gegebenenfalls die Scopes aus. Mit <code>openid-client<\/code> und OIDC-Discovery gen\u00fcgt sogar die Issuer-URL, der Rest wird automatisch geladen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"wie-teste-ich-den-flow-ohne-echten-provider\">Wie teste ich den Flow ohne echten Provider?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Nutzen Sie einen lokalen Mock-OIDC-Server oder einen kostenlosen Entwickler-Tenant. F\u00fcr automatisierte Tests eignen sich Bibliotheken, die einen OIDC-Provider in einem Container starten. So testen Sie State-, Verifier- und Fehlerpfade reproduzierbar.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"related-coverage\">Related Coverage<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"\/de\/ecdsa-nodejs-signaturen\/\">ECDSA in Node.js: Signaturen in 11 Schritten<\/a><\/li>\n<li><a href=\"\/de\/https-und-tls\/\">HTTPS und TLS: Wie das Schloss im Browser Sie sch\u00fctzt<\/a><\/li>\n<li><a href=\"\/de\/passwortsicherheit\/\">Passwortsicherheit: starke Passw\u00f6rter, Hashing und 2FA<\/a><\/li>\n<li><a href=\"\/de\/nis2-deutschland-umsetzung-2026\/\">NIS2 Deutschland: 29.500 Firmen, 10 Mio \u20ac Strafe<\/a><\/li>\n<li><a href=\"\/de\/cyber-resilience-act\/\">Cyber Resilience Act: 90 Tage bis zur Meldepflicht<\/a><\/li>\n<li><a href=\"\/de\/security-hub\/\">Online-Sicherheit verst\u00e4ndlich erkl\u00e4rt<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Weiterf\u00fchrende Spezifikationen und offizielle Quellen: <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc6749\" target=\"_blank\" rel=\"noopener\">OAuth 2.0 (RFC 6749)<\/a>, <a href=\"https:\/\/datatracker.ietf.org\/doc\/html\/rfc7636\" target=\"_blank\" rel=\"noopener\">PKCE (RFC 7636)<\/a>, <a href=\"https:\/\/oauth.net\/2.1\/\" target=\"_blank\" rel=\"noopener\">OAuth 2.1 \u00dcbersicht<\/a>, <a href=\"https:\/\/openid.net\/specs\/openid-connect-core-1_0.html\" target=\"_blank\" rel=\"noopener\">OpenID Connect Core 1.0<\/a> und die <a href=\"https:\/\/github.com\/panva\/openid-client\" target=\"_blank\" rel=\"noopener\">openid-client Dokumentation<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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.\u2026<\/p>\n","protected":false},"author":5,"featured_media":135,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-134","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security"],"_links":{"self":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/134","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/users\/5"}],"replies":[{"embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/comments?post=134"}],"version-history":[{"count":1,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/134\/revisions"}],"predecessor-version":[{"id":136,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/134\/revisions\/136"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/media\/135"}],"wp:attachment":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/media?parent=134"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/categories?post=134"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/tags?post=134"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}