Passwörter sind das schwächste Glied der meisten Web-Anwendungen. Phishing, Credential Stuffing und wiederverwendete Logins verursachen den Großteil aller Kontoübernahmen. Passkeys lösen 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ößten Websites unterstützen die Anmeldung ohne Passwort. Dieser Leitfaden zeigt Ihnen Schritt für Schritt, wie Sie Passkeys in Node.js mit dem WebAuthn-Standard implementieren, inklusive eines vollständigen, lauffähigen Projekts.

Wir bauen ein komplettes passwortloses Login-System: ein Express-Backend mit der Bibliothek @simplewebauthn/server, eine SQLite-Datenbank für Nutzer und Credentials sowie ein schlankes Frontend mit @simplewebauthn/browser. Am Ende registrieren und authentifizieren sich Nutzer per Fingerabdruck, Gesichtserkennung oder Hardware-Schlüssel, ganz ohne Passwort. Stand: 23. Juni 2026. Planen Sie rund 40 Minuten für die Umsetzung ein.

Was sind Passkeys? WebAuthn und FIDO2 in der Praxis

Ein Passkey ist ein kryptografisches Schlüsselpaar, das ein Gerät oder ein Passwortmanager für eine bestimmte Website erzeugt. Der private Schlüssel verlässt das Gerät nie. Der öffentliche Schlüssel wandert zum Server und wird dort gespeichert. Bei der Anmeldung beweist das Gerät den Besitz des privaten Schlüssels durch eine digitale Signatur, ohne das Geheimnis selbst zu übertragen. Genau dieser Mechanismus macht Passkeys gegen Phishing immun, denn es gibt kein Passwort, das ein Angreifer abgreifen könnte.

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älfte dieses Protokolls: das Erzeugen von Challenges und das Verifizieren von Signaturen.

Der aktuelle Stand der Standardisierung ist eindeutig: WebAuthn Level 2 ist die offizielle W3C-Empfehlung (W3C Web Authentication Level 2), während Level 3 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ützen WebAuthn produktiv. Eine gepflegte Referenz für Entwickler ist passkeys.dev, ein Gemeinschaftsprojekt der FIDO Alliance.

Registrierung und Authentifizierung: die zwei Ceremonies

WebAuthn kennt genau zwei Abläufe, in der Spezifikation Ceremonies genannt. Die Registrierung (Attestation) erzeugt ein neues Schlüsselpaar und meldet den öffentlichen Schlüssel beim Server an. Die Authentifizierung (Assertion) nutzt ein bestehendes Schlüsselpaar, um eine Anmeldung zu signieren. Beide folgen demselben Muster: Der Server erzeugt eine zufällige Challenge, der Browser leitet sie an den Authenticator weiter, das Gerät signiert sie nach einer Nutzerbestätigung, und der Server prüft die Antwort. Diese vier Phasen werden Sie in Node.js exakt nachbauen.

Warum Passkeys phishing-resistent sind

Der entscheidende Schutz liegt in der Origin-Bindung. Ein Passkey ist kryptografisch an die Domain gebunden, für die er erstellt wurde. Versucht eine Phishing-Seite unter einer falschen Domain, eine Anmeldung auszulösen, verweigert der Browser die Signatur, weil die Origin nicht übereinstimmt. Selbst ein perfekt nachgebautes Login-Formular läuft ins Leere. Diese Eigenschaft prüft Ihr Server später über die Parameter expectedOrigin und expectedRPID. Ein gestohlener öffentlicher Schlüssel nützt einem Angreifer ohne das physische Gerät und die biometrische Freigabe nichts.

Technisch besteht die Antwort des Authenticators bei der Anmeldung aus zwei Teilen: den authenticatorData und dem clientDataJSON, das die Challenge und die Origin enthält. Der Authenticator signiert die Verkettung dieser Daten mit dem privaten Schlüssel. Ihr Server berechnet denselben Wert erneut und prüft die Signatur mit dem gespeicherten öffentlichen Schlüssel. Stimmt alles, ist bewiesen, dass dieselbe Hardware antwortet, die sich ursprünglich registriert hat, und dass die Anmeldung für genau diese Domain bestimmt war. Diese Prüfung erledigt verifyAuthenticationResponse für Sie, doch zu wissen, was darunter passiert, hilft bei der Fehlersuche enorm.

Passkeys vs. Passwörter: die Zahlen für 2026

Die Verbreitung von Passkeys hat 2025 einen Wendepunkt erreicht. Große Plattformen melden nicht nur höhere Sicherheit, sondern auch bessere Conversion, weil sich Nutzer schneller und seltener fehlerhaft anmelden. Die folgende Tabelle fasst die belastbaren Kennzahlen aus offiziellen Quellen zusammen.

