Passwörter sind das schwächste Glied jeder Authentifizierungskette. WebAuthn, der W3C-Standard hinter Passkeys, löst dieses Problem mit asymmetrischer Kryptografie: Der private Schlüssel verlässt das Gerät des Nutzers nie, der Server speichert ausschließlich den öffentlichen Schlüssel. Das Ergebnis ist eine Authentifizierungsmethode, die gegen Phishing, Credential-Stuffing und Brute-Force-Angriffe resistent ist. Diese Schritt-für-Schritt-Anleitung zeigt, wie du WebAuthn in Node.js mit dem Paket @simplewebauthn/server v13.3.1 implementierst, einen vollständigen Registrierungs- und Authentifizierungsflow aufbaust und die Lösung produktionsbereit absicherst.

Was ist WebAuthn? Grundlagen für Node.js-Entwickler

WebAuthn (Web Authentication API) ist ein W3C-Standard, der 2019 in der ersten Version und 2023 in der dritten Version veröffentlicht wurde. Er definiert, wie Webanwendungen Public-Key-Kryptografie zur Benutzerauthentifizierung einsetzen können, ohne Passwörter zu übertragen oder zu speichern. WebAuthn ist der technische Kern des FIDO2-Ökosystems, zu dem auch CTAP (Client to Authenticator Protocol) gehört. Gemeinsam bilden sie den Standard, den Apple, Google, Microsoft und Hunderte anderer Dienste seit 2022 aktiv ausrollen.

Ein WebAuthn-Flow besteht aus zwei Phasen. Während der Registrierung erzeugt das Gerät des Nutzers ein asymmetrisches Schlüsselpaar. Der öffentliche Schlüssel wird auf dem Server gespeichert, der private Schlüssel verbleibt im sicheren Speicher des Authenticators (Secure Enclave bei Apple, TPM-Chip bei Windows, StrongBox Keymaster bei Android oder externer Sicherheitsschlüssel). Während der Authentifizierung signiert der Authenticator eine serverseitige Challenge mit dem privaten Schlüssel. Der Server prüft die Signatur mit dem gespeicherten öffentlichen Schlüssel.

Passkeys sind eine nutzerfreundliche Implementierung von WebAuthn, bei der private Schlüssel zwischen Geräten eines Nutzers synchronisiert werden, über iCloud Keychain, Google Password Manager oder Microsoft Authenticator. Dadurch entfällt die Bindung an ein einzelnes Gerät. Die technische Basis bleibt identisch: FIDO2-konforme Kryptografie, keine shared secrets, keine Passwörter im Klartext oder als Hash auf dem Server.

Drei zentrale Begriffe sind für die Implementierung entscheidend. Die Relying Party (RP) ist deine Webanwendung. Die RP ID ist die Domäne, für die Credentials erstellt werden (zum Beispiel “example.com”). Der Origin ist die vollständige URL des Frontends (“https://example.com”). Diese drei Werte müssen konsistent konfiguriert sein, sonst schlägt die Verifikation mit einem SecurityError fehl.

Die FIDO Alliance berichtet, dass über 13 Milliarden Benutzerkonten bei mehr als 15.000 Diensten weltweit (Stand: Anfang 2026) Passkey-Unterstützung bieten, darunter Google, Apple, Microsoft, GitHub, PayPal und zahlreiche Banken. Die Anmeldung mit einem Passkey dauert durchschnittlich 8,5 Sekunden gegenüber 31 Sekunden für passwortbasierte Logins. Das bedeutet: Passkeys sind nicht nur sicherer, sondern auch schneller.

Voraussetzungen und Technologie-Stack

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).

  • Node.js 22.x LTS oder neuer (mit nativer Web Crypto API)
  • npm 10.x oder neuer
  • @simplewebauthn/server v13.3.1 (Backend-Bibliothek)
  • @simplewebauthn/browser v13.3.0 (Frontend-Helfer)
  • express v5.2.1 (HTTP-Framework)
  • express-session v1.19.0 (Session-Management)
  • Ein moderner Browser: Chrome 108+, Firefox 119+, Safari 16+, Edge 108+
  • HTTPS für die Produktion (localhost funktioniert für Entwicklung ohne HTTPS)

WebAuthn setzt einen sicheren Kontext voraus. Lokal kannst du über http://localhost oder http://127.0.0.1 testen, da Browser diese Adressen als sicher behandeln. In der Produktion ist HTTPS ohne Ausnahmen erforderlich. Selbstsignierte Zertifikate werden in den meisten Browsern für WebAuthn abgelehnt. Für HTTPS-Zertifikate in der Produktion eignet sich Let’s Encrypt, das kostenlose und automatisch erneuerte Zertifikate bereitstellt.

Das Projekt verwendet außerdem better-sqlite3 v9.x für persistente Credential-Speicherung im Produktionsabschnitt. Der erste Teil des Tutorials arbeitet mit einer In-Memory-Datenstruktur, um die Kernlogik ohne Datenbankboilerplate zu vermitteln.

Schritt 1 bis 3: Projektstruktur aufsetzen

Erstelle ein neues Verzeichnis und initialisiere das Node.js-Projekt. Da SimpleWebAuthn v13.x ESModules verwendet, setzt du "type": "module" in der package.json. CommonJS-Projekte müssen zuerst auf ESM migriert werden oder eine Dynamic-Import-Lösung verwenden.

