{"id":126,"date":"2026-06-18T16:22:03","date_gmt":"2026-06-18T16:22:03","guid":{"rendered":"https:\/\/shattered.io\/at\/2026\/06\/18\/webauthn-nodejs\/"},"modified":"2026-06-18T16:23:28","modified_gmt":"2026-06-18T16:23:28","slug":"webauthn-nodejs","status":"publish","type":"post","link":"https:\/\/shattered.io\/at\/webauthn-nodejs\/","title":{"rendered":"WebAuthn in Node.js: Passwortlos in 12 Schritten [2026]"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Passw\u00f6rter sind das schw\u00e4chste Glied jeder Authentifizierungskette. WebAuthn, der W3C-Standard hinter Passkeys, l\u00f6st dieses Problem mit asymmetrischer Kryptografie: Der private Schl\u00fcssel verl\u00e4sst das Ger\u00e4t des Nutzers nie, der Server speichert ausschlie\u00dflich den \u00f6ffentlichen Schl\u00fcssel. Das Ergebnis ist eine Authentifizierungsmethode, die gegen Phishing, Credential-Stuffing und Brute-Force-Angriffe resistent ist. Diese Schritt-f\u00fcr-Schritt-Anleitung zeigt, wie du WebAuthn in Node.js mit dem Paket <strong>@simplewebauthn\/server v13.3.1<\/strong> implementierst, einen vollst\u00e4ndigen Registrierungs- und Authentifizierungsflow aufbaust und die L\u00f6sung produktionsbereit absicherst.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"was-ist-webauthn-grundlagen-fuer-node-js-entwickler\">Was ist WebAuthn? Grundlagen f\u00fcr Node.js-Entwickler<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn (Web Authentication API) ist ein W3C-Standard, der 2019 in der ersten Version und 2023 in der dritten Version ver\u00f6ffentlicht wurde. Er definiert, wie Webanwendungen Public-Key-Kryptografie zur Benutzerauthentifizierung einsetzen k\u00f6nnen, ohne Passw\u00f6rter zu \u00fcbertragen oder zu speichern. WebAuthn ist der technische Kern des FIDO2-\u00d6kosystems, zu dem auch CTAP (Client to Authenticator Protocol) geh\u00f6rt. Gemeinsam bilden sie den Standard, den Apple, Google, Microsoft und Hunderte anderer Dienste seit 2022 aktiv ausrollen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ein WebAuthn-Flow besteht aus zwei Phasen. W\u00e4hrend der <strong>Registrierung<\/strong> erzeugt das Ger\u00e4t des Nutzers ein asymmetrisches Schl\u00fcsselpaar. Der \u00f6ffentliche Schl\u00fcssel wird auf dem Server gespeichert, der private Schl\u00fcssel verbleibt im sicheren Speicher des Authenticators (Secure Enclave bei Apple, TPM-Chip bei Windows, StrongBox Keymaster bei Android oder externer Sicherheitsschl\u00fcssel). W\u00e4hrend der <strong>Authentifizierung<\/strong> signiert der Authenticator eine serverseitige Challenge mit dem privaten Schl\u00fcssel. Der Server pr\u00fcft die Signatur mit dem gespeicherten \u00f6ffentlichen Schl\u00fcssel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Passkeys sind eine nutzerfreundliche Implementierung von WebAuthn, bei der private Schl\u00fcssel zwischen Ger\u00e4ten eines Nutzers synchronisiert werden, \u00fcber iCloud Keychain, Google Password Manager oder Microsoft Authenticator. Dadurch entf\u00e4llt die Bindung an ein einzelnes Ger\u00e4t. Die technische Basis bleibt identisch: FIDO2-konforme Kryptografie, keine shared secrets, keine Passw\u00f6rter im Klartext oder als Hash auf dem Server.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Drei zentrale Begriffe sind f\u00fcr die Implementierung entscheidend. Die <strong>Relying Party (RP)<\/strong> ist deine Webanwendung. Die <strong>RP ID<\/strong> ist die Dom\u00e4ne, f\u00fcr die Credentials erstellt werden (zum Beispiel &#8220;example.com&#8221;). Der <strong>Origin<\/strong> ist die vollst\u00e4ndige URL des Frontends (&#8220;https:\/\/example.com&#8221;). Diese drei Werte m\u00fcssen konsistent konfiguriert sein, sonst schl\u00e4gt die Verifikation mit einem SecurityError fehl.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Die FIDO Alliance berichtet, dass \u00fcber 13 Milliarden Benutzerkonten bei mehr als 15.000 Diensten weltweit (Stand: Anfang 2026) Passkey-Unterst\u00fctzung bieten, darunter Google, Apple, Microsoft, GitHub, PayPal und zahlreiche Banken. Die Anmeldung mit einem Passkey dauert durchschnittlich 8,5 Sekunden gegen\u00fcber 31 Sekunden f\u00fcr passwortbasierte Logins. Das bedeutet: Passkeys sind nicht nur sicherer, sondern auch schneller.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"voraussetzungen-und-technologie-stack\">Voraussetzungen und Technologie-Stack<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bevor du mit der Implementierung beginnst, stelle sicher, dass folgende Komponenten installiert und konfiguriert sind. Die angegebenen Versionsnummern sind die aktuell stabilen Versionen (Stand: Juni 2026).<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Node.js 22.x LTS<\/strong> oder neuer (mit nativer Web Crypto API)<\/li>\n<li><strong>npm 10.x<\/strong> oder neuer<\/li>\n<li><strong>@simplewebauthn\/server v13.3.1<\/strong> (Backend-Bibliothek)<\/li>\n<li><strong>@simplewebauthn\/browser v13.3.0<\/strong> (Frontend-Helfer)<\/li>\n<li><strong>express v5.2.1<\/strong> (HTTP-Framework)<\/li>\n<li><strong>express-session v1.19.0<\/strong> (Session-Management)<\/li>\n<li>Ein moderner Browser: Chrome 108+, Firefox 119+, Safari 16+, Edge 108+<\/li>\n<li>HTTPS f\u00fcr die Produktion (localhost funktioniert f\u00fcr Entwicklung ohne HTTPS)<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn setzt einen sicheren Kontext voraus. Lokal kannst du \u00fcber <code>http:\/\/localhost<\/code> oder <code>http:\/\/127.0.0.1<\/code> testen, da Browser diese Adressen als sicher behandeln. In der Produktion ist HTTPS ohne Ausnahmen erforderlich. Selbstsignierte Zertifikate werden in den meisten Browsern f\u00fcr WebAuthn abgelehnt. F\u00fcr HTTPS-Zertifikate in der Produktion eignet sich Let&#8217;s Encrypt, das kostenlose und automatisch erneuerte Zertifikate bereitstellt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Das Projekt verwendet au\u00dferdem <strong>better-sqlite3 v9.x<\/strong> f\u00fcr persistente Credential-Speicherung im Produktionsabschnitt. Der erste Teil des Tutorials arbeitet mit einer In-Memory-Datenstruktur, um die Kernlogik ohne Datenbankboilerplate zu vermitteln.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-1-bis-3-projektstruktur-aufsetzen\">Schritt 1 bis 3: Projektstruktur aufsetzen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Erstelle ein neues Verzeichnis und initialisiere das Node.js-Projekt. Da SimpleWebAuthn v13.x ESModules verwendet, setzt du <code>\"type\": \"module\"<\/code> in der <code>package.json<\/code>. CommonJS-Projekte m\u00fcssen zuerst auf ESM migriert werden oder eine Dynamic-Import-L\u00f6sung verwenden.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>mkdir webauthn-demo && cd webauthn-demo\nnpm init -y\nnpm install @simplewebauthn\/server @simplewebauthn\/browser express express-session\nnpm install --save-dev nodemon<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">\u00d6ffne die generierte <code>package.json<\/code> und passe sie wie folgt an:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"name\": \"webauthn-demo\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"node src\/server.js\",\n    \"dev\": \"nodemon src\/server.js\"\n  },\n  \"dependencies\": {\n    \"@simplewebauthn\/browser\": \"^13.3.0\",\n    \"@simplewebauthn\/server\": \"^13.3.1\",\n    \"express\": \"^5.2.1\",\n    \"express-session\": \"^1.19.0\"\n  },\n  \"devDependencies\": {\n    \"nodemon\": \"^3.1.0\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die Verzeichnisstruktur des Projekts sieht so aus:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>webauthn-demo\/\n\u251c\u2500\u2500 src\/\n\u2502   \u251c\u2500\u2500 server.js        # Express-Server und Routen\n\u2502   \u251c\u2500\u2500 db.js            # In-Memory-Datenspeicher\n\u2502   \u2514\u2500\u2500 config.js        # RP-Konfiguration\n\u251c\u2500\u2500 public\/\n\u2502   \u251c\u2500\u2500 index.html       # Login-Seite (HTML)\n\u2502   \u2514\u2500\u2500 app.js           # Frontend-JavaScript\n\u2514\u2500\u2500 package.json<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Lege zun\u00e4chst die Konfigurationsdatei an. Die RP ID und der erwartete Origin sind die kritischsten Einstellungen der gesamten Implementierung. Ein Fehler hier f\u00fchrt zu Verifikationsfehlern, die bei der Fehlersuche mehrere Stunden kosten k\u00f6nnen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/config.js\nexport const config = {\n  rpName: process.env.RP_NAME || 'WebAuthn Demo',\n  rpID: process.env.RP_ID || 'localhost',\n  expectedOrigin: process.env.EXPECTED_ORIGIN || 'http:\/\/localhost:3000',\n  port: parseInt(process.env.PORT || '3000', 10),\n  sessionSecret: process.env.SESSION_SECRET || 'dev-secret-aendern-in-produktion',\n};<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Lege danach den In-Memory-Datenspeicher an. Dieser dient ausschlie\u00dflich der Entwicklung. In der Produktion ersetzt du ihn durch Datenbankabfragen (mehr dazu in Schritt 11).<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/db.js\nimport { randomUUID } from 'crypto';\n\nconst users = new Map();\nconst challenges = new Map();\n\nexport const db = {\n  createUser(username) {\n    const user = {\n      id: randomUUID(),\n      username,\n      credentials: [],\n      createdAt: new Date().toISOString(),\n    };\n    users.set(username, user);\n    return user;\n  },\n  getUserByUsername(username) {\n    return users.get(username) ?? null;\n  },\n  setChallenge(userId, challenge) {\n    \/\/ Challenge l\u00e4uft nach 5 Minuten ab (einmalige Verwendung erzwingen)\n    challenges.set(userId, {\n      challenge,\n      expiresAt: Date.now() + 5 * 60 * 1000,\n    });\n  },\n  getChallenge(userId) {\n    const entry = challenges.get(userId);\n    if (!entry) return null;\n    if (Date.now() > entry.expiresAt) {\n      challenges.delete(userId);\n      return null;\n    }\n    return entry.challenge;\n  },\n  deleteChallenge(userId) {\n    challenges.delete(userId);\n  },\n  addCredential(userId, credential) {\n    const user = [...users.values()].find((u) => u.id === userId);\n    if (user) user.credentials.push(credential);\n  },\n  updateCredentialCounter(userId, credentialID, newCounter) {\n    const user = [...users.values()].find((u) => u.id === userId);\n    if (!user) return;\n    const cred = user.credentials.find((c) => c.credentialID === credentialID);\n    if (cred) cred.counter = newCounter;\n  },\n};<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-4-und-5-registrierungsoptionen-generieren\">Schritt 4 und 5: Registrierungsoptionen generieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Die Registrierung beginnt damit, dass der Client beim Server Optionen anfordert. Der Server generiert eine kryptografisch zuf\u00e4llige Challenge mit 32 Bytes Entropie und gibt sie zusammen mit der RP-Konfiguration zur\u00fcck. Die Challenge ist ein einmaliger Wert, der Replay-Angriffe verhindert. Sie muss serverseitig gespeichert werden, bevor die Antwort gesendet wird.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Richte zuerst den Express-Server in <code>src\/server.js<\/code> ein:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/server.js\nimport express from 'express';\nimport session from 'express-session';\nimport {\n  generateRegistrationOptions,\n  verifyRegistrationResponse,\n  generateAuthenticationOptions,\n  verifyAuthenticationResponse,\n} from '@simplewebauthn\/server';\nimport { config } from '.\/config.js';\nimport { db } from '.\/db.js';\n\nconst app = express();\napp.use(express.json());\napp.use(express.static('public'));\napp.use(session({\n  secret: config.sessionSecret,\n  resave: false,\n  saveUninitialized: false,\n  cookie: {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === 'production',\n    sameSite: 'lax',\n    maxAge: 24 * 60 * 60 * 1000, \/\/ 24 Stunden\n  },\n}));\n\n\/\/ Schritt 4: Registrierungsoptionen anfordern\napp.post('\/api\/auth\/register\/options', async (req, res) => {\n  const { username } = req.body;\n  if (!username || username.length < 3) {\n    return res.status(400).json({ error: 'Benutzername ben\u00f6tigt mindestens 3 Zeichen' });\n  }\n\n  let user = db.getUserByUsername(username);\n  if (!user) {\n    user = db.createUser(username);\n  }\n\n  const options = await generateRegistrationOptions({\n    rpName: config.rpName,\n    rpID: config.rpID,\n    userID: Buffer.from(user.id),\n    userName: user.username,\n    userDisplayName: user.username,\n    attestationType: 'none',\n    authenticatorSelection: {\n      residentKey: 'preferred',\n      userVerification: 'required',\n      authenticatorAttachment: 'platform',\n    },\n    excludeCredentials: user.credentials.map((cred) => ({\n      id: Buffer.from(cred.credentialID, 'base64url'),\n      type: 'public-key',\n      transports: cred.transports,\n    })),\n    supportedAlgorithmIDs: [-7, -257], \/\/ ES256 (ECDSA P-256), RS256 (RSA-PSS)\n  });\n\n  \/\/ Schritt 5: Challenge serverseitig speichern (vor Antwort!)\n  db.setChallenge(user.id, options.challenge);\n\n  res.json(options);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die Option <code>residentKey: 'preferred'<\/code> erm\u00f6glicht, dass der Authenticator einen Discoverable Credential speichert. Mit <code>attestationType: 'none'<\/code> verzichtest du auf die Zertifikatspr\u00fcfung des Authenticator-Herstellers, was die Implementierung vereinfacht und f\u00fcr die meisten Web-Applikationen ausreicht. Die Zertifikatspr\u00fcfung ist nur f\u00fcr hochsicherheitskritische Anwendungen wie Beh\u00f6rden-Logins oder Finanzdienstleister sinnvoll.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Die Option <code>authenticatorAttachment: 'platform'<\/code> bevorzugt den integrierten Authenticator des Ger\u00e4ts (Face ID, Touch ID, Windows Hello). F\u00fcr externe Sicherheitsschl\u00fcssel wie YubiKey oder Nitrokey setzt du diesen Wert auf <code>'cross-platform'<\/code>. L\u00e4sst du die Option weg, erlaubst du beide Typen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-6-registrierungsantwort-verifizieren\">Schritt 6: Registrierungsantwort verifizieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nach der clientseitigen Erstellung des Credentials sendet der Browser die Attestation-Antwort an den Server. Die Verifikation durch <code>verifyRegistrationResponse()<\/code> pr\u00fcft die kryptografische Signatur, den Origin, die RP ID, die Challenge und die Attestation-Daten. Erst nach erfolgreicher Verifikation speicherst du den \u00f6ffentlichen Schl\u00fcssel dauerhaft. Dieser Schritt ist kritisch: \u00dcberspringe die Verifikation nie, auch nicht in Tests.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Fortsetzung src\/server.js\napp.post('\/api\/auth\/register\/verify', async (req, res) => {\n  const { username, response } = req.body;\n  const user = db.getUserByUsername(username);\n  if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });\n\n  const expectedChallenge = db.getChallenge(user.id);\n  if (!expectedChallenge) {\n    return res.status(400).json({ error: 'Keine ausstehende Challenge. Bitte neu starten.' });\n  }\n\n  let verification;\n  try {\n    verification = await verifyRegistrationResponse({\n      response,\n      expectedChallenge,\n      expectedOrigin: config.expectedOrigin,\n      expectedRPID: config.rpID,\n      requireUserVerification: true,\n    });\n  } catch (err) {\n    console.error('[WebAuthn] Registrierungsverifikation fehlgeschlagen:', err.message);\n    return res.status(400).json({ error: err.message });\n  }\n\n  if (!verification.verified || !verification.registrationInfo) {\n    return res.status(400).json({ verified: false });\n  }\n\n  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;\n\n  db.addCredential(user.id, {\n    credentialID: Buffer.from(credential.id).toString('base64url'),\n    credentialPublicKey: Buffer.from(credential.publicKey).toString('base64'),\n    counter: credential.counter,\n    transports: response.response?.transports ?? [],\n    deviceType: credentialDeviceType,\n    backedUp: credentialBackedUp,\n    createdAt: new Date().toISOString(),\n  });\n\n  \/\/ Challenge nach Verwendung l\u00f6schen (verhindert Replay-Angriffe)\n  db.deleteChallenge(user.id);\n\n  res.json({ verified: true });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Das Feld <code>credentialBackedUp<\/code> gibt an, ob der Passkey zwischen Ger\u00e4ten synchronisiert wird (true bei iCloud Keychain, Google Password Manager). Speichere diese Information in der Datenbank. Du kannst sie verwenden, um Nutzern anzuzeigen, ob ihr Passkey ger\u00e4te\u00fcbergreifend verf\u00fcgbar ist, und gegebenenfalls die Registrierung eines weiteren Credentials zu empfehlen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Die <code>counter<\/code>-Eigenschaft ist ein Z\u00e4hler, der bei jeder Verwendung des Credentials inkrementiert werden sollte. Bei physischen Sicherheitsschl\u00fcsseln (YubiKey) ist ein Counter-R\u00fcckschritt ein Indikator f\u00fcr ein geklontes Ger\u00e4t. Sync-Passkeys setzen den Counter oft auf 0, was laut FIDO2-Spezifikation und SimpleWebAuthn-Dokumentation erwartetes Verhalten ist.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-7-und-8-authentifizierung-implementieren\">Schritt 7 und 8: Authentifizierung implementieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Die Authentifizierung folgt demselben Challenge-Response-Muster wie die Registrierung. Der Server generiert eine neue, einmalige Challenge. Der Client l\u00e4sst den Authenticator die Challenge mit dem privaten Schl\u00fcssel signieren und sendet die Assertion an den Server. Der Server pr\u00fcft die Signatur gegen den gespeicherten \u00f6ffentlichen Schl\u00fcssel. Schl\u00e4gt die Verifikation fehl, scheitert die Anmeldung, ohne dass der Grund f\u00fcr den Nutzer sichtbar ist.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Schritt 7: Authentifizierungsoptionen generieren\napp.post('\/api\/auth\/login\/options', async (req, res) => {\n  const { username } = req.body;\n  const user = db.getUserByUsername(username);\n  if (!user || user.credentials.length === 0) {\n    return res.status(400).json({ error: 'Keine registrierten Passkeys f\u00fcr diesen Benutzer' });\n  }\n\n  const options = await generateAuthenticationOptions({\n    rpID: config.rpID,\n    userVerification: 'required',\n    allowCredentials: user.credentials.map((cred) => ({\n      id: Buffer.from(cred.credentialID, 'base64url'),\n      type: 'public-key',\n      transports: cred.transports,\n    })),\n    timeout: 60000,\n  });\n\n  db.setChallenge(user.id, options.challenge);\n  res.json(options);\n});\n\n\/\/ Schritt 8: Authentifizierungsantwort verifizieren\napp.post('\/api\/auth\/login\/verify', async (req, res) => {\n  const { username, response } = req.body;\n  const user = db.getUserByUsername(username);\n  if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });\n\n  const expectedChallenge = db.getChallenge(user.id);\n  if (!expectedChallenge) {\n    return res.status(400).json({ error: 'Keine ausstehende Challenge' });\n  }\n\n  const credential = user.credentials.find((c) => c.credentialID === response.id);\n  if (!credential) {\n    return res.status(400).json({ error: 'Unbekanntes Credential' });\n  }\n\n  let verification;\n  try {\n    verification = await verifyAuthenticationResponse({\n      response,\n      expectedChallenge,\n      expectedOrigin: config.expectedOrigin,\n      expectedRPID: config.rpID,\n      credential: {\n        id: Buffer.from(credential.credentialID, 'base64url'),\n        publicKey: Buffer.from(credential.credentialPublicKey, 'base64'),\n        counter: credential.counter,\n        transports: credential.transports,\n      },\n      requireUserVerification: true,\n    });\n  } catch (err) {\n    console.error('[WebAuthn] Authentifizierungsverifikation fehlgeschlagen:', err.message);\n    return res.status(400).json({ error: err.message });\n  }\n\n  if (!verification.verified) {\n    return res.status(401).json({ verified: false });\n  }\n\n  \/\/ Counter nach erfolgreicher Authentifizierung aktualisieren\n  db.updateCredentialCounter(\n    user.id,\n    credential.credentialID,\n    verification.authenticationInfo.newCounter\n  );\n  db.deleteChallenge(user.id);\n\n  req.session.userId = user.id;\n  req.session.username = user.username;\n\n  res.json({ verified: true, username: user.username });\n});\n\napp.listen(config.port, () => {\n  console.log(`[WebAuthn Demo] Server l\u00e4uft auf Port ${config.port}`);\n  console.log(`[WebAuthn Demo] RP ID: ${config.rpID}, Origin: ${config.expectedOrigin}`);\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-9-und-10-frontend-integration-mit-simplewebauthn-browser\">Schritt 9 und 10: Frontend-Integration mit @simplewebauthn\/browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Das Frontend verwendet <strong>@simplewebauthn\/browser v13.3.0<\/strong>. Die Bibliothek kapselt die nativen Browser-APIs <code>navigator.credentials.create()<\/code> und <code>navigator.credentials.get()<\/code> in benutzerfreundliche Funktionen, die Base64URL-Encoding, ArrayBuffer-Konvertierungen und browser\u00fcbergreifende Kompatibilit\u00e4tsprobleme automatisch handhaben.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!-- public\/index.html --&gt;\n&lt;!DOCTYPE html&gt;\n&lt;html lang=\"de\"&gt;\n&lt;head&gt;\n  &lt;meta charset=\"UTF-8\"&gt;\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"&gt;\n  &lt;title&gt;WebAuthn Demo&lt;\/title&gt;\n  &lt;style&gt;\n    body { font-family: system-ui; max-width: 480px; margin: 60px auto; padding: 0 20px; }\n    input { display: block; width: 100%; padding: 10px; margin: 8px 0; border: 1px solid #ccc; border-radius: 6px; font-size: 16px; }\n    button { display: block; width: 100%; padding: 12px; margin: 8px 0; background: #0070f3; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }\n    button:hover { background: #0051cc; }\n    #status { margin-top: 16px; padding: 12px; border-radius: 6px; font-size: 14px; }\n    .ok { background: #d4edda; color: #155724; }\n    .err { background: #f8d7da; color: #721c24; }\n  &lt;\/style&gt;\n&lt;\/head&gt;\n&lt;body&gt;\n  &lt;h1&gt;Passwortlose Anmeldung&lt;\/h1&gt;\n  &lt;input type=\"text\" id=\"username\" placeholder=\"Benutzername\" autocomplete=\"username webauthn\" \/&gt;\n  &lt;button id=\"register\"&gt;Passkey registrieren&lt;\/button&gt;\n  &lt;button id=\"login\"&gt;Mit Passkey anmelden&lt;\/button&gt;\n  &lt;div id=\"status\"&gt;&lt;\/div&gt;\n  &lt;script type=\"module\" src=\"app.js\"&gt;&lt;\/script&gt;\n&lt;\/body&gt;\n&lt;\/html&gt;<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ public\/app.js\nimport {\n  startRegistration,\n  startAuthentication,\n} from 'https:\/\/cdn.jsdelivr.net\/npm\/@simplewebauthn\/browser@13.3.0\/+esm';\n\nconst statusEl = document.getElementById('status');\n\nfunction showStatus(msg, isError = false) {\n  statusEl.textContent = msg;\n  statusEl.className = isError ? 'err' : 'ok';\n}\n\n\/\/ Schritt 9: Passkey-Registrierung\ndocument.getElementById('register').addEventListener('click', async () => {\n  const username = document.getElementById('username').value.trim();\n  if (!username) { showStatus('Bitte Benutzername eingeben', true); return; }\n\n  \/\/ 1. Optionen vom Server holen\n  const optRes = await fetch('\/api\/auth\/register\/options', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ username }),\n  });\n  if (!optRes.ok) {\n    showStatus('Fehler beim Holen der Optionen: ' + (await optRes.json()).error, true);\n    return;\n  }\n  const options = await optRes.json();\n\n  \/\/ 2. Browser-API aufrufen (\u00f6ffnet Authenticator-Dialog)\n  let attResp;\n  try {\n    attResp = await startRegistration({ optionsJSON: options });\n  } catch (err) {\n    showStatus('Registrierung abgebrochen: ' + err.message, true);\n    return;\n  }\n\n  \/\/ 3. Antwort zur Verifikation senden\n  const verRes = await fetch('\/api\/auth\/register\/verify', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ username, response: attResp }),\n  });\n  const result = await verRes.json();\n  if (result.verified) {\n    showStatus('Passkey erfolgreich registriert! Du kannst dich jetzt anmelden.');\n  } else {\n    showStatus('Registrierung fehlgeschlagen: ' + (result.error || 'Unbekannter Fehler'), true);\n  }\n});\n\n\/\/ Schritt 10: Passkey-Authentifizierung\ndocument.getElementById('login').addEventListener('click', async () => {\n  const username = document.getElementById('username').value.trim();\n  if (!username) { showStatus('Bitte Benutzername eingeben', true); return; }\n\n  const optRes = await fetch('\/api\/auth\/login\/options', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ username }),\n  });\n  if (!optRes.ok) {\n    showStatus('Fehler: ' + (await optRes.json()).error, true);\n    return;\n  }\n  const options = await optRes.json();\n\n  let assertResp;\n  try {\n    assertResp = await startAuthentication({ optionsJSON: options });\n  } catch (err) {\n    showStatus('Anmeldung abgebrochen: ' + err.message, true);\n    return;\n  }\n\n  const verRes = await fetch('\/api\/auth\/login\/verify', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application\/json' },\n    body: JSON.stringify({ username, response: assertResp }),\n  });\n  const result = await verRes.json();\n  if (result.verified) {\n    showStatus(`Willkommen zur\u00fcck, ${result.username}! Anmeldung erfolgreich.`);\n  } else {\n    showStatus('Anmeldung fehlgeschlagen: ' + (result.error || 'Ung\u00fcltige Antwort'), true);\n  }\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der Attribut-Wert <code>autocomplete=\"username webauthn\"<\/code> im Eingabefeld aktiviert die <strong>Conditional UI<\/strong> in unterst\u00fctzten Browsern (Chrome 108+, Safari 16+). Der Browser zeigt beim Fokussieren des Feldes automatisch verf\u00fcgbare Passkeys an, \u00e4hnlich wie bei gespeicherten Passw\u00f6rtern. Dieser Flow ist f\u00fcr Nutzer besonders angenehm, weil kein Klick auf eine separate Schaltfl\u00e4che n\u00f6tig ist.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-11-datenbankintegration-fuer-den-produktionseinsatz\">Schritt 11: Datenbankintegration f\u00fcr den Produktionseinsatz<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Der In-Memory-Datenspeicher verliert alle Daten beim Serverneustart. F\u00fcr die Produktion brauchst du eine persistente Datenbank. Das folgende Schema zeigt die empfohlene Tabellenstruktur f\u00fcr SQLite (mit <code>better-sqlite3<\/code>) oder PostgreSQL.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Datenbankschema f\u00fcr WebAuthn-Credentials\nCREATE TABLE users (\n  id          TEXT PRIMARY KEY,          -- UUID\n  username    TEXT UNIQUE NOT NULL,\n  created_at  TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE TABLE credentials (\n  id                  TEXT PRIMARY KEY,  -- UUID\n  user_id             TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n  credential_id       TEXT UNIQUE NOT NULL,   -- Base64URL-kodierte ID\n  public_key          TEXT NOT NULL,           -- Base64-kodierter \u00f6ffentlicher Schl\u00fcssel\n  counter             INTEGER NOT NULL DEFAULT 0,\n  transports          TEXT NOT NULL DEFAULT '[]',  -- JSON-Array\n  device_type         TEXT,              -- 'singleDevice' oder 'multiDevice'\n  backed_up           INTEGER DEFAULT 0, -- 1 = synchronisiert, 0 = ger\u00e4tegebunden\n  name                TEXT,              -- Nutzerdefinierbarer Name (z.B. \"iPhone von Anna\")\n  created_at          TEXT NOT NULL DEFAULT (datetime('now')),\n  last_used_at        TEXT\n);\n\nCREATE TABLE challenges (\n  user_id     TEXT PRIMARY KEY,\n  challenge   TEXT NOT NULL,\n  expires_at  INTEGER NOT NULL           -- Unix-Timestamp in Millisekunden\n);\n\nCREATE INDEX idx_credentials_user_id ON credentials(user_id);\nCREATE INDEX idx_credentials_credential_id ON credentials(credential_id);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der Index auf <code>credential_id<\/code> ist zwingend erforderlich, da dieses Feld bei jeder Authentifizierung abgefragt wird. Bei 100.000 aktiven Nutzern mit je durchschnittlich 2,3 Credentials (typischer Wert nach FIDO-Alliance-Daten) w\u00fcrde eine sequenzielle Suche ohne Index messbare Latenzen verursachen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Implementiere au\u00dferdem einen Endpunkt zum Verwalten von Credentials. Nutzer m\u00fcssen ihre registrierten Passkeys einsehen, benennen und entfernen k\u00f6nnen. Ohne diese Funktion k\u00f6nnen Nutzer bei einem verlorenen Ger\u00e4t mit nicht synchronisiertem Passkey den Account nicht mehr erreichen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-12-produktionssicherheit-und-haertung\">Schritt 12: Produktionssicherheit und H\u00e4rtung<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bevor du WebAuthn in die Produktion bringst, pr\u00fcfe alle folgenden Sicherheitsma\u00dfnahmen. Jeder Punkt sch\u00fctzt vor einer spezifischen Angriffskategorie.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Sicherheitsma\u00dfnahme<\/th><th>Schutz gegen<\/th><th>Implementierung<\/th><\/tr><\/thead><tbody><tr><td>HTTPS erzwingen<\/td><td>Man-in-the-Middle<\/td><td>HSTS-Header, HTTP auf 443 weiterleiten<\/td><\/tr><tr><td>Sichere Session-Cookies<\/td><td>XSS, CSRF<\/td><td><code>httpOnly: true, secure: true, sameSite: 'lax'<\/code><\/td><\/tr><tr><td>Challenge-Ablauf (5 Min.)<\/td><td>Replay-Angriffe<\/td><td>Timestamp beim Speichern, bei Abruf pr\u00fcfen<\/td><\/tr><tr><td>Rate Limiting (10 req\/min\/IP)<\/td><td>Brute Force, DoS<\/td><td>express-rate-limit auf allen Auth-Endpunkten<\/td><\/tr><tr><td>Challenge nach Verwendung l\u00f6schen<\/td><td>Replay-Angriffe<\/td><td><code>deleteChallenge()<\/code> nach Verifikation<\/td><\/tr><tr><td>Counter-Validierung<\/td><td>Credential-Kloning<\/td><td>Neuer Counter muss gr\u00f6\u00dfer oder gleich sein<\/td><\/tr><tr><td>Origin exakt validieren<\/td><td>Cross-Origin-Angriffe<\/td><td><code>expectedOrigin<\/code> exakt konfigurieren<\/td><\/tr><tr><td><code>excludeCredentials<\/code> bei Registrierung<\/td><td>Duplikat-Credentials<\/td><td>Alle vorhandenen Credentials \u00fcbergeben<\/td><\/tr><tr><td><code>userVerification: 'required'<\/code><\/td><td>Unbefugter Ger\u00e4tezugriff<\/td><td>Biometrie oder PIN erzwingen<\/td><\/tr><tr><td>Credential-Ownership pr\u00fcfen<\/td><td>Account-Takeover<\/td><td>Credential muss zum angefragten User geh\u00f6ren<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Setze au\u00dferdem sinnvolle HTTP-Sicherheitsheader. WebAuthn selbst erfordert keinen zus\u00e4tzlichen Content-Security-Policy-Eintrag, aber deine App sollte grundlegende Headers wie <code>X-Frame-Options: DENY<\/code>, <code>X-Content-Type-Options: nosniff<\/code> und einen strikten Referrer-Policy-Header liefern. F\u00fcr die vollst\u00e4ndige CSP-Konfiguration in Node.js empfehlen wir unseren Artikel zu <a href=\"\/at\/content-security-policy-nodejs\/\">Content Security Policy in Node.js<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Implementiere einen Recovery-Flow. F\u00fcr den Fall, dass ein Nutzer alle seine Passkeys verliert (Ger\u00e4t gestohlen, kein Backup-Passkey, iCloud-Account gesperrt), brauchst du einen alternativen Zugang. Empfohlene Optionen sind: E-Mail-Magic-Link mit zeitbegrenztem Token, Admin-seitige manuelle Verifikation oder ein Backup-Code-System \u00e4hnlich wie bei TOTP. Informiere Nutzer nach der ersten Passkey-Registrierung aktiv \u00fcber diese M\u00f6glichkeit.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"haeufige-fehler-und-troubleshooting\">H\u00e4ufige Fehler und Troubleshooting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn-Fehler erscheinen oft kryptisch im Browser. Die folgenden 8 Szenarien decken \u00fcber 90 Prozent der Probleme ab, die bei der Node.js-Implementierung auftreten.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. NotAllowedError: The operation either timed out or was not allowed.<\/strong> Der Nutzer hat den Authenticator-Dialog abgebrochen, der Vorgang lief in einen Timeout (Standard: 60 Sekunden), oder der Browser verweigerte den Zugriff. In Firefox erscheint dieser Fehler auch, wenn die Seite nicht fokussiert ist. L\u00f6sung: Zeige dem Nutzer klare Anweisungen, bevor du den Dialog \u00f6ffnest. Erh\u00f6he das Timeout auf 120.000 Millisekunden f\u00fcr langsame Nutzer. Fange den Fehler gracefully ab und zeige eine verst\u00e4ndliche Fehlermeldung statt des technischen Fehlertexts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. SecurityError: The relying party ID is not a registrable domain suffix of the current origin.<\/strong> Die RP ID stimmt nicht mit der Domain der Seite \u00fcberein. Wenn deine App unter <code>app.example.com<\/code> l\u00e4uft und die RP ID <code>example.com<\/code> ist, muss das ein registrierbares Suffix der aktuellen Domain sein. IP-Adressen (au\u00dfer 127.0.0.1) und Ports werden in der RP ID ignoriert. L\u00f6sung: In der Entwicklung immer <code>localhost<\/code> als RP ID verwenden, nie eine IP-Adresse wie 192.168.1.x.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. InvalidStateError: An account already exists.<\/strong> Der Authenticator enth\u00e4lt bereits ein Credential f\u00fcr diese Kombination aus User ID und RP ID. Das passiert, wenn <code>excludeCredentials<\/code> fehlt oder unvollst\u00e4ndig ist. L\u00f6sung: \u00dcbergib immer alle bestehenden Credentials eines Nutzers im <code>excludeCredentials<\/code>-Array bei der Generierung der Registrierungsoptionen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. Verifikation schl\u00e4gt fehl: Unexpected challenge.<\/strong> Die vom Server gespeicherte Challenge stimmt nicht mit der in der Authenticator-Antwort enthaltenen \u00fcberein. H\u00e4ufige Ursachen: Race-Conditions bei mehreren gleichzeitigen Anfragen desselben Nutzers, oder die Challenge wurde bereits gel\u00f6scht (doppelte Anfrage). L\u00f6sung: Challenges an die Session statt an die User ID binden, um parallele Flows desselben Nutzers zu isolieren.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. Counter-Fehler: Credential counter is not greater than stored counter.<\/strong> Bei Sync-Passkeys ist der Counter oft 0, was SimpleWebAuthn v13.x korrekt behandelt. Bei physischen Sicherheitsschl\u00fcsseln deutet ein Counter-R\u00fcckschritt auf ein geklontes Credential hin. L\u00f6sung: Das Credential deaktivieren, den Nutzer zur Neuregistrierung auffordern und den Vorfall im Security-Log festhalten.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. ConstraintError: This request is not supported.<\/strong> Die konfigurierten Authenticator-Anforderungen k\u00f6nnen vom Ger\u00e4t nicht erf\u00fcllt werden, etwa wenn <code>authenticatorAttachment: 'platform'<\/code> gesetzt ist, aber das Ger\u00e4t keinen integrierten Authenticator hat (\u00e4ltere Laptops ohne Windows Hello, Desktop-PCs ohne Fingerabdruckleser). L\u00f6sung: Entferne <code>authenticatorAttachment<\/code> oder setze es auf <code>'cross-platform'<\/code>, um externe Sicherheitsschl\u00fcssel zu erlauben.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>7. TypeError beim Import der Browser-Bibliothek.<\/strong> Mit <code>@simplewebauthn\/browser v13.x<\/code> m\u00fcssen Named Exports verwendet werden: <code>import { startRegistration, startAuthentication } from '@simplewebauthn\/browser'<\/code>. Der CDN-Import \u00fcber <code>+esm<\/code> funktioniert nur mit <code>type=\"module\"<\/code> im Script-Tag. Pr\u00fcfe auch, ob der Browser den ESM-Import blockiert (Content-Security-Policy <code>script-src<\/code>).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>8. Authentifizierung schl\u00e4gt in Produktion fehl, lokal funktioniert alles.<\/strong> Der h\u00e4ufigste Grund ist eine falsche <code>expectedOrigin<\/code>-Konfiguration. Hinter einem Reverse-Proxy (nginx, Cloudflare, AWS ALB) muss der Origin die externe URL sein (<code>https:\/\/example.com<\/code>), nicht die interne (<code>http:\/\/127.0.0.1:3000<\/code>). L\u00f6sung: Konfiguriere <code>EXPECTED_ORIGIN<\/code> und <code>RP_ID<\/code> als Umgebungsvariablen und logge beide Werte beim Serverstart f\u00fcr eine einfachere Fehlerdiagnose.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"erwartete-serverantworten-ausgabebeispiele\">Erwartete Serverantworten: Ausgabebeispiele<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">So sehen korrekte Serverantworten aus, damit du Abweichungen leichter erkennst:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Erfolgreiche Registrierungsoptionen (GET-Antwort von \/api\/auth\/register\/options)\n{\n  \"challenge\": \"a8FBm_Xyz123...\",\n  \"rp\": { \"name\": \"WebAuthn Demo\", \"id\": \"localhost\" },\n  \"user\": { \"id\": \"dXNlci0x\", \"name\": \"anna\", \"displayName\": \"anna\" },\n  \"pubKeyCredParams\": [\n    { \"alg\": -7,   \"type\": \"public-key\" },\n    { \"alg\": -257, \"type\": \"public-key\" }\n  ],\n  \"timeout\": 60000,\n  \"excludeCredentials\": [],\n  \"authenticatorSelection\": {\n    \"residentKey\": \"preferred\",\n    \"userVerification\": \"required\",\n    \"authenticatorAttachment\": \"platform\"\n  },\n  \"attestation\": \"none\"\n}\n\n# Erfolgreiche Verifikation\n{ \"verified\": true }\n\n# Erfolgreiche Login-Verifikation\n{ \"verified\": true, \"username\": \"anna\" }\n\n# Fehler: falscher Origin\n{\n  \"error\": \"Unexpected origin 'http:\/\/localhost:3001', expected 'http:\/\/localhost:3000'\"\n}\n\n# Fehler: abgelaufene Challenge\n{\n  \"error\": \"Keine ausstehende Challenge. Bitte neu starten.\"\n}<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"sicherheitsvergleich-passkeys-vs-passwoerter-vs-totp\">Sicherheitsvergleich: Passkeys vs. Passw\u00f6rter vs. TOTP<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Die folgende Tabelle vergleicht die drei g\u00e4ngigsten Authentifizierungsmethoden anhand konkreter Sicherheits- und Usability-Kriterien. Die Daten basieren auf FIDO-Alliance-Berichten (2026) und NIST-Publikationen.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Kriterium<\/th><th>Passw\u00f6rter<\/th><th>TOTP (z.B. Google Authenticator)<\/th><th>WebAuthn Passkeys<\/th><\/tr><\/thead><tbody><tr><td>Phishing-Resistent<\/td><td>Nein<\/td><td>Bedingt (AiTM-Angriffe m\u00f6glich)<\/td><td>Ja (origin-gebunden)<\/td><\/tr><tr><td>Credential-Stuffing<\/td><td>Verwundbar<\/td><td>Verwundbar (Passwort + Code n\u00f6tig)<\/td><td>Immun<\/td><\/tr><tr><td>Risiko bei Server-Breach<\/td><td>Hoch (Hash oder Klartext)<\/td><td>Mittel (TOTP-Secret im Klartext)<\/td><td>Niedrig (nur Public Key)<\/td><\/tr><tr><td>Durchschnittliche Login-Dauer<\/td><td>31 Sekunden<\/td><td>45 Sekunden<\/td><td>8,5 Sekunden<\/td><\/tr><tr><td>Ger\u00e4te-Verlust-Recovery<\/td><td>Passwort-Reset per E-Mail<\/td><td>Backup-Codes (oft nicht gespeichert)<\/td><td>Sync-Passkey oder Recovery-Flow<\/td><\/tr><tr><td>Browser-Unterst\u00fctzung<\/td><td>Universal<\/td><td>Universal (per JS-Generator)<\/td><td>95 Prozent der modernen Browser<\/td><\/tr><tr><td>Nutzer-Akzeptanz<\/td><td>Bekannt, aber ungeliebt<\/td><td>M\u00e4\u00dfig (App-Installation n\u00f6tig)<\/td><td>Hoch (biometrisch, schnell)<\/td><\/tr><tr><td>NIST AAL2-Konformit\u00e4t<\/td><td>Nein<\/td><td>Ja (mit sicherem Kanal)<\/td><td>Ja<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Passkeys bieten den besten Sicherheits-zu-Usability-Kompromiss. Die Origin-Bindung macht sie strukturell phishing-resistent: Ein Angreifer, der Nutzer auf evil-example.com lockt, erh\u00e4lt dort ein Credential, das nur f\u00fcr evil-example.com gilt, nicht f\u00fcr example.com. Diesen Schutz k\u00f6nnen weder TOTP noch Passw\u00f6rter bieten. F\u00fcr weitere Details lies unseren Vergleichsartikel <a href=\"\/at\/passkeys-vs-passwords\/\">Passkeys vs. Passw\u00f6rter: 8,5s vs. 31s Anmeldung<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"browser-unterstuetzung-fuer-webauthn-2026\">Browser-Unterst\u00fctzung f\u00fcr WebAuthn 2026<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn ist in allen modernen Browsern und Betriebssystemen verf\u00fcgbar. Die folgende Tabelle zeigt den Supportstatus der wichtigsten Plattformen.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Browser \/ Plattform<\/th><th>Support seit<\/th><th>Passkey-Sync<\/th><th>Authenticator-Typen<\/th><\/tr><\/thead><tbody><tr><td>Chrome 108+ \/ Android<\/td><td>Vollst\u00e4ndig<\/td><td>Google Password Manager<\/td><td>Plattform + extern<\/td><\/tr><tr><td>Safari 16+ \/ iOS &amp; macOS<\/td><td>Vollst\u00e4ndig<\/td><td>iCloud Keychain<\/td><td>Plattform + extern<\/td><\/tr><tr><td>Firefox 119+<\/td><td>Vollst\u00e4ndig<\/td><td>\u00dcber OS (kein eigener Sync)<\/td><td>Plattform + extern<\/td><\/tr><tr><td>Edge 108+ \/ Windows<\/td><td>Vollst\u00e4ndig<\/td><td>Windows Hello \/ MS Authenticator<\/td><td>Plattform + extern<\/td><\/tr><tr><td>Samsung Internet 21+<\/td><td>Vollst\u00e4ndig<\/td><td>Samsung Pass<\/td><td>Plattform + extern<\/td><\/tr><tr><td>iOS 16+ (alle Browser)<\/td><td>Vollst\u00e4ndig<\/td><td>iCloud Keychain<\/td><td>Nur Plattform (Face\/Touch ID)<\/td><\/tr><tr><td>Android unter 9.0<\/td><td>Eingeschr\u00e4nkt<\/td><td>Kein Passkey-Sync<\/td><td>Extern (CTAP2-Schl\u00fcssel)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Die FIDO Alliance sch\u00e4tzt, dass im Jahr 2026 \u00fcber 95 Prozent aller aktiv genutzten Browser WebAuthn vollst\u00e4ndig unterst\u00fctzen. Der einzige nennenswerte Vorbehalt betrifft Android-Ger\u00e4te unter Version 9, die keine Passkey-Synchronisierung unterst\u00fctzen. F\u00fcr diese Nutzer empfiehlt sich ein Fallback auf TOTP. Unseren Artikel zu <a href=\"\/at\/two-factor-authentication-nodejs\/\">Zwei-Faktor-Authentifizierung in Node.js<\/a> zeigt, wie du TOTP parallel zu WebAuthn implementierst.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"erweiterte-konfiguration-discoverable-credentials-und-cross-device-flows\">Erweiterte Konfiguration: Discoverable Credentials und Cross-Device-Flows<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Discoverable Credentials erm\u00f6glichen eine Anmeldung ohne vorherige Eingabe eines Benutzernamens. Der Authenticator speichert nicht nur den privaten Schl\u00fcssel, sondern auch Nutzermetadaten. Beim Login zeigt der Browser eine Auswahlliste verf\u00fcgbarer Accounts, ohne dass der Nutzer seinen Benutzernamen eingibt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr diesen nutzerlosen Login-Flow (auch Conditional UI genannt) sende beim Generieren der Login-Optionen ein leeres <code>allowCredentials<\/code>-Array:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Nutzerlosen Login (Conditional UI \/ Passkey-Autofill) unterst\u00fctzen\napp.post('\/api\/auth\/login\/options\/conditional', async (req, res) => {\n  const options = await generateAuthenticationOptions({\n    rpID: config.rpID,\n    userVerification: 'required',\n    allowCredentials: [],  \/\/ Leeres Array: Browser zeigt alle verf\u00fcgbaren Passkeys\n    timeout: 120000,\n  });\n\n  \/\/ Challenge in Session statt an User-ID binden\n  req.session.conditionalChallenge = options.challenge;\n  req.session.conditionalExpiry = Date.now() + 5 * 60 * 1000;\n  res.json(options);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cross-Device Authentication<\/strong> erm\u00f6glicht, dass ein Mobiltelefon als Authenticator f\u00fcr einen Desktop-Browser verwendet wird. Dieser Flow l\u00e4uft \u00fcber Bluetooth (CTAP 2.2 mit Hybrid Transport). SimpleWebAuthn unterst\u00fctzt ihn automatisch, wenn du in <code>allowCredentials<\/code> die Transporte <code>['hybrid', 'ble']<\/code> eintr\u00e4gst. In der Praxis funktioniert das zwischen Android-Telefon und Chrome auf dem Desktop sowie zwischen iPhone und Safari oder Chrome auf dem Mac. Der Nutzer scannt einen QR-Code und best\u00e4tigt auf dem Telefon per Biometrie.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr eine vollst\u00e4ndige OAuth-2.0-Integration, bei der WebAuthn als starker zweiter Faktor in einem bestehenden Authorization-Code-Flow fungiert, lies unseren Artikel <a href=\"\/at\/oauth2-nodejs\/\">OAuth 2.0 in Node.js: 12 Schritte, 45 Min<\/a>. F\u00fcr die Kryptografie hinter ECDSA, dem Signaturalgorithmus, den alle Plattform-Authenticators standardm\u00e4\u00dfig verwenden, empfehlen wir den Artikel <a href=\"\/at\/digitale-signatur-nodejs\/\">Digitale Signatur in Node.js: 11 Schritte, 40 Min<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"webauthn-in-der-bestehenden-auth-architektur-integrieren\">WebAuthn in der bestehenden Auth-Architektur integrieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn ist selten die einzige Authentifizierungsmethode in einer Produktionsapp. Drei Integrationsszenarien decken die meisten Anwendungsf\u00e4lle ab.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Szenario 1: WebAuthn als prim\u00e4re Authentifizierung.<\/strong> Nutzer registrieren sich mit E-Mail und setzen sofort einen Passkey ein. Es gibt keine Passw\u00f6rter in der Datenbank. Ein Recovery-Flow \u00fcber verifizierte E-Mail-Links ist der Fallback f\u00fcr verlorene Ger\u00e4te ohne Passkey-Sync. Dieses Modell ist das sicherste, erfordert aber die sorgf\u00e4ltigste UX-Gestaltung des Recovery-Flows. Neue Dienste (Startups ohne Legacy-Systeme) sollten diesen Ansatz w\u00e4hlen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Szenario 2: WebAuthn als starker zweiter Faktor.<\/strong> Nutzer melden sich mit Passwort an und best\u00e4tigen mit einem Passkey. Das ist die empfohlene Migrationsstrategie f\u00fcr bestehende Systeme mit vielen Nutzern. Die WebAuthn-Verifikation ersetzt TOTP oder SMS-OTP. Passkeys sind phishing-resistent, TOTP ist es nicht vollst\u00e4ndig (AiTM-Angriffe umgehen TOTP). F\u00fcr die Session-Verwaltung nach erfolgreicher Authentifizierung lies unseren Artikel <a href=\"\/at\/nodejs-session-management\/\">Node.js Session Management: 11 Schritte<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Szenario 3: WebAuthn \u00fcber einen externen Identity Provider.<\/strong> Dienste wie Auth0, Okta, Keycloak oder Azure AD B2C bieten bereits Passkey-Unterst\u00fctzung an. In diesem Fall implementierst du WebAuthn nicht selbst, sondern konfigurierst den Provider. Die Node.js-App \u00fcbernimmt nur die OAuth-2.0\/OIDC-Verifikation des Tokens. Das ist der schnellste Weg zur Passkey-Unterst\u00fctzung f\u00fcr Teams ohne dedizierte Security-Expertise.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr alle drei Szenarien gilt: Implementiere ein Credential-Management-Interface in den Account-Einstellungen. Nutzer m\u00fcssen registrierte Passkeys einsehen (mit Ger\u00e4tename und Datum der letzten Verwendung), umbenennen und entfernen k\u00f6nnen. Das ist keine optionale Komfortfunktion, sondern ein Sicherheitserfordernis. F\u00fcr JWT-basierte API-Authentifizierung nach dem WebAuthn-Login empfehlen wir den Artikel <a href=\"\/at\/jwt-authentication-nodejs\/\">JWT Authentication in Node.js: 10 Schritte<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"haeufige-fragen-zu-webauthn-in-node-js\">H\u00e4ufige Fragen zu WebAuthn in Node.js<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kann-ich-webauthn-mit-bestehenden-passwort-accounts-kombinieren\">Kann ich WebAuthn mit bestehenden Passwort-Accounts kombinieren?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja, das ist der empfohlene Migrationspfad. Erm\u00f6gliche Nutzern, einen Passkey zu ihrem bestehenden Account hinzuzuf\u00fcgen, ohne das Passwort sofort zu entfernen. Sobald ein Nutzer einen Passkey registriert hat, biete die Passkey-Anmeldung als prim\u00e4ren und bevorzugten Flow an. Das Passwort bleibt als Fallback erhalten, bis der Nutzer es manuell l\u00f6scht.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"funktioniert-webauthn-auf-shared-computern-bibliotheken-schulen\">Funktioniert WebAuthn auf Shared Computern (Bibliotheken, Schulen)?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Auf Shared Computern setze <code>authenticatorAttachment: 'cross-platform'<\/code>, damit Nutzer externe Sicherheitsschl\u00fcssel (YubiKey, Nitrokey) verwenden k\u00f6nnen. Platform Authenticators (Windows Hello, Touch ID) funktionieren zwar auch auf Shared Computern, aber ein Passkey ist dann an das Ger\u00e4t gebunden, sofern kein Passkey-Manager synchronisiert. F\u00fcr Bibliotheken oder Schulen empfiehlt sich entweder ein Fallback auf TOTP oder eine Kombination aus kurzzeitigem E-Mail-Magic-Link.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"wie-teste-ich-webauthn-lokal-ohne-echten-authenticator\">Wie teste ich WebAuthn lokal ohne echten Authenticator?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Chrome bietet einen integrierten Virtual Authenticator in den DevTools. \u00d6ffne die DevTools, w\u00e4hle &#8220;Weitere Tools&#8221; und dann &#8220;WebAuthn&#8221;. Aktiviere den virtuellen Authenticator und konfiguriere den Transport (internal, usb, nfc). Du kannst gezielt verschiedene Konfigurationen testen: Plattform-Authenticator, roaming Authenticator, Authenticator mit und ohne Resident-Key-Unterst\u00fctzung. Firefox bietet eine \u00e4hnliche Funktion \u00fcber <code>about:config<\/code> mit dem Flag <code>security.webauth.webauthn<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"wie-ist-die-datenschutzlage-bei-webauthn-in-oesterreich-dsgvo\">Wie ist die Datenschutzlage bei WebAuthn in \u00d6sterreich (DSGVO)?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn verbessert den Datenschutz gegen\u00fcber Passw\u00f6rtern erheblich. Der Server speichert ausschlie\u00dflich den \u00f6ffentlichen Schl\u00fcssel, keine biometrischen Daten (diese verarbeitet ausschlie\u00dflich das Ger\u00e4t des Nutzers lokal). Jedes Credential ist au\u00dferdem f\u00fcr genau eine Relying Party ausgestellt, wodurch Cross-Service-Tracking verhindert wird. Aus DSGVO-Sicht ist der \u00f6ffentliche Schl\u00fcssel ein personenbezogenes Datum, da er einer Person zugeordnet werden kann. Verschl\u00fcsselung at rest und Zugriffsprotokolle f\u00fcr die Credentials-Tabelle sind daher verpflichtend.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"was-passiert-wenn-ein-nutzer-sein-geraet-verliert\">Was passiert, wenn ein Nutzer sein Ger\u00e4t verliert?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Bei Sync-Passkeys (iCloud Keychain, Google Password Manager, Windows Microsoft Authenticator) ist der Passkey auf anderen Ger\u00e4ten desselben Nutzers verf\u00fcgbar, sofern derselbe Plattform-Account verwendet wird. Bei ger\u00e4tegebundenen Passkeys oder physischen Sicherheitsschl\u00fcsseln ohne Backup greift der Recovery-Flow. Empfehle Nutzern aktiv, mindestens zwei Passkeys zu registrieren (beispielsweise iPhone und Mac, oder Telefon und YubiKey). Zeige die Credential-Liste mit Datum der letzten Verwendung, damit Nutzer erkennen, welche Passkeys noch aktiv sind.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"welche-algorithmen-unterstuetzt-simplewebauthn-server-13-x\">Welche Algorithmen unterst\u00fctzt @simplewebauthn\/server 13.x?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">SimpleWebAuthn v13.x unterst\u00fctzt standardm\u00e4\u00dfig <strong>ES256<\/strong> (ECDSA mit P-256-Kurve, Algorithmus-ID -7) und <strong>RS256<\/strong> (RSA-PSS mit SHA-256, Algorithmus-ID -257). Alle aktuellen Plattform-Authenticators auf iOS, Android und Windows Hello verwenden ES256. RS256 ist f\u00fcr \u00e4ltere USB-Sicherheitsschl\u00fcssel relevant. Aktiviere beide in <code>supportedAlgorithmIDs: [-7, -257]<\/code> f\u00fcr maximale Kompatibilit\u00e4t.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"wie-schuetze-ich-webauthn-endpunkte-vor-missbrauch\">Wie sch\u00fctze ich WebAuthn-Endpunkte vor Missbrauch?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Implementiere Rate Limiting auf allen vier Auth-Endpunkten. Empfohlene Limits: maximal 10 Anfragen pro IP und Minute auf Options-Endpunkten, maximal 5 Verifikationsversuche pro Account und Stunde. Bei wiederholten Fehlschl\u00e4gen das Account tempor\u00e4r sperren. Logge alle Verifikationsfehler mit IP-Adresse, User Agent und Timestamp f\u00fcr sp\u00e4tere Forensik. Details zur Implementierung findest du in unserem Artikel <a href=\"\/at\/rate-limiting-nodejs\/\">Rate Limiting in Node.js: 12 Schritte, 35 Min<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"weiterfuehrende-artikel\">Weiterf\u00fchrende Artikel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Diese Artikel erg\u00e4nzen die WebAuthn-Implementierung mit weiteren sicherheitsrelevanten Themen f\u00fcr Node.js:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"\/at\/two-factor-authentication-nodejs\/\">Zwei-Faktor-Authentifizierung in Node.js: 11 Schritte<\/a>, TOTP-Integration als Fallback-Methode neben Passkeys<\/li>\n<li><a href=\"\/at\/jwt-authentication-nodejs\/\">JWT Authentication in Node.js: 10 Schritte<\/a>, Token-basierte Auth f\u00fcr APIs nach WebAuthn-Login<\/li>\n<li><a href=\"\/at\/nodejs-session-management\/\">Node.js Session Management: 11 Schritte<\/a>, sichere Session-Konfiguration nach erfolgreicher WebAuthn-Verifikation<\/li>\n<li><a href=\"\/at\/passkeys-vs-passwords\/\">Passkeys vs. Passw\u00f6rter: 8,5s vs. 31s Anmeldung<\/a>, Sicherheitsvergleich und Adoptionsstatistiken 2026<\/li>\n<li><a href=\"\/at\/oauth2-nodejs\/\">OAuth 2.0 in Node.js: 12 Schritte, 45 Min<\/a>, WebAuthn in OAuth-2.0-Flows einbetten<\/li>\n<li><a href=\"\/at\/rate-limiting-nodejs\/\">Rate Limiting in Node.js: 12 Schritte, 35 Min<\/a>, WebAuthn-Endpunkte vor Brute Force sch\u00fctzen<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Externe Ressourcen:<\/strong> <a href=\"https:\/\/www.w3.org\/TR\/webauthn-3\/\" rel=\"noopener noreferrer\" target=\"_blank\">W3C WebAuthn Level 3 Spezifikation<\/a>, <a href=\"https:\/\/fidoalliance.org\/passkeys\/\" rel=\"noopener noreferrer\" target=\"_blank\">FIDO Alliance: Passkeys<\/a>, <a href=\"https:\/\/simplewebauthn.dev\/\" rel=\"noopener noreferrer\" target=\"_blank\">SimpleWebAuthn Dokumentation<\/a>, <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_Authentication_API\" rel=\"noopener noreferrer\" target=\"_blank\">MDN Web Authentication API<\/a>, <a href=\"https:\/\/passkeys.dev\/\" rel=\"noopener noreferrer\" target=\"_blank\">passkeys.dev (FIDO Alliance Entwicklerressource)<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Passw\u00f6rter sind das schw\u00e4chste Glied jeder Authentifizierungskette. WebAuthn, der W3C-Standard hinter Passkeys, l\u00f6st dieses Problem mit asymmetrischer Kryptografie: Der private Schl\u00fcssel verl\u00e4sst das Ger\u00e4t des Nutzers nie, der Server speichert\u2026<\/p>\n","protected":false},"author":2,"featured_media":127,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-126","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security"],"_links":{"self":[{"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/posts\/126","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/comments?post=126"}],"version-history":[{"count":1,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/posts\/126\/revisions"}],"predecessor-version":[{"id":128,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/posts\/126\/revisions\/128"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/media\/127"}],"wp:attachment":[{"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/media?parent=126"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/categories?post=126"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shattered.io\/at\/wp-json\/wp\/v2\/tags?post=126"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}