KennzahlWertQuelle
Verbraucher mit mindestens einem Passkey69 %FIDO Alliance (2025)
Top-100-Websites mit Passkey-Support48 %FIDO Alliance
Login-Erfolgsrate Passkey vs. Passwort93 % vs. 63 %FIDO Alliance
Google: Erfolg gegenüber Passwörtern4-fach höherGoogle
Google: Wachstum der Passkey-Logins seit Okt. 2023+352 %Google
Microsoft: Passkey-Standard für neue Kontenseit Mai 2025, +120 % LoginsMicrosoft
TikTok: Erfolgsrate Passkey-Anmeldung97 %TikTok
Unternehmen, die Passkeys ausrollen87 %HID Global / FIDO Alliance

Die Botschaft ist klar: Passkeys reduzieren Reibung und Risiko gleichzeitig. Eine Login-Erfolgsrate von 93 Prozent gegenüber 63 Prozent bei Passwörtern bedeutet weniger Support-Tickets und weniger Abbrüche. Für DACH-Unternehmen, die unter NIS2 nachweisbare Authentifizierungsstandards brauchen, sind diese Zahlen ein starkes Argument. Wer den Vergleich zu klassischen Verfahren vertiefen möchte, findet in unserem Beitrag zu Authenticator-Apps eine Einordnung der TOTP-Alternativen.

Was Sie bauen: Architektur des Passkey-Logins

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 öffentliche Schlüssel landet dauerhaft in einer Credentials-Tabelle.

  • POST /register/options erzeugt Registrierungs-Optionen und eine Challenge.
  • POST /register/verify prüft die Attestation und speichert den öffentlichen Schlüssel.
  • POST /login/options erzeugt Authentifizierungs-Optionen mit erlaubten Credentials.
  • POST /login/verify prüft die Signatur und aktualisiert den Signaturzähler.

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üfen zu können. Genau wie bei einem sicheren Schlüsselaustausch hängt die Sicherheit daran, dass die Challenge zufällig, einmalig und zeitlich begrenzt ist.

Voraussetzungen: Node.js, Bibliotheken und Versionen

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 @simplewebauthn/server verlangt mindestens Node.js 20. Wir empfehlen eine LTS-Version, also Node.js 22 (“Jod”) oder 24 (“Krypton”).

KomponenteVersionZweck
Node.js22 LTS oder 24 LTS (mind. 20)Laufzeitumgebung
@simplewebauthn/server13.3.1Serverseitige WebAuthn-Logik
@simplewebauthn/browser13.3.0Browser-API-Wrapper
express5.2.1HTTP-Server und Routing
better-sqlite312.11.1Speicherung von Nutzern und Credentials
helmet8.2.0Sicherheits-HTTP-Header

Eine Besonderheit von WebAuthn betrifft die Entwicklungsumgebung: Die API funktioniert nur in einem sicheren Kontext, also über HTTPS. Eine einzige Ausnahme gilt für localhost, das Browser als sicher behandeln. Deshalb können Sie das Projekt lokal ohne Zertifikat testen, brauchen in Produktion aber zwingend HTTPS. Wer die Grundlagen sicherer Verbindungen auffrischen will, findet sie in unserem Überblick zu Online-Sicherheit.

Wir nutzen better-sqlite3, weil es synchron arbeitet, keine separate Datenbank erfordert und sich ideal für ein nachvollziehbares Tutorial eignet. Für Produktion ist die Wahl der Datenbank austauschbar: Das Schema aus Nutzern und Credentials lässt sich eins zu eins auf PostgreSQL oder MySQL übertragen. Wichtig ist allein, dass der öffentliche Schlüssel als Binärtyp gespeichert wird und die Credential-ID eindeutig indiziert ist. Wer hohe Lasten erwartet, sollte zusätzlich die Challenge aus der Nutzertabelle in einen schnellen Cache auslagern, um Schreibzugriffe zu reduzieren.

Schritt 1 bis 3: Projekt, Express-Server und Datenbank

Schritt 1: Projekt anlegen. Erstellen Sie einen Ordner und installieren Sie die Abhängigkeiten. Wir nutzen ES-Module, also setzen Sie "type": "module" in der package.json.

mkdir passkeys-nodejs-demo && cd passkeys-nodejs-demo
npm init -y
npm install @simplewebauthn/[email protected] @simplewebauthn/[email protected] \
  [email protected] [email protected] [email protected]
node --version   # erwartet v22.x oder v24.x

Die fertige package.json sollte die folgenden Felder enthalten. Das Feld "type": "module" ist entscheidend, sonst schlagen die import-Anweisungen fehl.

{
  "name": "passkeys-nodejs-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "@simplewebauthn/server": "13.3.1",
    "better-sqlite3": "12.11.1",
    "express": "5.2.1",
    "helmet": "8.2.0"
  }
}

Schritt 2: Datenbank-Schema definieren. Legen Sie die Datei db.js an. Wir speichern Nutzer mit einer stabilen ID und ihrer aktuellen Challenge sowie Credentials mit öffentlichem Schlüssel, Signaturzähler und Transports. Der öffentliche Schlüssel ist ein Byte-Array und wird als BLOB abgelegt.

import Database from 'better-sqlite3';