mkdir webauthn-demo && cd webauthn-demo
npm init -y
npm install @simplewebauthn/server @simplewebauthn/browser express express-session
npm install --save-dev nodemon

Öffne die generierte package.json und passe sie wie folgt an:

{
  "name": "webauthn-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js"
  },
  "dependencies": {
    "@simplewebauthn/browser": "^13.3.0",
    "@simplewebauthn/server": "^13.3.1",
    "express": "^5.2.1",
    "express-session": "^1.19.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

Die Verzeichnisstruktur des Projekts sieht so aus:

webauthn-demo/
├── src/
│   ├── server.js        # Express-Server und Routen
│   ├── db.js            # In-Memory-Datenspeicher
│   └── config.js        # RP-Konfiguration
├── public/
│   ├── index.html       # Login-Seite (HTML)
│   └── app.js           # Frontend-JavaScript
└── package.json

Lege zunächst die Konfigurationsdatei an. Die RP ID und der erwartete Origin sind die kritischsten Einstellungen der gesamten Implementierung. Ein Fehler hier führt zu Verifikationsfehlern, die bei der Fehlersuche mehrere Stunden kosten können.

// src/config.js
export const config = {
  rpName: process.env.RP_NAME || 'WebAuthn Demo',
  rpID: process.env.RP_ID || 'localhost',
  expectedOrigin: process.env.EXPECTED_ORIGIN || 'http://localhost:3000',
  port: parseInt(process.env.PORT || '3000', 10),
  sessionSecret: process.env.SESSION_SECRET || 'dev-secret-aendern-in-produktion',
};

Lege danach den In-Memory-Datenspeicher an. Dieser dient ausschließlich der Entwicklung. In der Produktion ersetzt du ihn durch Datenbankabfragen (mehr dazu in Schritt 11).

// src/db.js
import { randomUUID } from 'crypto';

const users = new Map();
const challenges = new Map();

export const db = {
  createUser(username) {
    const user = {
      id: randomUUID(),
      username,
      credentials: [],
      createdAt: new Date().toISOString(),
    };
    users.set(username, user);
    return user;
  },
  getUserByUsername(username) {
    return users.get(username) ?? null;
  },
  setChallenge(userId, challenge) {
    // Challenge läuft nach 5 Minuten ab (einmalige Verwendung erzwingen)
    challenges.set(userId, {
      challenge,
      expiresAt: Date.now() + 5 * 60 * 1000,
    });
  },
  getChallenge(userId) {
    const entry = challenges.get(userId);
    if (!entry) return null;
    if (Date.now() > entry.expiresAt) {
      challenges.delete(userId);
      return null;
    }
    return entry.challenge;
  },
  deleteChallenge(userId) {
    challenges.delete(userId);
  },
  addCredential(userId, credential) {
    const user = [...users.values()].find((u) => u.id === userId);
    if (user) user.credentials.push(credential);
  },
  updateCredentialCounter(userId, credentialID, newCounter) {
    const user = [...users.values()].find((u) => u.id === userId);
    if (!user) return;
    const cred = user.credentials.find((c) => c.credentialID === credentialID);
    if (cred) cred.counter = newCounter;
  },
};

Schritt 4 und 5: Registrierungsoptionen generieren

Die Registrierung beginnt damit, dass der Client beim Server Optionen anfordert. Der Server generiert eine kryptografisch zufällige Challenge mit 32 Bytes Entropie und gibt sie zusammen mit der RP-Konfiguration zurück. Die Challenge ist ein einmaliger Wert, der Replay-Angriffe verhindert. Sie muss serverseitig gespeichert werden, bevor die Antwort gesendet wird.

Richte zuerst den Express-Server in src/server.js ein:

// src/server.js
import express from 'express';
import session from 'express-session';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { config } from './config.js';
import { db } from './db.js';

const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(session({
  secret: config.sessionSecret,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000, // 24 Stunden
  },
}));

// Schritt 4: Registrierungsoptionen anfordern
app.post('/api/auth/register/options', async (req, res) => {
  const { username } = req.body;
  if (!username || username.length < 3) {
    return res.status(400).json({ error: 'Benutzername benötigt mindestens 3 Zeichen' });
  }

  let user = db.getUserByUsername(username);
  if (!user) {
    user = db.createUser(username);
  }

  const options = await generateRegistrationOptions({
    rpName: config.rpName,
    rpID: config.rpID,
    userID: Buffer.from(user.id),
    userName: user.username,
    userDisplayName: user.username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'required',
      authenticatorAttachment: 'platform',
    },
    excludeCredentials: user.credentials.map((cred) => ({
      id: Buffer.from(cred.credentialID, 'base64url'),
      type: 'public-key',
      transports: cred.transports,
    })),
    supportedAlgorithmIDs: [-7, -257], // ES256 (ECDSA P-256), RS256 (RSA-PSS)
  });

  // Schritt 5: Challenge serverseitig speichern (vor Antwort!)
  db.setChallenge(user.id, options.challenge);

  res.json(options);
});

