{"id":336,"date":"2026-06-23T20:29:25","date_gmt":"2026-06-23T20:29:25","guid":{"rendered":"https:\/\/shattered.io\/de\/passkeys-webauthn-nodejs\/"},"modified":"2026-06-23T20:30:07","modified_gmt":"2026-06-23T20:30:07","slug":"passkeys-webauthn-nodejs","status":"publish","type":"post","link":"https:\/\/shattered.io\/de\/passkeys-webauthn-nodejs\/","title":{"rendered":"Passkeys in Node.js: WebAuthn in 12 Schritten [2026]"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Passw\u00f6rter sind das schw\u00e4chste Glied der meisten Web-Anwendungen. Phishing, Credential Stuffing und wiederverwendete Logins verursachen den Gro\u00dfteil aller Konto\u00fcbernahmen. Passkeys l\u00f6sen dieses Problem an der Wurzel, weil sie auf Public-Key-Kryptografie statt auf geteilten Geheimnissen beruhen. Laut FIDO Alliance besitzen 2025 bereits 69 Prozent der Verbraucher mindestens einen Passkey, und 48 Prozent der 100 gr\u00f6\u00dften Websites unterst\u00fctzen die Anmeldung ohne Passwort. Dieser Leitfaden zeigt Ihnen Schritt f\u00fcr Schritt, wie Sie Passkeys in Node.js mit dem WebAuthn-Standard implementieren, inklusive eines vollst\u00e4ndigen, lauff\u00e4higen Projekts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Wir bauen ein komplettes passwortloses Login-System: ein Express-Backend mit der Bibliothek <code>@simplewebauthn\/server<\/code>, eine SQLite-Datenbank f\u00fcr Nutzer und Credentials sowie ein schlankes Frontend mit <code>@simplewebauthn\/browser<\/code>. Am Ende registrieren und authentifizieren sich Nutzer per Fingerabdruck, Gesichtserkennung oder Hardware-Schl\u00fcssel, ganz ohne Passwort. Stand: 23. Juni 2026. Planen Sie rund 40 Minuten f\u00fcr die Umsetzung ein.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"was-sind-passkeys-webauthn-und-fido2-in-der-praxis\">Was sind Passkeys? WebAuthn und FIDO2 in der Praxis<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ein Passkey ist ein kryptografisches Schl\u00fcsselpaar, das ein Ger\u00e4t oder ein Passwortmanager f\u00fcr eine bestimmte Website erzeugt. Der private Schl\u00fcssel verl\u00e4sst das Ger\u00e4t nie. Der \u00f6ffentliche Schl\u00fcssel wandert zum Server und wird dort gespeichert. Bei der Anmeldung beweist das Ger\u00e4t den Besitz des privaten Schl\u00fcssels durch eine digitale Signatur, ohne das Geheimnis selbst zu \u00fcbertragen. Genau dieser Mechanismus macht Passkeys gegen Phishing immun, denn es gibt kein Passwort, das ein Angreifer abgreifen k\u00f6nnte.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Die technische Grundlage bilden zwei Standards. WebAuthn ist die Browser-API, die das W3C zusammen mit der FIDO Alliance definiert hat. Sie steuert die Kommunikation zwischen Webseite und Authenticator. CTAP2 (Client to Authenticator Protocol) regelt, wie der Browser mit externen Authenticatoren wie einem YubiKey oder dem Smartphone spricht. Zusammen ergeben WebAuthn und CTAP2 das FIDO2-Framework. Wenn dieser Artikel von WebAuthn in Node.js spricht, meint er die serverseitige H\u00e4lfte dieses Protokolls: das Erzeugen von Challenges und das Verifizieren von Signaturen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Der aktuelle Stand der Standardisierung ist eindeutig: WebAuthn Level 2 ist die offizielle W3C-Empfehlung (<a href=\"https:\/\/www.w3.org\/TR\/webauthn-2\/\" target=\"_blank\" rel=\"noopener\">W3C Web Authentication Level 2<\/a>), w\u00e4hrend <a href=\"https:\/\/www.w3.org\/TR\/webauthn-3\/\" target=\"_blank\" rel=\"noopener\">Level 3<\/a> den Standard um Funktionen wie verbesserte Conditional UI erweitert und sich in fortgeschrittener Standardisierung befindet. Alle modernen Browser (Chrome, Safari, Firefox, Edge) und Betriebssysteme unterst\u00fctzen WebAuthn produktiv. Eine gepflegte Referenz f\u00fcr Entwickler ist <a href=\"https:\/\/passkeys.dev\/\" target=\"_blank\" rel=\"noopener\">passkeys.dev<\/a>, ein Gemeinschaftsprojekt der FIDO Alliance.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"registrierung-und-authentifizierung-die-zwei-ceremonies\">Registrierung und Authentifizierung: die zwei Ceremonies<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn kennt genau zwei Abl\u00e4ufe, in der Spezifikation Ceremonies genannt. Die Registrierung (Attestation) erzeugt ein neues Schl\u00fcsselpaar und meldet den \u00f6ffentlichen Schl\u00fcssel beim Server an. Die Authentifizierung (Assertion) nutzt ein bestehendes Schl\u00fcsselpaar, um eine Anmeldung zu signieren. Beide folgen demselben Muster: Der Server erzeugt eine zuf\u00e4llige Challenge, der Browser leitet sie an den Authenticator weiter, das Ger\u00e4t signiert sie nach einer Nutzerbest\u00e4tigung, und der Server pr\u00fcft die Antwort. Diese vier Phasen werden Sie in Node.js exakt nachbauen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"warum-passkeys-phishing-resistent-sind\">Warum Passkeys phishing-resistent sind<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Der entscheidende Schutz liegt in der Origin-Bindung. Ein Passkey ist kryptografisch an die Domain gebunden, f\u00fcr die er erstellt wurde. Versucht eine Phishing-Seite unter einer falschen Domain, eine Anmeldung auszul\u00f6sen, verweigert der Browser die Signatur, weil die Origin nicht \u00fcbereinstimmt. Selbst ein perfekt nachgebautes Login-Formular l\u00e4uft ins Leere. Diese Eigenschaft pr\u00fcft Ihr Server sp\u00e4ter \u00fcber die Parameter <code>expectedOrigin<\/code> und <code>expectedRPID<\/code>. Ein gestohlener \u00f6ffentlicher Schl\u00fcssel n\u00fctzt einem Angreifer ohne das physische Ger\u00e4t und die biometrische Freigabe nichts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Technisch besteht die Antwort des Authenticators bei der Anmeldung aus zwei Teilen: den <code>authenticatorData<\/code> und dem <code>clientDataJSON<\/code>, das die Challenge und die Origin enth\u00e4lt. Der Authenticator signiert die Verkettung dieser Daten mit dem privaten Schl\u00fcssel. Ihr Server berechnet denselben Wert erneut und pr\u00fcft die Signatur mit dem gespeicherten \u00f6ffentlichen Schl\u00fcssel. Stimmt alles, ist bewiesen, dass dieselbe Hardware antwortet, die sich urspr\u00fcnglich registriert hat, und dass die Anmeldung f\u00fcr genau diese Domain bestimmt war. Diese Pr\u00fcfung erledigt <code>verifyAuthenticationResponse<\/code> f\u00fcr Sie, doch zu wissen, was darunter passiert, hilft bei der Fehlersuche enorm.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"passkeys-vs-passwoerter-die-zahlen-fuer-2026\">Passkeys vs. Passw\u00f6rter: die Zahlen f\u00fcr 2026<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Die Verbreitung von Passkeys hat 2025 einen Wendepunkt erreicht. Gro\u00dfe Plattformen melden nicht nur h\u00f6here Sicherheit, sondern auch bessere Conversion, weil sich Nutzer schneller und seltener fehlerhaft anmelden. Die folgende Tabelle fasst die belastbaren Kennzahlen aus offiziellen Quellen zusammen.<\/p>\n\n\n\n<table class=\"wp-block-table\"><thead><tr><th>Kennzahl<\/th><th>Wert<\/th><th>Quelle<\/th><\/tr><\/thead><tbody><tr><td>Verbraucher mit mindestens einem Passkey<\/td><td>69 %<\/td><td>FIDO Alliance (2025)<\/td><\/tr><tr><td>Top-100-Websites mit Passkey-Support<\/td><td>48 %<\/td><td>FIDO Alliance<\/td><\/tr><tr><td>Login-Erfolgsrate Passkey vs. Passwort<\/td><td>93 % vs. 63 %<\/td><td>FIDO Alliance<\/td><\/tr><tr><td>Google: Erfolg gegen\u00fcber Passw\u00f6rtern<\/td><td>4-fach h\u00f6her<\/td><td>Google<\/td><\/tr><tr><td>Google: Wachstum der Passkey-Logins seit Okt. 2023<\/td><td>+352 %<\/td><td>Google<\/td><\/tr><tr><td>Microsoft: Passkey-Standard f\u00fcr neue Konten<\/td><td>seit Mai 2025, +120 % Logins<\/td><td>Microsoft<\/td><\/tr><tr><td>TikTok: Erfolgsrate Passkey-Anmeldung<\/td><td>97 %<\/td><td>TikTok<\/td><\/tr><tr><td>Unternehmen, die Passkeys ausrollen<\/td><td>87 %<\/td><td>HID Global \/ FIDO Alliance<\/td><\/tr><\/tbody><\/table>\n\n\n\n<p class=\"wp-block-paragraph\">Die Botschaft ist klar: Passkeys reduzieren Reibung und Risiko gleichzeitig. Eine Login-Erfolgsrate von 93 Prozent gegen\u00fcber 63 Prozent bei Passw\u00f6rtern bedeutet weniger Support-Tickets und weniger Abbr\u00fcche. F\u00fcr DACH-Unternehmen, die unter NIS2 nachweisbare Authentifizierungsstandards brauchen, sind diese Zahlen ein starkes Argument. Wer den Vergleich zu klassischen Verfahren vertiefen m\u00f6chte, findet in unserem Beitrag zu <a href=\"\/de\/google-authenticator-vs-microsoft-authenticator-aegis\/\">Authenticator-Apps<\/a> eine Einordnung der TOTP-Alternativen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"was-sie-bauen-architektur-des-passkey-logins\">Was Sie bauen: Architektur des Passkey-Logins<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Das Projekt besteht aus vier Endpunkten und einem Datenmodell. Zwei Endpunkte bedienen die Registrierung, zwei die Anmeldung. Jeder erste Endpunkt eines Paares erzeugt Optionen samt Challenge, jeder zweite verifiziert die Antwort des Authenticators. Die Challenge speichern wir kurzfristig pro Nutzer in der Datenbank, der \u00f6ffentliche Schl\u00fcssel landet dauerhaft in einer Credentials-Tabelle.<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><code>POST \/register\/options<\/code> erzeugt Registrierungs-Optionen und eine Challenge.<\/li><li><code>POST \/register\/verify<\/code> pr\u00fcft die Attestation und speichert den \u00f6ffentlichen Schl\u00fcssel.<\/li><li><code>POST \/login\/options<\/code> erzeugt Authentifizierungs-Optionen mit erlaubten Credentials.<\/li><li><code>POST \/login\/verify<\/code> pr\u00fcft die Signatur und aktualisiert den Signaturz\u00e4hler.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Diese Trennung in zwei Schritte pro Ceremony ist kein Zufall, sondern folgt dem Challenge-Response-Prinzip. Der Server muss die Challenge kennen, die er ausgegeben hat, um die signierte Antwort pr\u00fcfen zu k\u00f6nnen. Genau wie bei einem <a href=\"\/de\/ecdh-nodejs-schluesselaustausch\/\">sicheren Schl\u00fcsselaustausch<\/a> h\u00e4ngt die Sicherheit daran, dass die Challenge zuf\u00e4llig, einmalig und zeitlich begrenzt ist.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"voraussetzungen-node-js-bibliotheken-und-versionen\">Voraussetzungen: Node.js, Bibliotheken und Versionen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Bevor Sie loslegen, brauchen Sie eine aktuelle Node.js-Laufzeit und vier npm-Pakete. Pinnen Sie die Versionen, damit Ihr Build reproduzierbar bleibt. Die Bibliothek <code>@simplewebauthn\/server<\/code> verlangt mindestens Node.js 20. Wir empfehlen eine LTS-Version, also Node.js 22 (&#8220;Jod&#8221;) oder 24 (&#8220;Krypton&#8221;).<\/p>\n\n\n\n<table class=\"wp-block-table\"><thead><tr><th>Komponente<\/th><th>Version<\/th><th>Zweck<\/th><\/tr><\/thead><tbody><tr><td>Node.js<\/td><td>22 LTS oder 24 LTS (mind. 20)<\/td><td>Laufzeitumgebung<\/td><\/tr><tr><td>@simplewebauthn\/server<\/td><td>13.3.1<\/td><td>Serverseitige WebAuthn-Logik<\/td><\/tr><tr><td>@simplewebauthn\/browser<\/td><td>13.3.0<\/td><td>Browser-API-Wrapper<\/td><\/tr><tr><td>express<\/td><td>5.2.1<\/td><td>HTTP-Server und Routing<\/td><\/tr><tr><td>better-sqlite3<\/td><td>12.11.1<\/td><td>Speicherung von Nutzern und Credentials<\/td><\/tr><tr><td>helmet<\/td><td>8.2.0<\/td><td>Sicherheits-HTTP-Header<\/td><\/tr><\/tbody><\/table>\n\n\n\n<p class=\"wp-block-paragraph\">Eine Besonderheit von WebAuthn betrifft die Entwicklungsumgebung: Die API funktioniert nur in einem sicheren Kontext, also \u00fcber HTTPS. Eine einzige Ausnahme gilt f\u00fcr <code>localhost<\/code>, das Browser als sicher behandeln. Deshalb k\u00f6nnen Sie das Projekt lokal ohne Zertifikat testen, brauchen in Produktion aber zwingend HTTPS. Wer die Grundlagen sicherer Verbindungen auffrischen will, findet sie in unserem \u00dcberblick zu <a href=\"\/de\/security\/\">Online-Sicherheit<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Wir nutzen <code>better-sqlite3<\/code>, weil es synchron arbeitet, keine separate Datenbank erfordert und sich ideal f\u00fcr ein nachvollziehbares Tutorial eignet. F\u00fcr Produktion ist die Wahl der Datenbank austauschbar: Das Schema aus Nutzern und Credentials l\u00e4sst sich eins zu eins auf PostgreSQL oder MySQL \u00fcbertragen. Wichtig ist allein, dass der \u00f6ffentliche Schl\u00fcssel als Bin\u00e4rtyp gespeichert wird und die Credential-ID eindeutig indiziert ist. Wer hohe Lasten erwartet, sollte zus\u00e4tzlich die Challenge aus der Nutzertabelle in einen schnellen Cache auslagern, um Schreibzugriffe zu reduzieren.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-1-bis-3-projekt-express-server-und-datenbank\">Schritt 1 bis 3: Projekt, Express-Server und Datenbank<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 1: Projekt anlegen.<\/strong> Erstellen Sie einen Ordner und installieren Sie die Abh\u00e4ngigkeiten. Wir nutzen ES-Module, also setzen Sie <code>\"type\": \"module\"<\/code> in der <code>package.json<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>mkdir passkeys-nodejs-demo &amp;&amp; cd passkeys-nodejs-demo\nnpm init -y\nnpm install @simplewebauthn\/server@13.3.1 @simplewebauthn\/browser@13.3.0 \\\n  express@5.2.1 better-sqlite3@12.11.1 helmet@8.2.0\nnode --version   # erwartet v22.x oder v24.x<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die fertige <code>package.json<\/code> sollte die folgenden Felder enthalten. Das Feld <code>\"type\": \"module\"<\/code> ist entscheidend, sonst schlagen die <code>import<\/code>-Anweisungen fehl.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"name\": \"passkeys-nodejs-demo\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"node app.js\"\n  },\n  \"dependencies\": {\n    \"@simplewebauthn\/server\": \"13.3.1\",\n    \"better-sqlite3\": \"12.11.1\",\n    \"express\": \"5.2.1\",\n    \"helmet\": \"8.2.0\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 2: Datenbank-Schema definieren.<\/strong> Legen Sie die Datei <code>db.js<\/code> an. Wir speichern Nutzer mit einer stabilen ID und ihrer aktuellen Challenge sowie Credentials mit \u00f6ffentlichem Schl\u00fcssel, Signaturz\u00e4hler und Transports. Der \u00f6ffentliche Schl\u00fcssel ist ein Byte-Array und wird als BLOB abgelegt.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import Database from 'better-sqlite3';\n\nconst db = new Database('passkeys.db');\ndb.pragma('journal_mode = WAL');\n\ndb.exec(`\n  CREATE TABLE IF NOT EXISTS users (\n    id TEXT PRIMARY KEY,\n    username TEXT UNIQUE NOT NULL,\n    current_challenge TEXT\n  );\n  CREATE TABLE IF NOT EXISTS credentials (\n    id TEXT PRIMARY KEY,          -- Credential-ID als Base64URL\n    user_id TEXT NOT NULL,\n    public_key BLOB NOT NULL,     -- COSE-Public-Key als Bytes\n    counter INTEGER NOT NULL,\n    transports TEXT,              -- JSON-Array, z. B. [\"internal\",\"hybrid\"]\n    FOREIGN KEY (user_id) REFERENCES users(id)\n  );\n`);\n\nexport default db;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 3: Express-Server aufsetzen.<\/strong> Erstellen Sie <code>app.js<\/code> mit dem Grundger\u00fcst. Hier importieren wir die vier WebAuthn-Funktionen, liefern statische Dateien aus und definieren die Konfiguration. Die Werte <code>rpID<\/code> und <code>origin<\/code> lesen wir aus Umgebungsvariablen, mit sinnvollen Standardwerten f\u00fcr die lokale Entwicklung.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import express from 'express';\nimport helmet from 'helmet';\nimport { randomUUID } from 'node:crypto';\nimport {\n  generateRegistrationOptions,\n  verifyRegistrationResponse,\n  generateAuthenticationOptions,\n  verifyAuthenticationResponse,\n} from '@simplewebauthn\/server';\nimport db from '.\/db.js';\n\nconst app = express();\napp.use(express.json());\napp.use(express.static('public'));\n\n\/\/ Relying Party und Origin: in Produktion aus Umgebungsvariablen\nconst rpName = 'Passkey Demo';\nconst rpID = process.env.RP_ID || 'localhost';\nconst origin = process.env.ORIGIN || `http:\/\/${rpID}:3000`;\n\nconst PORT = process.env.PORT || 3000;\napp.listen(PORT, () => console.log(`Server laeuft auf ${origin}`));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die <code>rpID<\/code> ist die Relying-Party-ID, also Ihre registrierbare Domain ohne Schema und Port. Lokal ist das <code>localhost<\/code>, in Produktion etwa <code>example.com<\/code>. Die <code>origin<\/code> muss exakt mit der Adresse \u00fcbereinstimmen, unter der die Seite l\u00e4uft, inklusive <code>https:\/\/<\/code>. Stimmen diese Werte nicht, scheitert jede Verifikation. Das ist die h\u00e4ufigste Fehlerquelle bei der Implementierung von Passkeys in Node.js.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-4-und-5-passkey-registrierung-in-node-js\">Schritt 4 und 5: Passkey-Registrierung in Node.js<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 4: Registrierungs-Optionen erzeugen.<\/strong> Dieser Endpunkt erstellt einen Nutzer, falls er noch nicht existiert, und gibt die Optionen f\u00fcr den Browser zur\u00fcck. Wichtig sind <code>excludeCredentials<\/code>, damit ein Nutzer denselben Authenticator nicht doppelt registriert, sowie <code>supportedAlgorithmIDs<\/code> mit den Werten -7 (ES256) und -257 (RS256), den am weitesten verbreiteten Signaturalgorithmen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/register\/options', async (req, res) => {\n  const { username } = req.body;\n  if (!username) return res.status(400).json({ error: 'username fehlt' });\n\n  let user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);\n  if (!user) {\n    const id = randomUUID();\n    db.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, username);\n    user = { id, username };\n  }\n\n  const existing = db\n    .prepare('SELECT id, transports FROM credentials WHERE user_id = ?')\n    .all(user.id);\n\n  const options = await generateRegistrationOptions({\n    rpName,\n    rpID,\n    userName: user.username,\n    userID: new TextEncoder().encode(user.id),\n    attestationType: 'none',\n    excludeCredentials: existing.map((c) => ({\n      id: c.id,\n      transports: JSON.parse(c.transports || '[]'),\n    })),\n    authenticatorSelection: {\n      residentKey: 'preferred',\n      userVerification: 'preferred',\n    },\n    supportedAlgorithmIDs: [-7, -257],\n  });\n\n  db.prepare('UPDATE users SET current_challenge = ? WHERE id = ?')\n    .run(options.challenge, user.id);\n\n  res.json(options);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Beachten Sie, dass <code>generateRegistrationOptions<\/code> in Version 13 asynchron ist und mit <code>await<\/code> aufgerufen werden muss. Die zur\u00fcckgegebene <code>options.challenge<\/code> speichern wir auf dem Nutzerdatensatz. Eine typische Antwort an den Browser sieht gek\u00fcrzt so aus:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"challenge\": \"k3Q9c1J...gekuerzt...\",\n  \"rp\": { \"name\": \"Passkey Demo\", \"id\": \"localhost\" },\n  \"user\": { \"id\": \"Yz...id\", \"name\": \"alice\", \"displayName\": \"alice\" },\n  \"pubKeyCredParams\": [\n    { \"alg\": -7, \"type\": \"public-key\" },\n    { \"alg\": -257, \"type\": \"public-key\" }\n  ],\n  \"timeout\": 60000,\n  \"attestation\": \"none\",\n  \"authenticatorSelection\": {\n    \"residentKey\": \"preferred\",\n    \"userVerification\": \"preferred\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 5: Attestation verifizieren und Schl\u00fcssel speichern.<\/strong> Der zweite Endpunkt pr\u00fcft die Antwort des Authenticators gegen die gespeicherte Challenge, die erwartete Origin und die erwartete RP-ID. Bei Erfolg legen wir Credential-ID, \u00f6ffentlichen Schl\u00fcssel, Z\u00e4hler und Transports in der Datenbank ab.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/register\/verify', async (req, res) => {\n  const { username, response } = req.body;\n  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);\n  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });\n\n  let verification;\n  try {\n    verification = await verifyRegistrationResponse({\n      response,\n      expectedChallenge: user.current_challenge,\n      expectedOrigin: origin,\n      expectedRPID: rpID,\n      requireUserVerification: false,\n    });\n  } catch (err) {\n    return res.status(400).json({ error: err.message });\n  }\n\n  const { verified, registrationInfo } = verification;\n  if (verified &amp;&amp; registrationInfo) {\n    const { credential } = registrationInfo;\n    db.prepare(\n      `INSERT OR REPLACE INTO credentials\n       (id, user_id, public_key, counter, transports)\n       VALUES (?, ?, ?, ?, ?)`\n    ).run(\n      credential.id,\n      user.id,\n      Buffer.from(credential.publicKey),\n      credential.counter,\n      JSON.stringify(credential.transports || [])\n    );\n  }\n\n  res.json({ verified });\n});<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"challenge-sicher-speichern\">Challenge sicher speichern<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In diesem Tutorial liegt die Challenge in der Spalte <code>current_challenge<\/code> des Nutzers. Das ist klar nachvollziehbar, hat aber Grenzen. Eine Challenge muss einmalig sein, nach kurzer Zeit ablaufen und nach der Verifikation gel\u00f6scht werden. In Produktion geh\u00f6rt sie in einen serverseitigen Session-Speicher oder einen Cache wie Redis mit einer TTL von 60 bis 120 Sekunden. Speichern Sie Challenges niemals im Browser, etwa in <code>localStorage<\/code>, denn dann verlieren Sie die Replay-Schutzgarantie. In Version 13 erwartet <code>verifyRegistrationResponse<\/code> die Felder <code>registrationInfo.credential.id<\/code> (Base64URL-String), <code>publicKey<\/code> (Byte-Array) und <code>counter<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-6-und-7-anmeldung-mit-webauthn-verifizieren\">Schritt 6 und 7: Anmeldung mit WebAuthn verifizieren<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 6: Authentifizierungs-Optionen erzeugen.<\/strong> F\u00fcr die Anmeldung holt der Server die registrierten Credentials des Nutzers und packt sie in <code>allowCredentials<\/code>. So wei\u00df der Browser, welche Passkeys er anbieten darf. Auch hier speichern wir die neue Challenge.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/login\/options', async (req, res) => {\n  const { username } = req.body;\n  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);\n  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });\n\n  const creds = db\n    .prepare('SELECT id, transports FROM credentials WHERE user_id = ?')\n    .all(user.id);\n\n  const options = await generateAuthenticationOptions({\n    rpID,\n    allowCredentials: creds.map((c) => ({\n      id: c.id,\n      transports: JSON.parse(c.transports || '[]'),\n    })),\n    userVerification: 'preferred',\n  });\n\n  db.prepare('UPDATE users SET current_challenge = ? WHERE id = ?')\n    .run(options.challenge, user.id);\n\n  res.json(options);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 7: Signatur pr\u00fcfen und Z\u00e4hler aktualisieren.<\/strong> Der letzte Endpunkt l\u00e4dt das passende Credential anhand der vom Browser gelieferten <code>response.id<\/code>, rekonstruiert den \u00f6ffentlichen Schl\u00fcssel aus dem BLOB und ruft <code>verifyAuthenticationResponse<\/code> auf. Nach erfolgreicher Pr\u00fcfung schreiben wir den neuen Signaturz\u00e4hler zur\u00fcck. Dieser Z\u00e4hler erkennt geklonte Authenticatoren: Sinkt er, stimmt etwas nicht.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/login\/verify', async (req, res) => {\n  const { username, response } = req.body;\n  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);\n  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });\n\n  const cred = db.prepare('SELECT * FROM credentials WHERE id = ?').get(response.id);\n  if (!cred || cred.user_id !== user.id) {\n    return res.status(400).json({ error: 'Passkey nicht gefunden' });\n  }\n\n  let verification;\n  try {\n    verification = await verifyAuthenticationResponse({\n      response,\n      expectedChallenge: user.current_challenge,\n      expectedOrigin: origin,\n      expectedRPID: rpID,\n      credential: {\n        id: cred.id,\n        publicKey: new Uint8Array(cred.public_key),\n        counter: cred.counter,\n        transports: JSON.parse(cred.transports || '[]'),\n      },\n      requireUserVerification: false,\n    });\n  } catch (err) {\n    return res.status(400).json({ error: err.message });\n  }\n\n  const { verified, authenticationInfo } = verification;\n  if (verified) {\n    db.prepare('UPDATE credentials SET counter = ? WHERE id = ?')\n      .run(authenticationInfo.newCounter, cred.id);\n  }\n\n  res.json({ verified });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wichtig f\u00fcr Version 13: Der Parameter hei\u00dft <code>credential<\/code> (nicht mehr <code>authenticator<\/code> wie in \u00e4lteren Versionen) und erwartet ein Objekt mit <code>id<\/code>, <code>publicKey<\/code> und <code>counter<\/code>. Verwenden Sie veraltete Tutorials, schl\u00e4gt der Aufruf mit kryptischen Fehlern fehl. Das Signieren von Daten mit asymmetrischen Schl\u00fcsseln vertieft unser Beitrag zu <a href=\"\/de\/ecdsa-nodejs-signaturen\/\">ECDSA-Signaturen in Node.js<\/a>, denn ES256 ist exakt der Algorithmus, den die meisten Passkeys verwenden.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-8-und-9-das-frontend-mit-simplewebauthn-browser\">Schritt 8 und 9: Das Frontend mit @simplewebauthn\/browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 8: HTML und Browser-Logik.<\/strong> Legen Sie die Datei <code>public\/index.html<\/code> an. Die Bibliothek <code>@simplewebauthn\/browser<\/code> kapselt die komplexe WebAuthn-API in zwei Funktionen: <code>startRegistration<\/code> und <code>startAuthentication<\/code>. In Version 13 erwarten beide ein Objekt mit dem Feld <code>optionsJSON<\/code>. F\u00fcr die Demo laden wir das ESM-Bundle vom CDN.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;!DOCTYPE html>\n&lt;html lang=\"de\">\n&lt;head>\n  &lt;meta charset=\"utf-8\" \/>\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \/>\n  &lt;title>Passkeys Demo&lt;\/title>\n&lt;\/head>\n&lt;body>\n  &lt;h1>Passwortlos anmelden mit Passkeys&lt;\/h1>\n  &lt;input id=\"username\" placeholder=\"Benutzername\" \/>\n  &lt;button id=\"register\">Passkey registrieren&lt;\/button>\n  &lt;button id=\"login\">Mit Passkey anmelden&lt;\/button>\n  &lt;pre id=\"log\">&lt;\/pre>\n\n  &lt;script type=\"module\">\n    import {\n      startRegistration,\n      startAuthentication,\n    } from 'https:\/\/cdn.jsdelivr.net\/npm\/@simplewebauthn\/browser@13\/+esm';\n\n    const log = (m) => (document.getElementById('log').textContent += m + '\\n');\n    const user = () => document.getElementById('username').value.trim();\n\n    async function post(url, data) {\n      const r = await fetch(url, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\/json' },\n        body: JSON.stringify(data),\n      });\n      return r.json();\n    }\n\n    document.getElementById('register').onclick = async () => {\n      const username = user();\n      const optionsJSON = await post('\/register\/options', { username });\n      const att = await startRegistration({ optionsJSON });\n      const out = await post('\/register\/verify', { username, response: att });\n      log(out.verified ? 'Passkey registriert' : 'Fehler: ' + JSON.stringify(out));\n    };\n\n    document.getElementById('login').onclick = async () => {\n      const username = user();\n      const optionsJSON = await post('\/login\/options', { username });\n      const asr = await startAuthentication({ optionsJSON });\n      const out = await post('\/login\/verify', { username, response: asr });\n      log(out.verified ? 'Anmeldung erfolgreich' : 'Fehler: ' + JSON.stringify(out));\n    };\n  &lt;\/script>\n&lt;\/body>\n&lt;\/html><\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 9: Erster Testlauf.<\/strong> Starten Sie den Server mit <code>npm start<\/code> und \u00f6ffnen Sie <code>http:\/\/localhost:3000<\/code>. Geben Sie einen Benutzernamen ein und klicken Sie auf &#8220;Passkey registrieren&#8221;. Ihr Betriebssystem fragt nach Fingerabdruck, Gesicht oder PIN. Anschlie\u00dfend testen Sie die Anmeldung. In der Konsole und im Browser sehen Sie etwa:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ npm start\nServer laeuft auf http:\/\/localhost:3000\n# nach Registrierung im Browser:\n{ \"verified\": true }\n# nach Anmeldung im Browser:\n{ \"verified\": true }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Die Bibliothek <code>@simplewebauthn\/browser<\/code> \u00fcbernimmt die Base64URL-Kodierung und die Umwandlung in die nativen <code>ArrayBuffer<\/code>, die die Browser-API verlangt. Sie m\u00fcssen sich also nicht mit der rohen <code>navigator.credentials<\/code>-Schnittstelle befassen. Details zur zugrunde liegenden API liefert die <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_Authentication_API\" target=\"_blank\" rel=\"noopener\">MDN-Dokumentation zur Web Authentication API<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"schritt-10-bis-12-helmet-https-und-produktivbetrieb\">Schritt 10 bis 12: Helmet, HTTPS und Produktivbetrieb<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 10: Sicherheits-Header mit Helmet.<\/strong> Da wir das Browser-Bundle vom CDN laden, m\u00fcssen Sie die Content-Security-Policy anpassen, sonst blockiert Helmet das Skript. F\u00fcgen Sie Helmet mit einer expliziten CSP hinzu, bevor die statischen Dateien ausgeliefert werden.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.use(\n  helmet({\n    contentSecurityPolicy: {\n      directives: {\n        defaultSrc: [\"'self'\"],\n        scriptSrc: [\"'self'\", 'https:\/\/cdn.jsdelivr.net'],\n        connectSrc: [\"'self'\"],\n      },\n    },\n  })\n);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr Produktion sollten Sie das Bundle lokal ausliefern statt vom CDN und die CSP weiter versch\u00e4rfen. Wie Sie eine restriktive Richtlinie aufbauen, erkl\u00e4rt unser Leitfaden zu <a href=\"\/de\/content-security-policy-nodejs\/\">Content Security Policy in Node.js<\/a> sowie der Beitrag zu <a href=\"\/de\/http-security-headers-nodejs-helmet\/\">HTTP-Security-Headern mit Helmet<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 11: Konfiguration f\u00fcr Produktion.<\/strong> WebAuthn ist streng bei Origin und RP-ID. Setzen Sie die Werte \u00fcber Umgebungsvariablen, niemals fest verdrahtet. Eine <code>.env<\/code>-Datei f\u00fcr Ihre Domain sieht so aus:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>RP_ID=example.com\nORIGIN=https:\/\/example.com\nPORT=3000<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Schritt 12: HTTPS erzwingen.<\/strong> In Produktion muss die Seite \u00fcber HTTPS laufen, sonst stellt der Browser die WebAuthn-API gar nicht erst bereit. Setzen Sie einen Reverse Proxy wie Nginx oder Caddy mit g\u00fcltigem TLS-Zertifikat davor. Achten Sie darauf, dass die <code>origin<\/code> exakt mit der aufgerufenen Adresse \u00fcbereinstimmt, inklusive Subdomain. Ein Passkey, der f\u00fcr <code>app.example.com<\/code> registriert wurde, funktioniert nicht unter <code>example.com<\/code>, wenn die RP-ID falsch gesetzt ist.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"das-komplette-projekt-im-ueberblick\">Das komplette Projekt im \u00dcberblick<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ihr fertiges Projekt umfasst vier Dateien. Die Routen aus den Schritten 4 bis 7 f\u00fcgen Sie in <code>app.js<\/code> zwischen Konfiguration und <code>app.listen<\/code> ein. Die folgende Struktur fasst alles zusammen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>passkeys-nodejs-demo\/\n\u251c\u2500\u2500 app.js              # Express-Server + 4 WebAuthn-Routen\n\u251c\u2500\u2500 db.js               # better-sqlite3 Schema und Verbindung\n\u251c\u2500\u2500 package.json        # type: module, Abhaengigkeiten\n\u251c\u2500\u2500 passkeys.db         # wird beim Start automatisch erzeugt\n\u2514\u2500\u2500 public\/\n    \u2514\u2500\u2500 index.html      # Frontend mit @simplewebauthn\/browser\n\n# Starten:\nnpm start\n\n# Ausgabe:\nServer laeuft auf http:\/\/localhost:3000<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Dieses Grundger\u00fcst ist bewusst minimal, aber funktional vollst\u00e4ndig. Es deckt beide Ceremonies, die persistente Speicherung und die Frontend-Integration ab. F\u00fcr eine echte Anwendung erg\u00e4nzen Sie Session-Management nach erfolgreicher Anmeldung, eine saubere Fehlerbehandlung und ein Fallback, etwa eine <a href=\"\/de\/oauth-pkce-nodejs\/\">OAuth-Anmeldung mit PKCE<\/a> f\u00fcr Ger\u00e4te ohne Passkey-Unterst\u00fctzung.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"passkeys-lokal-testen-virtuelle-authenticatoren-in-chrome\">Passkeys lokal testen: virtuelle Authenticatoren in Chrome<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr automatisierte Tests und schnelle Iterationen m\u00fcssen Sie nicht bei jedem Durchlauf den Finger auf den Sensor legen. Chrome und Edge bringen einen virtuellen Authenticator mit, der einen echten Passkey emuliert. So testen Sie beide Ceremonies in Sekunden, auch auf einem Rechner ohne Biometrie-Hardware.<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li>\u00d6ffnen Sie die Entwicklertools mit F12 und wechseln Sie \u00fcber das Drei-Punkte-Men\u00fc zu &#8220;More tools&#8221; und dann &#8220;WebAuthn&#8221;.<\/li><li>Aktivieren Sie die Option &#8220;Enable virtual authenticator environment&#8221;.<\/li><li>F\u00fcgen Sie einen neuen Authenticator hinzu, etwa mit Protokoll &#8220;ctap2&#8221;, Transport &#8220;internal&#8221; und aktivierter Option &#8220;Supports resident keys&#8221;.<\/li><li>Registrieren Sie nun im Browser einen Passkey. Der virtuelle Authenticator \u00fcbernimmt die Nutzerbest\u00e4tigung automatisch.<\/li><li>Melden Sie sich an. In der WebAuthn-Tabelle sehen Sie die gespeicherten Credentials samt steigendem Signaturz\u00e4hler.<\/li><\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Der virtuelle Authenticator ist ideal, um die Datenfl\u00fcsse zu verstehen und Edge Cases zu pr\u00fcfen, etwa was passiert, wenn ein Nutzer mehrere Passkeys besitzt. F\u00fcr End-to-End-Tests mit Playwright l\u00e4sst sich der virtuelle Authenticator \u00fcber das CDP-Protokoll (Chrome DevTools Protocol) programmatisch steuern, sodass Ihre CI-Pipeline die komplette Anmeldung ohne menschliches Eingreifen durchspielt. Achten Sie darauf, dass diese Tests dieselbe Origin und RP-ID verwenden wie Ihre Anwendung, sonst schlagen sie aus denselben Gr\u00fcnden fehl wie ein echter Login mit falscher Konfiguration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ein besonders wertvoller Testfall betrifft den Signaturz\u00e4hler. Erh\u00f6hen Sie im virtuellen Authenticator den Z\u00e4hler k\u00fcnstlich oder setzen Sie ihn zur\u00fcck, und pr\u00fcfen Sie, ob Ihr Backend einen r\u00fcckl\u00e4ufigen Wert korrekt als Warnsignal behandelt. In Produktion deutet ein sinkender Z\u00e4hler auf einen geklonten Authenticator hin. Viele Hardware-Schl\u00fcssel z\u00e4hlen jedoch gar nicht hoch und melden konstant null. Entscheiden Sie bewusst, ob Sie bei einem unplausiblen Z\u00e4hler die Anmeldung blockieren oder nur ein Audit-Log schreiben, und decken Sie beide Pfade mit Tests ab.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"robuste-fehlerbehandlung-im-frontend\">Robuste Fehlerbehandlung im Frontend<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ein produktionsreifes Passkey-Frontend muss mehr k\u00f6nnen als den Erfolgsfall. Nutzer brechen den Dialog ab, \u00e4ltere Browser kennen WebAuthn nicht, und manchmal liefert der Authenticator gar keine Antwort. Die Bibliothek <code>@simplewebauthn\/browser<\/code> exportiert daf\u00fcr Hilfsfunktionen und wirft typisierte Fehler, die Sie gezielt abfangen sollten.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pr\u00fcfen Sie zuerst, ob der Browser WebAuthn \u00fcberhaupt unterst\u00fctzt, und behandeln Sie den Abbruch durch den Nutzer separat. Bricht jemand den System-Dialog ab, wirft der Browser einen <code>NotAllowedError<\/code>. Das ist kein echter Fehler, sondern eine bewusste Entscheidung, und sollte nicht als rote Fehlermeldung erscheinen.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import {\n  startRegistration,\n  browserSupportsWebAuthn,\n} from 'https:\/\/cdn.jsdelivr.net\/npm\/@simplewebauthn\/browser@13\/+esm';\n\nasync function registrieren(username, optionsJSON) {\n  if (!browserSupportsWebAuthn()) {\n    return zeigeHinweis('Ihr Browser unterstuetzt keine Passkeys.');\n  }\n  try {\n    return await startRegistration({ optionsJSON });\n  } catch (err) {\n    if (err.name === 'NotAllowedError') {\n      zeigeHinweis('Vorgang abgebrochen.');\n    } else if (err.name === 'InvalidStateError') {\n      zeigeHinweis('Dieser Passkey ist bereits registriert.');\n    } else {\n      zeigeHinweis('Unerwarteter Fehler: ' + err.message);\n    }\n    return null;\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Der <code>InvalidStateError<\/code> tritt auf, wenn ein Nutzer versucht, einen bereits registrierten Authenticator erneut anzumelden, genau das verhindert <code>excludeCredentials<\/code> serverseitig. Bieten Sie zus\u00e4tzlich immer einen alternativen Anmeldeweg an, damit niemand vor einer Wand steht, falls Passkeys auf dem aktuellen Ger\u00e4t nicht verf\u00fcgbar sind. Eine klare Nutzerf\u00fchrung entscheidet dar\u00fcber, ob die Adoption gelingt. Zeigen Sie verst\u00e4ndliche Texte statt technischer Fehlercodes, und protokollieren Sie die Originalfehler serverseitig f\u00fcr die sp\u00e4tere Analyse.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"7-haeufige-stolperfallen-bei-passkeys-in-node-js\">7 h\u00e4ufige Stolperfallen bei Passkeys in Node.js<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Die meisten Fehler bei der Implementierung von Passkeys in Node.js entstehen aus wenigen, immer gleichen Ursachen. Wer diese sieben Fallen kennt, spart sich Stunden der Fehlersuche.<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li><strong>Falsche Origin oder RP-ID.<\/strong> Schon ein abweichender Port oder ein fehlendes <code>https:\/\/<\/code> l\u00e4sst jede Verifikation scheitern. Origin und RP-ID m\u00fcssen exakt zur aufgerufenen Adresse passen.<\/li><li><strong>Veraltete API-Signaturen.<\/strong> Version 13 nutzt <code>credential<\/code> statt <code>authenticator<\/code> und <code>registrationInfo.credential.id<\/code> statt <code>credentialID<\/code>. Alte Beispiele aus dem Netz brechen.<\/li><li><strong>Synchrone Aufrufe.<\/strong> Alle vier Hauptfunktionen sind asynchron. Ohne <code>await<\/code> erhalten Sie ein Promise statt der Optionen.<\/li><li><strong>Challenge nicht gespeichert.<\/strong> Wer die Challenge nicht serverseitig ablegt oder mehrfach verwendet, \u00f6ffnet Replay-Angriffe oder bekommt Verifikationsfehler.<\/li><li><strong>Public Key falsch serialisiert.<\/strong> Der Schl\u00fcssel ist ein Byte-Array. Speichern Sie ihn als BLOB und lesen Sie ihn mit <code>new Uint8Array(...)<\/code> zur\u00fcck, nicht als String.<\/li><li><strong>HTTP statt HTTPS in Produktion.<\/strong> Au\u00dferhalb von <code>localhost<\/code> verweigert der Browser WebAuthn ohne sicheren Kontext.<\/li><li><strong>excludeCredentials vergessen.<\/strong> Ohne diese Liste registriert ein Nutzer denselben Authenticator mehrfach, was zu Verwirrung und doppelten Datens\u00e4tzen f\u00fchrt.<\/li><\/ol>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"troubleshooting-8-typische-fehler-und-loesungen\">Troubleshooting: 8 typische Fehler und L\u00f6sungen<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Tritt ein Problem auf, hilft diese Tabelle bei der schnellen Diagnose. Sie deckt die acht h\u00e4ufigsten Fehlermeldungen und Symptome ab, die bei WebAuthn in Node.js auftreten.<\/p>\n\n\n\n<table class=\"wp-block-table\"><thead><tr><th>Symptom \/ Fehler<\/th><th>Ursache<\/th><th>L\u00f6sung<\/th><\/tr><\/thead><tbody><tr><td>&#8220;Unexpected authentication response origin&#8221;<\/td><td>origin stimmt nicht mit der Adresse \u00fcberein<\/td><td>ORIGIN exakt setzen, inkl. Schema und Port<\/td><\/tr><tr><td>&#8220;Unexpected RP ID hash&#8221;<\/td><td>rpID passt nicht zur Domain<\/td><td>RP_ID auf registrierbare Domain ohne Schema setzen<\/td><\/tr><tr><td>&#8220;Challenge mismatch&#8221;<\/td><td>Challenge nicht gespeichert oder bereits verbraucht<\/td><td>Challenge pro Ceremony speichern und danach l\u00f6schen<\/td><\/tr><tr><td>WebAuthn-API ist undefined<\/td><td>Seite l\u00e4uft \u00fcber HTTP statt HTTPS<\/td><td>localhost nutzen oder TLS-Zertifikat einrichten<\/td><\/tr><tr><td>&#8220;is not a function&#8221; bei startRegistration<\/td><td>Falsche Parameterform der Browser-Lib v13<\/td><td>startRegistration({ optionsJSON }) verwenden<\/td><\/tr><tr><td>credential.publicKey ist leer<\/td><td>BLOB falsch gelesen<\/td><td>new Uint8Array(row.public_key) verwenden<\/td><\/tr><tr><td>&#8220;authenticator is not defined&#8221;<\/td><td>Veraltete v9-API benutzt<\/td><td>Parameter credential in v13 verwenden<\/td><\/tr><tr><td>better-sqlite3 l\u00e4sst sich nicht installieren<\/td><td>Node-Version unter 20 oder fehlende Build-Tools<\/td><td>Node 22 oder 24 nutzen, Build-Tools installieren<\/td><\/tr><\/tbody><\/table>\n\n\n\n<p class=\"wp-block-paragraph\">Aktivieren Sie bei der Fehlersuche das Debug-Logging, indem Sie die komplette Fehlermeldung aus dem <code>catch<\/code>-Block protokollieren. Die Bibliothek liefert pr\u00e4zise Hinweise, etwa welche Origin sie erwartet hat. Vergleichen Sie diese mit Ihrer Konfiguration. In neun von zehn F\u00e4llen liegt der Fehler bei <code>origin<\/code> oder <code>rpID<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"fortgeschrittene-techniken-discoverable-credentials-und-conditional-ui\">Fortgeschrittene Techniken: Discoverable Credentials und Conditional UI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Unser Projekt nutzt den Ablauf &#8220;Benutzername zuerst&#8221;: Der Nutzer tippt seinen Namen, dann bietet der Browser den passenden Passkey an. Moderne Passkey-Erlebnisse gehen weiter und kommen ganz ohne Benutzername aus. Daf\u00fcr gibt es zwei Bausteine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Discoverable Credentials (fr\u00fcher Resident Keys) speichern die Nutzerkennung direkt im Authenticator. Setzen Sie dazu <code>residentKey: 'required'<\/code> bei der Registrierung. Bei der Anmeldung lassen Sie <code>allowCredentials<\/code> leer, und der Browser zeigt alle passenden Passkeys f\u00fcr die Domain an. Conditional UI (Autofill) blendet verf\u00fcgbare Passkeys direkt im Login-Feld ein, sobald der Nutzer es antippt. Im Frontend aktivieren Sie das \u00fcber <code>startAuthentication({ optionsJSON, useBrowserAutofill: true })<\/code> in Kombination mit dem Attribut <code>autocomplete=\"username webauthn\"<\/code> am Eingabefeld.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"geraete-gebundene-vs-synchronisierte-passkeys\">Ger\u00e4te-gebundene vs. synchronisierte Passkeys<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Passkeys gibt es in zwei Auspr\u00e4gungen. Synchronisierte Passkeys werden \u00fcber die Cloud des Anbieters (Apple iCloud Schl\u00fcsselbund, Google Passwortmanager, 1Password, Bitwarden) zwischen Ger\u00e4ten geteilt. Sie sind komfortabel und \u00fcberstehen Ger\u00e4teverlust. Ger\u00e4te-gebundene Passkeys, etwa auf einem YubiKey, verlassen die Hardware nie und bieten das h\u00f6chste Schutzniveau. F\u00fcr hochsensible Konten k\u00f6nnen Sie \u00fcber das Feld <code>credentialBackedUp<\/code> aus der Registrierung erkennen, ob ein Passkey synchronisiert wird, und so eine Richtlinie durchsetzen. Welche Passwortmanager Passkeys speichern, vergleicht unser <a href=\"\/de\/passwort-manager-vergleich-2026\/\">Passwortmanager-Vergleich 2026<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"attestation-und-unternehmensrichtlinien\">Attestation und Unternehmensrichtlinien<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr die meisten Anwendungen gen\u00fcgt <code>attestationType: 'none'<\/code>. Unternehmen, die nur zertifizierte Authenticatoren zulassen wollen, setzen <code>'direct'<\/code> und pr\u00fcfen die Attestation-Zertifikatskette gegen den FIDO Metadata Service. Das erh\u00f6ht die Komplexit\u00e4t deutlich und ist nur sinnvoll, wenn regulatorische Vorgaben es verlangen. F\u00fcr die meisten DACH-Unternehmen ist die einfache Variante ohne Attestation der pragmatische und datensparsame Weg.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"passkeys-im-dach-raum-bsi-nis2-und-compliance\">Passkeys im DACH-Raum: BSI, NIS2 und Compliance<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">F\u00fcr Unternehmen in Deutschland, \u00d6sterreich und der Schweiz sind Passkeys mehr als ein Komfortgewinn. Das Bundesamt f\u00fcr Sicherheit in der Informationstechnik empfiehlt FIDO2 und Passkeys ausdr\u00fccklich als phishing-resistente Anmeldemethode (<a href=\"https:\/\/www.bsi.bund.de\/dok\/passkeys\" target=\"_blank\" rel=\"noopener\">BSI: Sicherheit durch Passkeys<\/a>). Mit der NIS2-Richtlinie und ihrer Umsetzung im deutschen BSIG steigt der Druck, starke Authentifizierung nachweisbar einzusetzen. Eine Multi-Faktor-Anmeldung gilt unter NIS2 als Mindeststandard f\u00fcr viele Einrichtungen, und Passkeys erf\u00fcllen diese Anforderung in einem einzigen Schritt, weil Besitz (Ger\u00e4t) und Inh\u00e4renz (Biometrie) kombiniert werden.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Datenschutzrechtlich sind Passkeys vorteilhaft. Es werden keine biometrischen Daten an den Server \u00fcbertragen, denn die biometrische Pr\u00fcfung geschieht lokal auf dem Ger\u00e4t. Der Server speichert lediglich einen \u00f6ffentlichen Schl\u00fcssel, der f\u00fcr sich genommen keinen Personenbezug herstellt und bei einem Datenleck wertlos ist. Anders als gehashte Passw\u00f6rter l\u00e4sst sich ein \u00f6ffentlicher Schl\u00fcssel nicht per Brute Force angreifen. Das reduziert die Folgen eines Einbruchs erheblich und vereinfacht die Argumentation gegen\u00fcber Aufsichtsbeh\u00f6rden.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Planen Sie die Einf\u00fchrung schrittweise. Bieten Sie Passkeys zun\u00e4chst als zus\u00e4tzliche Option neben dem bestehenden Login an, sammeln Sie Erfahrungswerte und machen Sie sie erst dann zur Standardmethode, wie es Microsoft im Mai 2025 f\u00fcr neue Konten getan hat. Halten Sie ein dokumentiertes Wiederherstellungsverfahren bereit, etwa \u00fcber einen zweiten registrierten Passkey oder einen verifizierten E-Mail-Kanal, damit niemand bei Ger\u00e4teverlust ausgesperrt wird.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"passkeys-totp-und-magic-links-im-vergleich\">Passkeys, TOTP und Magic Links im Vergleich<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Passkeys sind nicht die einzige Option f\u00fcr moderne Anmeldung. TOTP-Codes aus einer Authenticator-App und Magic Links per E-Mail haben ihre Berechtigung. Die folgende Tabelle ordnet die drei Verfahren nach den Kriterien ein, die in der Praxis z\u00e4hlen.<\/p>\n\n\n\n<table class=\"wp-block-table\"><thead><tr><th>Kriterium<\/th><th>Passkeys<\/th><th>TOTP (App)<\/th><th>Magic Link<\/th><\/tr><\/thead><tbody><tr><td>Phishing-resistent<\/td><td>Ja (Origin-Bindung)<\/td><td>Nein<\/td><td>Nein<\/td><\/tr><tr><td>Zus\u00e4tzliches Ger\u00e4t n\u00f6tig<\/td><td>Nein<\/td><td>Meist ja<\/td><td>Nein<\/td><\/tr><tr><td>Funktioniert offline<\/td><td>Ja<\/td><td>Ja<\/td><td>Nein (E-Mail n\u00f6tig)<\/td><\/tr><tr><td>Schutz bei Server-Leak<\/td><td>Hoch (nur Public Key)<\/td><td>Mittel (Seed gespeichert)<\/td><td>Gering<\/td><\/tr><tr><td>Nutzerkomfort<\/td><td>Sehr hoch<\/td><td>Mittel<\/td><td>Mittel<\/td><\/tr><\/tbody><\/table>\n\n\n\n<p class=\"wp-block-paragraph\">Der entscheidende Unterschied liegt in der Phishing-Resistenz. TOTP-Codes lassen sich auf einer gef\u00e4lschten Seite genauso abfangen wie Passw\u00f6rter, denn der Nutzer tippt den Code selbst ein. Magic Links sind nur so sicher wie das E-Mail-Konto dahinter und scheitern, wenn die E-Mail verz\u00f6gert oder im Spam landet. Passkeys umgehen beide Probleme, weil die Signatur niemals den Browser verl\u00e4sst und an die korrekte Domain gebunden ist.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In der Praxis schlie\u00dfen sich die Verfahren nicht aus. Eine gute Strategie bietet Passkeys als bevorzugte Methode an, h\u00e4lt TOTP als vertrauten Zweitfaktor f\u00fcr Bestandsnutzer bereit und nutzt Magic Links h\u00f6chstens als Notfall-Wiederherstellung. Wer den direkten Vergleich der TOTP-Apps sucht, findet ihn in unserem Test zu <a href=\"\/de\/google-authenticator-vs-microsoft-authenticator-aegis\/\">Authenticator-Apps<\/a>. So kombinieren Sie h\u00f6chste Sicherheit mit einem Migrationspfad, der niemanden aussperrt.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"haeufig-gestellte-fragen-faq\">H\u00e4ufig gestellte Fragen (FAQ)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"brauche-ich-eine-externe-bibliothek-fuer-passkeys-in-node-js\">Brauche ich eine externe Bibliothek f\u00fcr Passkeys in Node.js?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Technisch nein, praktisch ja. Sie k\u00f6nnten die WebAuthn-Datenstrukturen selbst parsen und Signaturen mit dem Node.js-Crypto-Modul pr\u00fcfen, aber das ist fehleranf\u00e4llig und sicherheitskritisch. Die Bibliothek <code>@simplewebauthn\/server<\/code> kapselt die korrekte Verifikation, das CBOR-Parsing und die Algorithmus-Behandlung. F\u00fcr nahezu alle Projekte ist sie die richtige Wahl.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"funktionieren-passkeys-ohne-smartphone-oder-teure-hardware\">Funktionieren Passkeys ohne Smartphone oder teure Hardware?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja. Jedes moderne Notebook mit Windows Hello, Touch ID oder Android-Ger\u00e4t kann als Authenticator dienen. Ein separater Hardware-Schl\u00fcssel wie ein YubiKey ist optional und vor allem f\u00fcr Hochsicherheitsszenarien sinnvoll. Synchronisierte Passkeys lassen sich zudem \u00fcber die Cloud auf mehreren Ger\u00e4ten nutzen.<\/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 synchronisierten Passkeys sind die Schl\u00fcssel \u00fcber die Cloud auf anderen Ger\u00e4ten verf\u00fcgbar. F\u00fcr ger\u00e4te-gebundene Passkeys sollten Nutzer mindestens zwei Authenticatoren registrieren oder ein Wiederherstellungsverfahren nutzen. Planen Sie diesen Fall von Anfang an ein, denn ein fehlender Wiederherstellungspfad sperrt Nutzer dauerhaft aus.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"sind-passkeys-quantensicher\">Sind Passkeys quantensicher?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Aktuelle Passkeys nutzen ES256 und RS256, also klassische Public-Key-Verfahren, die ein gro\u00dfer Quantencomputer theoretisch brechen k\u00f6nnte. F\u00fcr die absehbare Zukunft sind sie sicher, und die FIDO Alliance arbeitet bereits an post-quantensicheren Algorithmen. Wer sich f\u00fcr quantenresistente Verfahren interessiert, findet in unserem Beitrag zu <a href=\"\/de\/ml-kem-kyber-nodejs\/\">ML-KEM (Kyber) in Node.js<\/a> einen Einstieg.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"ersetzen-passkeys-die-zwei-faktor-authentifizierung\">Ersetzen Passkeys die Zwei-Faktor-Authentifizierung?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ein Passkey vereint zwei Faktoren in einem Schritt: den Besitz des Ger\u00e4ts und die biometrische oder PIN-Best\u00e4tigung. Damit ersetzt er klassische 2FA-Kombinationen aus Passwort plus TOTP-Code in den meisten F\u00e4llen und ist dabei phishing-resistent, was TOTP nicht ist. F\u00fcr besonders sensible Aktionen k\u00f6nnen Sie zus\u00e4tzlich eine erneute Passkey-Best\u00e4tigung verlangen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kann-ich-passkeys-neben-passwoertern-anbieten\">Kann ich Passkeys neben Passw\u00f6rtern anbieten?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja, und genau das ist der empfohlene Migrationspfad. Lassen Sie bestehende Nutzer einen Passkey zu ihrem Konto hinzuf\u00fcgen, w\u00e4hrend das Passwort vorerst als Fallback bestehen bleibt. Sobald gen\u00fcgend Nutzer einen Passkey besitzen, k\u00f6nnen Sie das Passwort optional machen oder ganz entfernen.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"welche-node-js-version-brauche-ich-mindestens\">Welche Node.js-Version brauche ich mindestens?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Die Bibliothek <code>@simplewebauthn\/server<\/code> in Version 13 verlangt mindestens Node.js 20. Empfohlen ist eine aktuelle LTS-Version, also Node.js 22 oder 24, da diese am l\u00e4ngsten Sicherheitsupdates erhalten und besser mit <code>better-sqlite3<\/code> harmonieren.<\/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\"><li><a href=\"\/de\/oauth-pkce-nodejs\/\">OAuth 2.0 mit PKCE in Node.js sicher implementieren<\/a><\/li><li><a href=\"\/de\/ecdsa-nodejs-signaturen\/\">ECDSA-Signaturen in Node.js: digitale Signaturen Schritt f\u00fcr Schritt<\/a><\/li><li><a href=\"\/de\/ecdh-nodejs-schluesselaustausch\/\">ECDH in Node.js: sicherer Schl\u00fcsselaustausch in 12 Schritten<\/a><\/li><li><a href=\"\/de\/http-security-headers-nodejs-helmet\/\">HTTP-Security-Header in Node.js mit Helmet<\/a><\/li><li><a href=\"\/de\/google-authenticator-vs-microsoft-authenticator-aegis\/\">Google vs. Microsoft Authenticator: 2FA-Apps im Test<\/a><\/li><li><a href=\"\/de\/passwort-manager-vergleich-2026\/\">Passwortmanager-Vergleich 2026<\/a><\/li><li><a href=\"\/de\/security\/\">Alle Beitr\u00e4ge zum Thema Cybersicherheit<\/a><\/li><\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"fazit-passwortlos-in-produktion\">Fazit: passwortlos in Produktion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sie haben ein vollst\u00e4ndiges passwortloses Login-System mit Passkeys in Node.js gebaut, von der Datenbank \u00fcber vier WebAuthn-Endpunkte bis zum Frontend. Der Kern ist \u00fcberschaubar: zwei Ceremonies, je zwei Endpunkte, eine sauber gespeicherte Challenge und ein korrekt serialisierter \u00f6ffentlicher Schl\u00fcssel. Die gr\u00f6\u00dften Risiken liegen nicht im Code, sondern in der Konfiguration von Origin und RP-ID sowie im Umgang mit der Challenge.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mit 69 Prozent Verbraucher-Adoption und messbar h\u00f6heren Login-Erfolgsraten sind Passkeys 2026 keine Zukunftstechnologie mehr, sondern Standard. Beginnen Sie mit der hier gezeigten Demo, erg\u00e4nzen Sie Session-Management und einen Wiederherstellungspfad, und rollen Sie Passkeys schrittweise aus. Ihre Nutzer melden sich schneller an, und Phishing verliert seine wichtigste Angriffsfl\u00e4che.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Passw\u00f6rter sind das schw\u00e4chste Glied der meisten Web-Anwendungen. Phishing, Credential Stuffing und wiederverwendete Logins verursachen den Gro\u00dfteil aller Konto\u00fcbernahmen. Passkeys l\u00f6sen dieses Problem an der Wurzel, weil sie auf Public-Key-Kryptografie\u2026<\/p>\n","protected":false},"author":8,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-336","post","type-post","status-publish","format-standard","hentry","category-security"],"_links":{"self":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/336","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\/8"}],"replies":[{"embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/comments?post=336"}],"version-history":[{"count":1,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/336\/revisions"}],"predecessor-version":[{"id":337,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/posts\/336\/revisions\/337"}],"wp:attachment":[{"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/media?parent=336"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/categories?post=336"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shattered.io\/de\/wp-json\/wp\/v2\/tags?post=336"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}