const db = new Database('passkeys.db');
db.pragma('journal_mode = WAL');

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    current_challenge TEXT
  );
  CREATE TABLE IF NOT EXISTS credentials (
    id TEXT PRIMARY KEY,          -- Credential-ID als Base64URL
    user_id TEXT NOT NULL,
    public_key BLOB NOT NULL,     -- COSE-Public-Key als Bytes
    counter INTEGER NOT NULL,
    transports TEXT,              -- JSON-Array, z. B. ["internal","hybrid"]
    FOREIGN KEY (user_id) REFERENCES users(id)
  );
`);

export default db;

Schritt 3: Express-Server aufsetzen. Erstellen Sie app.js mit dem Grundgerüst. Hier importieren wir die vier WebAuthn-Funktionen, liefern statische Dateien aus und definieren die Konfiguration. Die Werte rpID und origin lesen wir aus Umgebungsvariablen, mit sinnvollen Standardwerten für die lokale Entwicklung.

import express from 'express';
import helmet from 'helmet';
import { randomUUID } from 'node:crypto';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import db from './db.js';

const app = express();
app.use(express.json());
app.use(express.static('public'));

// Relying Party und Origin: in Produktion aus Umgebungsvariablen
const rpName = 'Passkey Demo';
const rpID = process.env.RP_ID || 'localhost';
const origin = process.env.ORIGIN || `http://${rpID}:3000`;

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server laeuft auf ${origin}`));

Die rpID ist die Relying-Party-ID, also Ihre registrierbare Domain ohne Schema und Port. Lokal ist das localhost, in Produktion etwa example.com. Die origin muss exakt mit der Adresse übereinstimmen, unter der die Seite läuft, inklusive https://. Stimmen diese Werte nicht, scheitert jede Verifikation. Das ist die häufigste Fehlerquelle bei der Implementierung von Passkeys in Node.js.

Schritt 4 und 5: Passkey-Registrierung in Node.js

Schritt 4: Registrierungs-Optionen erzeugen. Dieser Endpunkt erstellt einen Nutzer, falls er noch nicht existiert, und gibt die Optionen für den Browser zurück. Wichtig sind excludeCredentials, damit ein Nutzer denselben Authenticator nicht doppelt registriert, sowie supportedAlgorithmIDs mit den Werten -7 (ES256) und -257 (RS256), den am weitesten verbreiteten Signaturalgorithmen.

app.post('/register/options', async (req, res) => {
  const { username } = req.body;
  if (!username) return res.status(400).json({ error: 'username fehlt' });

  let user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
  if (!user) {
    const id = randomUUID();
    db.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, username);
    user = { id, username };
  }

  const existing = db
    .prepare('SELECT id, transports FROM credentials WHERE user_id = ?')
    .all(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.username,
    userID: new TextEncoder().encode(user.id),
    attestationType: 'none',
    excludeCredentials: existing.map((c) => ({
      id: c.id,
      transports: JSON.parse(c.transports || '[]'),
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
    supportedAlgorithmIDs: [-7, -257],
  });

  db.prepare('UPDATE users SET current_challenge = ? WHERE id = ?')
    .run(options.challenge, user.id);

  res.json(options);
});

Beachten Sie, dass generateRegistrationOptions in Version 13 asynchron ist und mit await aufgerufen werden muss. Die zurückgegebene options.challenge speichern wir auf dem Nutzerdatensatz. Eine typische Antwort an den Browser sieht gekürzt so aus:

{
  "challenge": "k3Q9c1J...gekuerzt...",
  "rp": { "name": "Passkey Demo", "id": "localhost" },
  "user": { "id": "Yz...id", "name": "alice", "displayName": "alice" },
  "pubKeyCredParams": [
    { "alg": -7, "type": "public-key" },
    { "alg": -257, "type": "public-key" }
  ],
  "timeout": 60000,
  "attestation": "none",
  "authenticatorSelection": {
    "residentKey": "preferred",
    "userVerification": "preferred"
  }
}

Schritt 5: Attestation verifizieren und Schlüssel speichern. Der zweite Endpunkt prüft die Antwort des Authenticators gegen die gespeicherte Challenge, die erwartete Origin und die erwartete RP-ID. Bei Erfolg legen wir Credential-ID, öffentlichen Schlüssel, Zähler und Transports in der Datenbank ab.

app.post('/register/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response,
      expectedChallenge: user.current_challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      requireUserVerification: false,
    });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }

  const { verified, registrationInfo } = verification;
  if (verified && registrationInfo) {
    const { credential } = registrationInfo;
    db.prepare(
      `INSERT OR REPLACE INTO credentials
       (id, user_id, public_key, counter, transports)
       VALUES (?, ?, ?, ?, ?)`
    ).run(
      credential.id,
      user.id,
      Buffer.from(credential.publicKey),
      credential.counter,
      JSON.stringify(credential.transports || [])
    );
  }

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

Challenge sicher speichern

In diesem Tutorial liegt die Challenge in der Spalte current_challenge des Nutzers. Das ist klar nachvollziehbar, hat aber Grenzen. Eine Challenge muss einmalig sein, nach kurzer Zeit ablaufen und nach der Verifikation gelöscht werden. In Produktion gehört 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 localStorage, denn dann verlieren Sie die Replay-Schutzgarantie. In Version 13 erwartet verifyRegistrationResponse die Felder registrationInfo.credential.id (Base64URL-String), publicKey (Byte-Array) und counter.

Schritt 6 und 7: Anmeldung mit WebAuthn verifizieren

Schritt 6: Authentifizierungs-Optionen erzeugen. Für die Anmeldung holt der Server die registrierten Credentials des Nutzers und packt sie in allowCredentials. So weiß der Browser, welche Passkeys er anbieten darf. Auch hier speichern wir die neue Challenge.

app.post('/login/options', async (req, res) => {
  const { username } = req.body;
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });

  const creds = db
    .prepare('SELECT id, transports FROM credentials WHERE user_id = ?')
    .all(user.id);

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: creds.map((c) => ({
      id: c.id,
      transports: JSON.parse(c.transports || '[]'),
    })),
    userVerification: 'preferred',
  });

  db.prepare('UPDATE users SET current_challenge = ? WHERE id = ?')
    .run(options.challenge, user.id);

  res.json(options);
});

Schritt 7: Signatur prüfen und Zähler aktualisieren. Der letzte Endpunkt lädt das passende Credential anhand der vom Browser gelieferten response.id, rekonstruiert den öffentlichen Schlüssel aus dem BLOB und ruft verifyAuthenticationResponse auf. Nach erfolgreicher Prüfung schreiben wir den neuen Signaturzähler zurück. Dieser Zähler erkennt geklonte Authenticatoren: Sinkt er, stimmt etwas nicht.

app.post('/login/verify', async (req, res) => {
  const { username, response } = req.body;
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
  if (!user) return res.status(400).json({ error: 'Unbekannter Nutzer' });

  const cred = db.prepare('SELECT * FROM credentials WHERE id = ?').get(response.id);
  if (!cred || cred.user_id !== user.id) {
    return res.status(400).json({ error: 'Passkey nicht gefunden' });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge: user.current_challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      credential: {
        id: cred.id,
        publicKey: new Uint8Array(cred.public_key),
        counter: cred.counter,
        transports: JSON.parse(cred.transports || '[]'),
      },
      requireUserVerification: false,
    });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }

  const { verified, authenticationInfo } = verification;
  if (verified) {
    db.prepare('UPDATE credentials SET counter = ? WHERE id = ?')
      .run(authenticationInfo.newCounter, cred.id);
  }

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

Wichtig für Version 13: Der Parameter heißt credential (nicht mehr authenticator wie in älteren Versionen) und erwartet ein Objekt mit id, publicKey und counter. Verwenden Sie veraltete Tutorials, schlägt der Aufruf mit kryptischen Fehlern fehl. Das Signieren von Daten mit asymmetrischen Schlüsseln vertieft unser Beitrag zu ECDSA-Signaturen in Node.js, denn ES256 ist exakt der Algorithmus, den die meisten Passkeys verwenden.

Schritt 8 und 9: Das Frontend mit @simplewebauthn/browser

Schritt 8: HTML und Browser-Logik. Legen Sie die Datei public/index.html an. Die Bibliothek @simplewebauthn/browser kapselt die komplexe WebAuthn-API in zwei Funktionen: startRegistration und startAuthentication. In Version 13 erwarten beide ein Objekt mit dem Feld optionsJSON. Für die Demo laden wir das ESM-Bundle vom CDN.

<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Passkeys Demo</title>
</head>
<body>
  <h1>Passwortlos anmelden mit Passkeys</h1>
  <input id="username" placeholder="Benutzername" />
  <button id="register">Passkey registrieren</button>
  <button id="login">Mit Passkey anmelden</button>
  <pre id="log"></pre>

  <script type="module">
    import {
      startRegistration,
      startAuthentication,
    } from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13/+esm';

    const log = (m) => (document.getElementById('log').textContent += m + '\n');
    const user = () => document.getElementById('username').value.trim();

    async function post(url, data) {
      const r = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      return r.json();
    }

    document.getElementById('register').onclick = async () => {
      const username = user();
      const optionsJSON = await post('/register/options', { username });
      const att = await startRegistration({ optionsJSON });
      const out = await post('/register/verify', { username, response: att });
      log(out.verified ? 'Passkey registriert' : 'Fehler: ' + JSON.stringify(out));
    };

    document.getElementById('login').onclick = async () => {
      const username = user();
      const optionsJSON = await post('/login/options', { username });
      const asr = await startAuthentication({ optionsJSON });
      const out = await post('/login/verify', { username, response: asr });
      log(out.verified ? 'Anmeldung erfolgreich' : 'Fehler: ' + JSON.stringify(out));
    };
  </script>
</body>
</html>

Schritt 9: Erster Testlauf. Starten Sie den Server mit npm start und öffnen Sie http://localhost:3000. Geben Sie einen Benutzernamen ein und klicken Sie auf “Passkey registrieren”. Ihr Betriebssystem fragt nach Fingerabdruck, Gesicht oder PIN. Anschließend testen Sie die Anmeldung. In der Konsole und im Browser sehen Sie etwa:

$ npm start
Server laeuft auf http://localhost:3000
# nach Registrierung im Browser:
{ "verified": true }
# nach Anmeldung im Browser:
{ "verified": true }

Die Bibliothek @simplewebauthn/browser übernimmt die Base64URL-Kodierung und die Umwandlung in die nativen ArrayBuffer, die die Browser-API verlangt. Sie müssen sich also nicht mit der rohen navigator.credentials-Schnittstelle befassen. Details zur zugrunde liegenden API liefert die MDN-Dokumentation zur Web Authentication API.

Schritt 10 bis 12: Helmet, HTTPS und Produktivbetrieb

Schritt 10: Sicherheits-Header mit Helmet. Da wir das Browser-Bundle vom CDN laden, müssen Sie die Content-Security-Policy anpassen, sonst blockiert Helmet das Skript. Fügen Sie Helmet mit einer expliziten CSP hinzu, bevor die statischen Dateien ausgeliefert werden.

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
        connectSrc: ["'self'"],
      },
    },
  })
);

Für Produktion sollten Sie das Bundle lokal ausliefern statt vom CDN und die CSP weiter verschärfen. Wie Sie eine restriktive Richtlinie aufbauen, erklärt unser Leitfaden zu Content Security Policy in Node.js sowie der Beitrag zu HTTP-Security-Headern mit Helmet.

Schritt 11: Konfiguration für Produktion. WebAuthn ist streng bei Origin und RP-ID. Setzen Sie die Werte über Umgebungsvariablen, niemals fest verdrahtet. Eine .env-Datei für Ihre Domain sieht so aus:

RP_ID=example.com
ORIGIN=https://example.com
PORT=3000

Schritt 12: HTTPS erzwingen. In Produktion muss die Seite über HTTPS laufen, sonst stellt der Browser die WebAuthn-API gar nicht erst bereit. Setzen Sie einen Reverse Proxy wie Nginx oder Caddy mit gültigem TLS-Zertifikat davor. Achten Sie darauf, dass die origin exakt mit der aufgerufenen Adresse übereinstimmt, inklusive Subdomain. Ein Passkey, der für app.example.com registriert wurde, funktioniert nicht unter example.com, wenn die RP-ID falsch gesetzt ist.

Das komplette Projekt im Überblick

Ihr fertiges Projekt umfasst vier Dateien. Die Routen aus den Schritten 4 bis 7 fügen Sie in app.js zwischen Konfiguration und app.listen ein. Die folgende Struktur fasst alles zusammen.

passkeys-nodejs-demo/
├── app.js              # Express-Server + 4 WebAuthn-Routen
├── db.js               # better-sqlite3 Schema und Verbindung
├── package.json        # type: module, Abhaengigkeiten
├── passkeys.db         # wird beim Start automatisch erzeugt
└── public/
    └── index.html      # Frontend mit @simplewebauthn/browser

# Starten:
npm start

# Ausgabe:
Server laeuft auf http://localhost:3000

Dieses Grundgerüst ist bewusst minimal, aber funktional vollständig. Es deckt beide Ceremonies, die persistente Speicherung und die Frontend-Integration ab. Für eine echte Anwendung ergänzen Sie Session-Management nach erfolgreicher Anmeldung, eine saubere Fehlerbehandlung und ein Fallback, etwa eine OAuth-Anmeldung mit PKCE für Geräte ohne Passkey-Unterstützung.

Passkeys lokal testen: virtuelle Authenticatoren in Chrome

Für automatisierte Tests und schnelle Iterationen müssen 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.

  1. Öffnen Sie die Entwicklertools mit F12 und wechseln Sie über das Drei-Punkte-Menü zu “More tools” und dann “WebAuthn”.
  2. Aktivieren Sie die Option “Enable virtual authenticator environment”.
  3. Fügen Sie einen neuen Authenticator hinzu, etwa mit Protokoll “ctap2”, Transport “internal” und aktivierter Option “Supports resident keys”.
  4. Registrieren Sie nun im Browser einen Passkey. Der virtuelle Authenticator übernimmt die Nutzerbestätigung automatisch.
  5. Melden Sie sich an. In der WebAuthn-Tabelle sehen Sie die gespeicherten Credentials samt steigendem Signaturzähler.

Der virtuelle Authenticator ist ideal, um die Datenflüsse zu verstehen und Edge Cases zu prüfen, etwa was passiert, wenn ein Nutzer mehrere Passkeys besitzt. Für End-to-End-Tests mit Playwright lässt sich der virtuelle Authenticator über 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ünden fehl wie ein echter Login mit falscher Konfiguration.

Ein besonders wertvoller Testfall betrifft den Signaturzähler. Erhöhen Sie im virtuellen Authenticator den Zähler künstlich oder setzen Sie ihn zurück, und prüfen Sie, ob Ihr Backend einen rückläufigen Wert korrekt als Warnsignal behandelt. In Produktion deutet ein sinkender Zähler auf einen geklonten Authenticator hin. Viele Hardware-Schlüssel zählen jedoch gar nicht hoch und melden konstant null. Entscheiden Sie bewusst, ob Sie bei einem unplausiblen Zähler die Anmeldung blockieren oder nur ein Audit-Log schreiben, und decken Sie beide Pfade mit Tests ab.

Robuste Fehlerbehandlung im Frontend

Ein produktionsreifes Passkey-Frontend muss mehr können als den Erfolgsfall. Nutzer brechen den Dialog ab, ältere Browser kennen WebAuthn nicht, und manchmal liefert der Authenticator gar keine Antwort. Die Bibliothek @simplewebauthn/browser exportiert dafür Hilfsfunktionen und wirft typisierte Fehler, die Sie gezielt abfangen sollten.

Prüfen Sie zuerst, ob der Browser WebAuthn überhaupt unterstützt, und behandeln Sie den Abbruch durch den Nutzer separat. Bricht jemand den System-Dialog ab, wirft der Browser einen NotAllowedError. Das ist kein echter Fehler, sondern eine bewusste Entscheidung, und sollte nicht als rote Fehlermeldung erscheinen.

import {
  startRegistration,
  browserSupportsWebAuthn,
} from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13/+esm';

async function registrieren(username, optionsJSON) {
  if (!browserSupportsWebAuthn()) {
    return zeigeHinweis('Ihr Browser unterstuetzt keine Passkeys.');
  }
  try {
    return await startRegistration({ optionsJSON });
  } catch (err) {
    if (err.name === 'NotAllowedError') {
      zeigeHinweis('Vorgang abgebrochen.');
    } else if (err.name === 'InvalidStateError') {
      zeigeHinweis('Dieser Passkey ist bereits registriert.');
    } else {
      zeigeHinweis('Unerwarteter Fehler: ' + err.message);
    }
    return null;
  }
}

Der InvalidStateError tritt auf, wenn ein Nutzer versucht, einen bereits registrierten Authenticator erneut anzumelden, genau das verhindert excludeCredentials serverseitig. Bieten Sie zusätzlich immer einen alternativen Anmeldeweg an, damit niemand vor einer Wand steht, falls Passkeys auf dem aktuellen Gerät nicht verfügbar sind. Eine klare Nutzerführung entscheidet darüber, ob die Adoption gelingt. Zeigen Sie verständliche Texte statt technischer Fehlercodes, und protokollieren Sie die Originalfehler serverseitig für die spätere Analyse.

7 häufige Stolperfallen bei Passkeys in Node.js

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.

  1. Falsche Origin oder RP-ID. Schon ein abweichender Port oder ein fehlendes https:// lässt jede Verifikation scheitern. Origin und RP-ID müssen exakt zur aufgerufenen Adresse passen.
  2. Veraltete API-Signaturen. Version 13 nutzt credential statt authenticator und registrationInfo.credential.id statt credentialID. Alte Beispiele aus dem Netz brechen.
  3. Synchrone Aufrufe. Alle vier Hauptfunktionen sind asynchron. Ohne await erhalten Sie ein Promise statt der Optionen.
  4. Challenge nicht gespeichert. Wer die Challenge nicht serverseitig ablegt oder mehrfach verwendet, öffnet Replay-Angriffe oder bekommt Verifikationsfehler.
  5. Public Key falsch serialisiert. Der Schlüssel ist ein Byte-Array. Speichern Sie ihn als BLOB und lesen Sie ihn mit new Uint8Array(...) zurück, nicht als String.
  6. HTTP statt HTTPS in Produktion. Außerhalb von localhost verweigert der Browser WebAuthn ohne sicheren Kontext.
  7. excludeCredentials vergessen. Ohne diese Liste registriert ein Nutzer denselben Authenticator mehrfach, was zu Verwirrung und doppelten Datensätzen führt.

Troubleshooting: 8 typische Fehler und Lösungen

Tritt ein Problem auf, hilft diese Tabelle bei der schnellen Diagnose. Sie deckt die acht häufigsten Fehlermeldungen und Symptome ab, die bei WebAuthn in Node.js auftreten.

Symptom / FehlerUrsacheLösung
“Unexpected authentication response origin”origin stimmt nicht mit der Adresse übereinORIGIN exakt setzen, inkl. Schema und Port
“Unexpected RP ID hash”rpID passt nicht zur DomainRP_ID auf registrierbare Domain ohne Schema setzen
“Challenge mismatch”Challenge nicht gespeichert oder bereits verbrauchtChallenge pro Ceremony speichern und danach löschen
WebAuthn-API ist undefinedSeite läuft über HTTP statt HTTPSlocalhost nutzen oder TLS-Zertifikat einrichten
“is not a function” bei startRegistrationFalsche Parameterform der Browser-Lib v13startRegistration({ optionsJSON }) verwenden
credential.publicKey ist leerBLOB falsch gelesennew Uint8Array(row.public_key) verwenden
“authenticator is not defined”Veraltete v9-API benutztParameter credential in v13 verwenden
better-sqlite3 lässt sich nicht installierenNode-Version unter 20 oder fehlende Build-ToolsNode 22 oder 24 nutzen, Build-Tools installieren

Aktivieren Sie bei der Fehlersuche das Debug-Logging, indem Sie die komplette Fehlermeldung aus dem catch-Block protokollieren. Die Bibliothek liefert präzise Hinweise, etwa welche Origin sie erwartet hat. Vergleichen Sie diese mit Ihrer Konfiguration. In neun von zehn Fällen liegt der Fehler bei origin oder rpID.

Fortgeschrittene Techniken: Discoverable Credentials und Conditional UI

Unser Projekt nutzt den Ablauf “Benutzername zuerst”: 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ür gibt es zwei Bausteine.

Discoverable Credentials (früher Resident Keys) speichern die Nutzerkennung direkt im Authenticator. Setzen Sie dazu residentKey: 'required' bei der Registrierung. Bei der Anmeldung lassen Sie allowCredentials leer, und der Browser zeigt alle passenden Passkeys für die Domain an. Conditional UI (Autofill) blendet verfügbare Passkeys direkt im Login-Feld ein, sobald der Nutzer es antippt. Im Frontend aktivieren Sie das über startAuthentication({ optionsJSON, useBrowserAutofill: true }) in Kombination mit dem Attribut autocomplete="username webauthn" am Eingabefeld.

Geräte-gebundene vs. synchronisierte Passkeys

Passkeys gibt es in zwei Ausprägungen. Synchronisierte Passkeys werden über die Cloud des Anbieters (Apple iCloud Schlüsselbund, Google Passwortmanager, 1Password, Bitwarden) zwischen Geräten geteilt. Sie sind komfortabel und überstehen Geräteverlust. Geräte-gebundene Passkeys, etwa auf einem YubiKey, verlassen die Hardware nie und bieten das höchste Schutzniveau. Für hochsensible Konten können Sie über das Feld credentialBackedUp aus der Registrierung erkennen, ob ein Passkey synchronisiert wird, und so eine Richtlinie durchsetzen. Welche Passwortmanager Passkeys speichern, vergleicht unser Passwortmanager-Vergleich 2026.

Attestation und Unternehmensrichtlinien

Für die meisten Anwendungen genügt attestationType: 'none'. Unternehmen, die nur zertifizierte Authenticatoren zulassen wollen, setzen 'direct' und prüfen die Attestation-Zertifikatskette gegen den FIDO Metadata Service. Das erhöht die Komplexität deutlich und ist nur sinnvoll, wenn regulatorische Vorgaben es verlangen. Für die meisten DACH-Unternehmen ist die einfache Variante ohne Attestation der pragmatische und datensparsame Weg.

Passkeys im DACH-Raum: BSI, NIS2 und Compliance

Für Unternehmen in Deutschland, Österreich und der Schweiz sind Passkeys mehr als ein Komfortgewinn. Das Bundesamt für Sicherheit in der Informationstechnik empfiehlt FIDO2 und Passkeys ausdrücklich als phishing-resistente Anmeldemethode (BSI: Sicherheit durch Passkeys). 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ür viele Einrichtungen, und Passkeys erfüllen diese Anforderung in einem einzigen Schritt, weil Besitz (Gerät) und Inhärenz (Biometrie) kombiniert werden.

Datenschutzrechtlich sind Passkeys vorteilhaft. Es werden keine biometrischen Daten an den Server übertragen, denn die biometrische Prüfung geschieht lokal auf dem Gerät. Der Server speichert lediglich einen öffentlichen Schlüssel, der für sich genommen keinen Personenbezug herstellt und bei einem Datenleck wertlos ist. Anders als gehashte Passwörter lässt sich ein öffentlicher Schlüssel nicht per Brute Force angreifen. Das reduziert die Folgen eines Einbruchs erheblich und vereinfacht die Argumentation gegenüber Aufsichtsbehörden.

Planen Sie die Einführung schrittweise. Bieten Sie Passkeys zunächst als zusätzliche Option neben dem bestehenden Login an, sammeln Sie Erfahrungswerte und machen Sie sie erst dann zur Standardmethode, wie es Microsoft im Mai 2025 für neue Konten getan hat. Halten Sie ein dokumentiertes Wiederherstellungsverfahren bereit, etwa über einen zweiten registrierten Passkey oder einen verifizierten E-Mail-Kanal, damit niemand bei Geräteverlust ausgesperrt wird.

Passkeys sind nicht die einzige Option für 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ählen.

KriteriumPasskeysTOTP (App)Magic Link
Phishing-resistentJa (Origin-Bindung)NeinNein
Zusätzliches Gerät nötigNeinMeist jaNein
Funktioniert offlineJaJaNein (E-Mail nötig)
Schutz bei Server-LeakHoch (nur Public Key)Mittel (Seed gespeichert)Gering
NutzerkomfortSehr hochMittelMittel

Der entscheidende Unterschied liegt in der Phishing-Resistenz. TOTP-Codes lassen sich auf einer gefälschten Seite genauso abfangen wie Passwörter, 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ögert oder im Spam landet. Passkeys umgehen beide Probleme, weil die Signatur niemals den Browser verlässt und an die korrekte Domain gebunden ist.

In der Praxis schließen sich die Verfahren nicht aus. Eine gute Strategie bietet Passkeys als bevorzugte Methode an, hält TOTP als vertrauten Zweitfaktor für Bestandsnutzer bereit und nutzt Magic Links höchstens als Notfall-Wiederherstellung. Wer den direkten Vergleich der TOTP-Apps sucht, findet ihn in unserem Test zu Authenticator-Apps. So kombinieren Sie höchste Sicherheit mit einem Migrationspfad, der niemanden aussperrt.

Häufig gestellte Fragen (FAQ)

Brauche ich eine externe Bibliothek für Passkeys in Node.js?

Technisch nein, praktisch ja. Sie könnten die WebAuthn-Datenstrukturen selbst parsen und Signaturen mit dem Node.js-Crypto-Modul prüfen, aber das ist fehleranfällig und sicherheitskritisch. Die Bibliothek @simplewebauthn/server kapselt die korrekte Verifikation, das CBOR-Parsing und die Algorithmus-Behandlung. Für nahezu alle Projekte ist sie die richtige Wahl.

Funktionieren Passkeys ohne Smartphone oder teure Hardware?

Ja. Jedes moderne Notebook mit Windows Hello, Touch ID oder Android-Gerät kann als Authenticator dienen. Ein separater Hardware-Schlüssel wie ein YubiKey ist optional und vor allem für Hochsicherheitsszenarien sinnvoll. Synchronisierte Passkeys lassen sich zudem über die Cloud auf mehreren Geräten nutzen.

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

Bei synchronisierten Passkeys sind die Schlüssel über die Cloud auf anderen Geräten verfügbar. Für geräte-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.

Sind Passkeys quantensicher?

Aktuelle Passkeys nutzen ES256 und RS256, also klassische Public-Key-Verfahren, die ein großer Quantencomputer theoretisch brechen könnte. Für die absehbare Zukunft sind sie sicher, und die FIDO Alliance arbeitet bereits an post-quantensicheren Algorithmen. Wer sich für quantenresistente Verfahren interessiert, findet in unserem Beitrag zu ML-KEM (Kyber) in Node.js einen Einstieg.

Ersetzen Passkeys die Zwei-Faktor-Authentifizierung?

Ein Passkey vereint zwei Faktoren in einem Schritt: den Besitz des Geräts und die biometrische oder PIN-Bestätigung. Damit ersetzt er klassische 2FA-Kombinationen aus Passwort plus TOTP-Code in den meisten Fällen und ist dabei phishing-resistent, was TOTP nicht ist. Für besonders sensible Aktionen können Sie zusätzlich eine erneute Passkey-Bestätigung verlangen.

Kann ich Passkeys neben Passwörtern anbieten?

Ja, und genau das ist der empfohlene Migrationspfad. Lassen Sie bestehende Nutzer einen Passkey zu ihrem Konto hinzufügen, während das Passwort vorerst als Fallback bestehen bleibt. Sobald genügend Nutzer einen Passkey besitzen, können Sie das Passwort optional machen oder ganz entfernen.

Welche Node.js-Version brauche ich mindestens?

Die Bibliothek @simplewebauthn/server in Version 13 verlangt mindestens Node.js 20. Empfohlen ist eine aktuelle LTS-Version, also Node.js 22 oder 24, da diese am längsten Sicherheitsupdates erhalten und besser mit better-sqlite3 harmonieren.

Fazit: passwortlos in Produktion

Sie haben ein vollständiges passwortloses Login-System mit Passkeys in Node.js gebaut, von der Datenbank über vier WebAuthn-Endpunkte bis zum Frontend. Der Kern ist überschaubar: zwei Ceremonies, je zwei Endpunkte, eine sauber gespeicherte Challenge und ein korrekt serialisierter öffentlicher Schlüssel. Die größten Risiken liegen nicht im Code, sondern in der Konfiguration von Origin und RP-ID sowie im Umgang mit der Challenge.

Mit 69 Prozent Verbraucher-Adoption und messbar höheren Login-Erfolgsraten sind Passkeys 2026 keine Zukunftstechnologie mehr, sondern Standard. Beginnen Sie mit der hier gezeigten Demo, ergänzen Sie Session-Management und einen Wiederherstellungspfad, und rollen Sie Passkeys schrittweise aus. Ihre Nutzer melden sich schneller an, und Phishing verliert seine wichtigste Angriffsfläche.