Die Option residentKey: 'preferred' ermöglicht, dass der Authenticator einen Discoverable Credential speichert. Mit attestationType: 'none' verzichtest du auf die Zertifikatsprüfung des Authenticator-Herstellers, was die Implementierung vereinfacht und für die meisten Web-Applikationen ausreicht. Die Zertifikatsprüfung ist nur für hochsicherheitskritische Anwendungen wie Behörden-Logins oder Finanzdienstleister sinnvoll.

Die Option authenticatorAttachment: 'platform' bevorzugt den integrierten Authenticator des Geräts (Face ID, Touch ID, Windows Hello). Für externe Sicherheitsschlüssel wie YubiKey oder Nitrokey setzt du diesen Wert auf 'cross-platform'. Lässt du die Option weg, erlaubst du beide Typen.

Schritt 6: Registrierungsantwort verifizieren

Nach der clientseitigen Erstellung des Credentials sendet der Browser die Attestation-Antwort an den Server. Die Verifikation durch verifyRegistrationResponse() prüft die kryptografische Signatur, den Origin, die RP ID, die Challenge und die Attestation-Daten. Erst nach erfolgreicher Verifikation speicherst du den öffentlichen Schlüssel dauerhaft. Dieser Schritt ist kritisch: Überspringe die Verifikation nie, auch nicht in Tests.

// Fortsetzung src/server.js
app.post('/api/auth/register/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = db.getUserByUsername(username);
  if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });

  const expectedChallenge = db.getChallenge(user.id);
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Keine ausstehende Challenge. Bitte neu starten.' });
  }

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin: config.expectedOrigin,
      expectedRPID: config.rpID,
      requireUserVerification: true,
    });
  } catch (err) {
    console.error('[WebAuthn] Registrierungsverifikation fehlgeschlagen:', err.message);
    return res.status(400).json({ error: err.message });
  }

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ verified: false });
  }

  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;

  db.addCredential(user.id, {
    credentialID: Buffer.from(credential.id).toString('base64url'),
    credentialPublicKey: Buffer.from(credential.publicKey).toString('base64'),
    counter: credential.counter,
    transports: response.response?.transports ?? [],
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    createdAt: new Date().toISOString(),
  });

  // Challenge nach Verwendung löschen (verhindert Replay-Angriffe)
  db.deleteChallenge(user.id);

  res.json({ verified: true });
});

Das Feld credentialBackedUp gibt an, ob der Passkey zwischen Geräten 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äteübergreifend verfügbar ist, und gegebenenfalls die Registrierung eines weiteren Credentials zu empfehlen.

Die counter-Eigenschaft ist ein Zähler, der bei jeder Verwendung des Credentials inkrementiert werden sollte. Bei physischen Sicherheitsschlüsseln (YubiKey) ist ein Counter-Rückschritt ein Indikator für ein geklontes Gerät. Sync-Passkeys setzen den Counter oft auf 0, was laut FIDO2-Spezifikation und SimpleWebAuthn-Dokumentation erwartetes Verhalten ist.

Schritt 7 und 8: Authentifizierung implementieren

Die Authentifizierung folgt demselben Challenge-Response-Muster wie die Registrierung. Der Server generiert eine neue, einmalige Challenge. Der Client lässt den Authenticator die Challenge mit dem privaten Schlüssel signieren und sendet die Assertion an den Server. Der Server prüft die Signatur gegen den gespeicherten öffentlichen Schlüssel. Schlägt die Verifikation fehl, scheitert die Anmeldung, ohne dass der Grund für den Nutzer sichtbar ist.

// Schritt 7: Authentifizierungsoptionen generieren
app.post('/api/auth/login/options', async (req, res) => {
  const { username } = req.body;
  const user = db.getUserByUsername(username);
  if (!user || user.credentials.length === 0) {
    return res.status(400).json({ error: 'Keine registrierten Passkeys für diesen Benutzer' });
  }

  const options = await generateAuthenticationOptions({
    rpID: config.rpID,
    userVerification: 'required',
    allowCredentials: user.credentials.map((cred) => ({
      id: Buffer.from(cred.credentialID, 'base64url'),
      type: 'public-key',
      transports: cred.transports,
    })),
    timeout: 60000,
  });

  db.setChallenge(user.id, options.challenge);
  res.json(options);
});

// Schritt 8: Authentifizierungsantwort verifizieren
app.post('/api/auth/login/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = db.getUserByUsername(username);
  if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' });

  const expectedChallenge = db.getChallenge(user.id);
  if (!expectedChallenge) {
    return res.status(400).json({ error: 'Keine ausstehende Challenge' });
  }

  const credential = user.credentials.find((c) => c.credentialID === response.id);
  if (!credential) {
    return res.status(400).json({ error: 'Unbekanntes Credential' });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin: config.expectedOrigin,
      expectedRPID: config.rpID,
      credential: {
        id: Buffer.from(credential.credentialID, 'base64url'),
        publicKey: Buffer.from(credential.credentialPublicKey, 'base64'),
        counter: credential.counter,
        transports: credential.transports,
      },
      requireUserVerification: true,
    });
  } catch (err) {
    console.error('[WebAuthn] Authentifizierungsverifikation fehlgeschlagen:', err.message);
    return res.status(400).json({ error: err.message });
  }

  if (!verification.verified) {
    return res.status(401).json({ verified: false });
  }

  // Counter nach erfolgreicher Authentifizierung aktualisieren
  db.updateCredentialCounter(
    user.id,
    credential.credentialID,
    verification.authenticationInfo.newCounter
  );
  db.deleteChallenge(user.id);

  req.session.userId = user.id;
  req.session.username = user.username;

  res.json({ verified: true, username: user.username });
});

app.listen(config.port, () => {
  console.log(`[WebAuthn Demo] Server läuft auf Port ${config.port}`);
  console.log(`[WebAuthn Demo] RP ID: ${config.rpID}, Origin: ${config.expectedOrigin}`);
});

Schritt 9 und 10: Frontend-Integration mit @simplewebauthn/browser

Das Frontend verwendet @simplewebauthn/browser v13.3.0. Die Bibliothek kapselt die nativen Browser-APIs navigator.credentials.create() und navigator.credentials.get() in benutzerfreundliche Funktionen, die Base64URL-Encoding, ArrayBuffer-Konvertierungen und browserübergreifende Kompatibilitätsprobleme automatisch handhaben.

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WebAuthn Demo</title>
  <style>
    body { font-family: system-ui; max-width: 480px; margin: 60px auto; padding: 0 20px; }
    input { display: block; width: 100%; padding: 10px; margin: 8px 0; border: 1px solid #ccc; border-radius: 6px; font-size: 16px; }
    button { display: block; width: 100%; padding: 12px; margin: 8px 0; background: #0070f3; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; }
    button:hover { background: #0051cc; }
    #status { margin-top: 16px; padding: 12px; border-radius: 6px; font-size: 14px; }
    .ok { background: #d4edda; color: #155724; }
    .err { background: #f8d7da; color: #721c24; }
  </style>
</head>
<body>
  <h1>Passwortlose Anmeldung</h1>
  <input type="text" id="username" placeholder="Benutzername" autocomplete="username webauthn" />
  <button id="register">Passkey registrieren</button>
  <button id="login">Mit Passkey anmelden</button>
  <div id="status"></div>
  <script type="module" src="app.js"></script>
</body>
</html>
// public/app.js
import {
  startRegistration,
  startAuthentication,
} from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/[email protected]/+esm';

const statusEl = document.getElementById('status');

function showStatus(msg, isError = false) {
  statusEl.textContent = msg;
  statusEl.className = isError ? 'err' : 'ok';
}

// Schritt 9: Passkey-Registrierung
document.getElementById('register').addEventListener('click', async () => {
  const username = document.getElementById('username').value.trim();
  if (!username) { showStatus('Bitte Benutzername eingeben', true); return; }

  // 1. Optionen vom Server holen
  const optRes = await fetch('/api/auth/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  });
  if (!optRes.ok) {
    showStatus('Fehler beim Holen der Optionen: ' + (await optRes.json()).error, true);
    return;
  }
  const options = await optRes.json();

  // 2. Browser-API aufrufen (öffnet Authenticator-Dialog)
  let attResp;
  try {
    attResp = await startRegistration({ optionsJSON: options });
  } catch (err) {
    showStatus('Registrierung abgebrochen: ' + err.message, true);
    return;
  }

  // 3. Antwort zur Verifikation senden
  const verRes = await fetch('/api/auth/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, response: attResp }),
  });
  const result = await verRes.json();
  if (result.verified) {
    showStatus('Passkey erfolgreich registriert! Du kannst dich jetzt anmelden.');
  } else {
    showStatus('Registrierung fehlgeschlagen: ' + (result.error || 'Unbekannter Fehler'), true);
  }
});

// Schritt 10: Passkey-Authentifizierung
document.getElementById('login').addEventListener('click', async () => {
  const username = document.getElementById('username').value.trim();
  if (!username) { showStatus('Bitte Benutzername eingeben', true); return; }

  const optRes = await fetch('/api/auth/login/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username }),
  });
  if (!optRes.ok) {
    showStatus('Fehler: ' + (await optRes.json()).error, true);
    return;
  }
  const options = await optRes.json();

  let assertResp;
  try {
    assertResp = await startAuthentication({ optionsJSON: options });
  } catch (err) {
    showStatus('Anmeldung abgebrochen: ' + err.message, true);
    return;
  }

  const verRes = await fetch('/api/auth/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, response: assertResp }),
  });
  const result = await verRes.json();
  if (result.verified) {
    showStatus(`Willkommen zurück, ${result.username}! Anmeldung erfolgreich.`);
  } else {
    showStatus('Anmeldung fehlgeschlagen: ' + (result.error || 'Ungültige Antwort'), true);
  }
});

Der Attribut-Wert autocomplete="username webauthn" im Eingabefeld aktiviert die Conditional UI in unterstützten Browsern (Chrome 108+, Safari 16+). Der Browser zeigt beim Fokussieren des Feldes automatisch verfügbare Passkeys an, ähnlich wie bei gespeicherten Passwörtern. Dieser Flow ist für Nutzer besonders angenehm, weil kein Klick auf eine separate Schaltfläche nötig ist.

Schritt 11: Datenbankintegration für den Produktionseinsatz

Der In-Memory-Datenspeicher verliert alle Daten beim Serverneustart. Für die Produktion brauchst du eine persistente Datenbank. Das folgende Schema zeigt die empfohlene Tabellenstruktur für SQLite (mit better-sqlite3) oder PostgreSQL.

-- Datenbankschema für WebAuthn-Credentials
CREATE TABLE users (
  id          TEXT PRIMARY KEY,          -- UUID
  username    TEXT UNIQUE NOT NULL,
  created_at  TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE credentials (
  id                  TEXT PRIMARY KEY,  -- UUID
  user_id             TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id       TEXT UNIQUE NOT NULL,   -- Base64URL-kodierte ID
  public_key          TEXT NOT NULL,           -- Base64-kodierter öffentlicher Schlüssel
  counter             INTEGER NOT NULL DEFAULT 0,
  transports          TEXT NOT NULL DEFAULT '[]',  -- JSON-Array
  device_type         TEXT,              -- 'singleDevice' oder 'multiDevice'
  backed_up           INTEGER DEFAULT 0, -- 1 = synchronisiert, 0 = gerätegebunden
  name                TEXT,              -- Nutzerdefinierbarer Name (z.B. "iPhone von Anna")
  created_at          TEXT NOT NULL DEFAULT (datetime('now')),
  last_used_at        TEXT
);

CREATE TABLE challenges (
  user_id     TEXT PRIMARY KEY,
  challenge   TEXT NOT NULL,
  expires_at  INTEGER NOT NULL           -- Unix-Timestamp in Millisekunden
);

CREATE INDEX idx_credentials_user_id ON credentials(user_id);
CREATE INDEX idx_credentials_credential_id ON credentials(credential_id);

Der Index auf credential_id 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ürde eine sequenzielle Suche ohne Index messbare Latenzen verursachen.

Implementiere außerdem einen Endpunkt zum Verwalten von Credentials. Nutzer müssen ihre registrierten Passkeys einsehen, benennen und entfernen können. Ohne diese Funktion können Nutzer bei einem verlorenen Gerät mit nicht synchronisiertem Passkey den Account nicht mehr erreichen.

Schritt 12: Produktionssicherheit und Härtung

Bevor du WebAuthn in die Produktion bringst, prüfe alle folgenden Sicherheitsmaßnahmen. Jeder Punkt schützt vor einer spezifischen Angriffskategorie.

SicherheitsmaßnahmeSchutz gegenImplementierung
HTTPS erzwingenMan-in-the-MiddleHSTS-Header, HTTP auf 443 weiterleiten
Sichere Session-CookiesXSS, CSRFhttpOnly: true, secure: true, sameSite: 'lax'
Challenge-Ablauf (5 Min.)Replay-AngriffeTimestamp beim Speichern, bei Abruf prüfen
Rate Limiting (10 req/min/IP)Brute Force, DoSexpress-rate-limit auf allen Auth-Endpunkten
Challenge nach Verwendung löschenReplay-AngriffedeleteChallenge() nach Verifikation
Counter-ValidierungCredential-KloningNeuer Counter muss größer oder gleich sein
Origin exakt validierenCross-Origin-AngriffeexpectedOrigin exakt konfigurieren
excludeCredentials bei RegistrierungDuplikat-CredentialsAlle vorhandenen Credentials übergeben
userVerification: 'required'Unbefugter GerätezugriffBiometrie oder PIN erzwingen
Credential-Ownership prüfenAccount-TakeoverCredential muss zum angefragten User gehören

Setze außerdem sinnvolle HTTP-Sicherheitsheader. WebAuthn selbst erfordert keinen zusätzlichen Content-Security-Policy-Eintrag, aber deine App sollte grundlegende Headers wie X-Frame-Options: DENY, X-Content-Type-Options: nosniff und einen strikten Referrer-Policy-Header liefern. Für die vollständige CSP-Konfiguration in Node.js empfehlen wir unseren Artikel zu Content Security Policy in Node.js.

Implementiere einen Recovery-Flow. Für den Fall, dass ein Nutzer alle seine Passkeys verliert (Gerät 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 ähnlich wie bei TOTP. Informiere Nutzer nach der ersten Passkey-Registrierung aktiv über diese Möglichkeit.

Häufige Fehler und Troubleshooting

WebAuthn-Fehler erscheinen oft kryptisch im Browser. Die folgenden 8 Szenarien decken über 90 Prozent der Probleme ab, die bei der Node.js-Implementierung auftreten.

1. NotAllowedError: The operation either timed out or was not allowed. 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ösung: Zeige dem Nutzer klare Anweisungen, bevor du den Dialog öffnest. Erhöhe das Timeout auf 120.000 Millisekunden für langsame Nutzer. Fange den Fehler gracefully ab und zeige eine verständliche Fehlermeldung statt des technischen Fehlertexts.

2. SecurityError: The relying party ID is not a registrable domain suffix of the current origin. Die RP ID stimmt nicht mit der Domain der Seite überein. Wenn deine App unter app.example.com läuft und die RP ID example.com ist, muss das ein registrierbares Suffix der aktuellen Domain sein. IP-Adressen (außer 127.0.0.1) und Ports werden in der RP ID ignoriert. Lösung: In der Entwicklung immer localhost als RP ID verwenden, nie eine IP-Adresse wie 192.168.1.x.

3. InvalidStateError: An account already exists. Der Authenticator enthält bereits ein Credential für diese Kombination aus User ID und RP ID. Das passiert, wenn excludeCredentials fehlt oder unvollständig ist. Lösung: Übergib immer alle bestehenden Credentials eines Nutzers im excludeCredentials-Array bei der Generierung der Registrierungsoptionen.

4. Verifikation schlägt fehl: Unexpected challenge. Die vom Server gespeicherte Challenge stimmt nicht mit der in der Authenticator-Antwort enthaltenen überein. Häufige Ursachen: Race-Conditions bei mehreren gleichzeitigen Anfragen desselben Nutzers, oder die Challenge wurde bereits gelöscht (doppelte Anfrage). Lösung: Challenges an die Session statt an die User ID binden, um parallele Flows desselben Nutzers zu isolieren.

5. Counter-Fehler: Credential counter is not greater than stored counter. Bei Sync-Passkeys ist der Counter oft 0, was SimpleWebAuthn v13.x korrekt behandelt. Bei physischen Sicherheitsschlüsseln deutet ein Counter-Rückschritt auf ein geklontes Credential hin. Lösung: Das Credential deaktivieren, den Nutzer zur Neuregistrierung auffordern und den Vorfall im Security-Log festhalten.

6. ConstraintError: This request is not supported. Die konfigurierten Authenticator-Anforderungen können vom Gerät nicht erfüllt werden, etwa wenn authenticatorAttachment: 'platform' gesetzt ist, aber das Gerät keinen integrierten Authenticator hat (ältere Laptops ohne Windows Hello, Desktop-PCs ohne Fingerabdruckleser). Lösung: Entferne authenticatorAttachment oder setze es auf 'cross-platform', um externe Sicherheitsschlüssel zu erlauben.

7. TypeError beim Import der Browser-Bibliothek. Mit @simplewebauthn/browser v13.x müssen Named Exports verwendet werden: import { startRegistration, startAuthentication } from '@simplewebauthn/browser'. Der CDN-Import über +esm funktioniert nur mit type="module" im Script-Tag. Prüfe auch, ob der Browser den ESM-Import blockiert (Content-Security-Policy script-src).

8. Authentifizierung schlägt in Produktion fehl, lokal funktioniert alles. Der häufigste Grund ist eine falsche expectedOrigin-Konfiguration. Hinter einem Reverse-Proxy (nginx, Cloudflare, AWS ALB) muss der Origin die externe URL sein (https://example.com), nicht die interne (http://127.0.0.1:3000). Lösung: Konfiguriere EXPECTED_ORIGIN und RP_ID als Umgebungsvariablen und logge beide Werte beim Serverstart für eine einfachere Fehlerdiagnose.

Erwartete Serverantworten: Ausgabebeispiele

So sehen korrekte Serverantworten aus, damit du Abweichungen leichter erkennst:

# Erfolgreiche Registrierungsoptionen (GET-Antwort von /api/auth/register/options)
{
  "challenge": "a8FBm_Xyz123...",
  "rp": { "name": "WebAuthn Demo", "id": "localhost" },
  "user": { "id": "dXNlci0x", "name": "anna", "displayName": "anna" },
  "pubKeyCredParams": [
    { "alg": -7,   "type": "public-key" },
    { "alg": -257, "type": "public-key" }
  ],
  "timeout": 60000,
  "excludeCredentials": [],
  "authenticatorSelection": {
    "residentKey": "preferred",
    "userVerification": "required",
    "authenticatorAttachment": "platform"
  },
  "attestation": "none"
}

# Erfolgreiche Verifikation
{ "verified": true }

# Erfolgreiche Login-Verifikation
{ "verified": true, "username": "anna" }

# Fehler: falscher Origin
{
  "error": "Unexpected origin 'http://localhost:3001', expected 'http://localhost:3000'"
}

# Fehler: abgelaufene Challenge
{
  "error": "Keine ausstehende Challenge. Bitte neu starten."
}

Sicherheitsvergleich: Passkeys vs. Passwörter vs. TOTP

Die folgende Tabelle vergleicht die drei gängigsten Authentifizierungsmethoden anhand konkreter Sicherheits- und Usability-Kriterien. Die Daten basieren auf FIDO-Alliance-Berichten (2026) und NIST-Publikationen.

KriteriumPasswörterTOTP (z.B. Google Authenticator)WebAuthn Passkeys
Phishing-ResistentNeinBedingt (AiTM-Angriffe möglich)Ja (origin-gebunden)
Credential-StuffingVerwundbarVerwundbar (Passwort + Code nötig)Immun
Risiko bei Server-BreachHoch (Hash oder Klartext)Mittel (TOTP-Secret im Klartext)Niedrig (nur Public Key)
Durchschnittliche Login-Dauer31 Sekunden45 Sekunden8,5 Sekunden
Geräte-Verlust-RecoveryPasswort-Reset per E-MailBackup-Codes (oft nicht gespeichert)Sync-Passkey oder Recovery-Flow
Browser-UnterstützungUniversalUniversal (per JS-Generator)95 Prozent der modernen Browser
Nutzer-AkzeptanzBekannt, aber ungeliebtMäßig (App-Installation nötig)Hoch (biometrisch, schnell)
NIST AAL2-KonformitätNeinJa (mit sicherem Kanal)Ja

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ält dort ein Credential, das nur für evil-example.com gilt, nicht für example.com. Diesen Schutz können weder TOTP noch Passwörter bieten. Für weitere Details lies unseren Vergleichsartikel Passkeys vs. Passwörter: 8,5s vs. 31s Anmeldung.

Browser-Unterstützung für WebAuthn 2026

WebAuthn ist in allen modernen Browsern und Betriebssystemen verfügbar. Die folgende Tabelle zeigt den Supportstatus der wichtigsten Plattformen.

Browser / PlattformSupport seitPasskey-SyncAuthenticator-Typen
Chrome 108+ / AndroidVollständigGoogle Password ManagerPlattform + extern
Safari 16+ / iOS & macOSVollständigiCloud KeychainPlattform + extern
Firefox 119+VollständigÜber OS (kein eigener Sync)Plattform + extern
Edge 108+ / WindowsVollständigWindows Hello / MS AuthenticatorPlattform + extern
Samsung Internet 21+VollständigSamsung PassPlattform + extern
iOS 16+ (alle Browser)VollständigiCloud KeychainNur Plattform (Face/Touch ID)
Android unter 9.0EingeschränktKein Passkey-SyncExtern (CTAP2-Schlüssel)

Die FIDO Alliance schätzt, dass im Jahr 2026 über 95 Prozent aller aktiv genutzten Browser WebAuthn vollständig unterstützen. Der einzige nennenswerte Vorbehalt betrifft Android-Geräte unter Version 9, die keine Passkey-Synchronisierung unterstützen. Für diese Nutzer empfiehlt sich ein Fallback auf TOTP. Unseren Artikel zu Zwei-Faktor-Authentifizierung in Node.js zeigt, wie du TOTP parallel zu WebAuthn implementierst.

Erweiterte Konfiguration: Discoverable Credentials und Cross-Device-Flows

Discoverable Credentials ermöglichen eine Anmeldung ohne vorherige Eingabe eines Benutzernamens. Der Authenticator speichert nicht nur den privaten Schlüssel, sondern auch Nutzermetadaten. Beim Login zeigt der Browser eine Auswahlliste verfügbarer Accounts, ohne dass der Nutzer seinen Benutzernamen eingibt.

Für diesen nutzerlosen Login-Flow (auch Conditional UI genannt) sende beim Generieren der Login-Optionen ein leeres allowCredentials-Array:

// Nutzerlosen Login (Conditional UI / Passkey-Autofill) unterstützen
app.post('/api/auth/login/options/conditional', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: config.rpID,
    userVerification: 'required',
    allowCredentials: [],  // Leeres Array: Browser zeigt alle verfügbaren Passkeys
    timeout: 120000,
  });

  // Challenge in Session statt an User-ID binden
  req.session.conditionalChallenge = options.challenge;
  req.session.conditionalExpiry = Date.now() + 5 * 60 * 1000;
  res.json(options);
});

Cross-Device Authentication ermöglicht, dass ein Mobiltelefon als Authenticator für einen Desktop-Browser verwendet wird. Dieser Flow läuft über Bluetooth (CTAP 2.2 mit Hybrid Transport). SimpleWebAuthn unterstützt ihn automatisch, wenn du in allowCredentials die Transporte ['hybrid', 'ble'] einträgst. 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ätigt auf dem Telefon per Biometrie.

Für eine vollständige OAuth-2.0-Integration, bei der WebAuthn als starker zweiter Faktor in einem bestehenden Authorization-Code-Flow fungiert, lies unseren Artikel OAuth 2.0 in Node.js: 12 Schritte, 45 Min. Für die Kryptografie hinter ECDSA, dem Signaturalgorithmus, den alle Plattform-Authenticators standardmäßig verwenden, empfehlen wir den Artikel Digitale Signatur in Node.js: 11 Schritte, 40 Min.

WebAuthn in der bestehenden Auth-Architektur integrieren

WebAuthn ist selten die einzige Authentifizierungsmethode in einer Produktionsapp. Drei Integrationsszenarien decken die meisten Anwendungsfälle ab.

Szenario 1: WebAuthn als primäre Authentifizierung. Nutzer registrieren sich mit E-Mail und setzen sofort einen Passkey ein. Es gibt keine Passwörter in der Datenbank. Ein Recovery-Flow über verifizierte E-Mail-Links ist der Fallback für verlorene Geräte ohne Passkey-Sync. Dieses Modell ist das sicherste, erfordert aber die sorgfältigste UX-Gestaltung des Recovery-Flows. Neue Dienste (Startups ohne Legacy-Systeme) sollten diesen Ansatz wählen.

Szenario 2: WebAuthn als starker zweiter Faktor. Nutzer melden sich mit Passwort an und bestätigen mit einem Passkey. Das ist die empfohlene Migrationsstrategie für bestehende Systeme mit vielen Nutzern. Die WebAuthn-Verifikation ersetzt TOTP oder SMS-OTP. Passkeys sind phishing-resistent, TOTP ist es nicht vollständig (AiTM-Angriffe umgehen TOTP). Für die Session-Verwaltung nach erfolgreicher Authentifizierung lies unseren Artikel Node.js Session Management: 11 Schritte.

Szenario 3: WebAuthn über einen externen Identity Provider. Dienste wie Auth0, Okta, Keycloak oder Azure AD B2C bieten bereits Passkey-Unterstützung an. In diesem Fall implementierst du WebAuthn nicht selbst, sondern konfigurierst den Provider. Die Node.js-App übernimmt nur die OAuth-2.0/OIDC-Verifikation des Tokens. Das ist der schnellste Weg zur Passkey-Unterstützung für Teams ohne dedizierte Security-Expertise.

Für alle drei Szenarien gilt: Implementiere ein Credential-Management-Interface in den Account-Einstellungen. Nutzer müssen registrierte Passkeys einsehen (mit Gerätename und Datum der letzten Verwendung), umbenennen und entfernen können. Das ist keine optionale Komfortfunktion, sondern ein Sicherheitserfordernis. Für JWT-basierte API-Authentifizierung nach dem WebAuthn-Login empfehlen wir den Artikel JWT Authentication in Node.js: 10 Schritte.

Häufige Fragen zu WebAuthn in Node.js

Kann ich WebAuthn mit bestehenden Passwort-Accounts kombinieren?

Ja, das ist der empfohlene Migrationspfad. Ermögliche Nutzern, einen Passkey zu ihrem bestehenden Account hinzuzufügen, ohne das Passwort sofort zu entfernen. Sobald ein Nutzer einen Passkey registriert hat, biete die Passkey-Anmeldung als primären und bevorzugten Flow an. Das Passwort bleibt als Fallback erhalten, bis der Nutzer es manuell löscht.

Funktioniert WebAuthn auf Shared Computern (Bibliotheken, Schulen)?

Auf Shared Computern setze authenticatorAttachment: 'cross-platform', damit Nutzer externe Sicherheitsschlüssel (YubiKey, Nitrokey) verwenden können. Platform Authenticators (Windows Hello, Touch ID) funktionieren zwar auch auf Shared Computern, aber ein Passkey ist dann an das Gerät gebunden, sofern kein Passkey-Manager synchronisiert. Für Bibliotheken oder Schulen empfiehlt sich entweder ein Fallback auf TOTP oder eine Kombination aus kurzzeitigem E-Mail-Magic-Link.

Wie teste ich WebAuthn lokal ohne echten Authenticator?

Chrome bietet einen integrierten Virtual Authenticator in den DevTools. Öffne die DevTools, wähle “Weitere Tools” und dann “WebAuthn”. 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ützung. Firefox bietet eine ähnliche Funktion über about:config mit dem Flag security.webauth.webauthn.

Wie ist die Datenschutzlage bei WebAuthn in Österreich (DSGVO)?

WebAuthn verbessert den Datenschutz gegenüber Passwörtern erheblich. Der Server speichert ausschließlich den öffentlichen Schlüssel, keine biometrischen Daten (diese verarbeitet ausschließlich das Gerät des Nutzers lokal). Jedes Credential ist außerdem für genau eine Relying Party ausgestellt, wodurch Cross-Service-Tracking verhindert wird. Aus DSGVO-Sicht ist der öffentliche Schlüssel ein personenbezogenes Datum, da er einer Person zugeordnet werden kann. Verschlüsselung at rest und Zugriffsprotokolle für die Credentials-Tabelle sind daher verpflichtend.

Was passiert, wenn ein Nutzer sein Gerät verliert?

Bei Sync-Passkeys (iCloud Keychain, Google Password Manager, Windows Microsoft Authenticator) ist der Passkey auf anderen Geräten desselben Nutzers verfügbar, sofern derselbe Plattform-Account verwendet wird. Bei gerätegebundenen Passkeys oder physischen Sicherheitsschlüsseln 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.

Welche Algorithmen unterstützt @simplewebauthn/server 13.x?

SimpleWebAuthn v13.x unterstützt standardmäßig ES256 (ECDSA mit P-256-Kurve, Algorithmus-ID -7) und RS256 (RSA-PSS mit SHA-256, Algorithmus-ID -257). Alle aktuellen Plattform-Authenticators auf iOS, Android und Windows Hello verwenden ES256. RS256 ist für ältere USB-Sicherheitsschlüssel relevant. Aktiviere beide in supportedAlgorithmIDs: [-7, -257] für maximale Kompatibilität.

Wie schütze ich WebAuthn-Endpunkte vor Missbrauch?

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ägen das Account temporär sperren. Logge alle Verifikationsfehler mit IP-Adresse, User Agent und Timestamp für spätere Forensik. Details zur Implementierung findest du in unserem Artikel Rate Limiting in Node.js: 12 Schritte, 35 Min.

Weiterführende Artikel

Diese Artikel ergänzen die WebAuthn-Implementierung mit weiteren sicherheitsrelevanten Themen für Node.js:


Externe Ressourcen: W3C WebAuthn Level 3 Spezifikation, FIDO Alliance: Passkeys, SimpleWebAuthn Dokumentation, MDN Web Authentication API, passkeys.dev (FIDO Alliance Entwicklerressource)