Get your free and exclusive 80-page Banking Passkey Report
passkey tutorial how to implement passkeys

Passkey-Tutorial: So implementieren Sie Passkeys in Web-Apps

Dieses Tutorial erklärt, wie Sie Passkeys in Ihrer Web-App implementieren. Wir verwenden Node.js (TypeScript), SimpleWebAuthn, Vanilla HTML / JavaScript und MySQL.

Vincent Delitz

Vincent

Created: June 20, 2025

Updated: June 20, 2025


We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.

1. Einführung: Wie man Passkeys implementiert#

In diesem Tutorial unterstützen wir Sie bei der Implementierung von Passkeys und bieten eine Schritt-für-Schritt-Anleitung, wie Sie Passkeys zu Ihrer Website hinzufügen können.

Demo Icon

Want to try passkeys yourself in a passkeys demo?

Try Passkeys

Eine moderne, robuste und benutzerfreundliche Authentifizierung ist entscheidend, wenn Sie eine großartige Website oder App erstellen möchten. Passkeys haben sich als Antwort auf diese Herausforderung herauskristallisiert. Als neuer Standard für Logins versprechen sie eine Zukunft ohne die Nachteile traditioneller Passwörter und bieten ein wirklich passwortloses Login-Erlebnis (das nicht nur sicher, sondern auch äußerst bequem ist).

Was das Potenzial von Passkeys wirklich unterstreicht, ist die breite Unterstützung, die sie erhalten haben. Jeder bedeutende Browser, sei es Chrome, Firefox, Safari oder Edge, und alle wichtigen Gerätehersteller (Apple, Microsoft, Google) haben Unterstützung integriert. Diese einstimmige Annahme zeigt, dass Passkeys der neue Standard für Logins sind.

Ja, es gibt bereits Tutorials zur Integration von Passkeys in Webanwendungen. Sei es für Frontend-Frameworks wie React, Vue.js oder Next.js, es gibt eine Fülle von Anleitungen, die darauf ausgelegt sind, Herausforderungen zu mindern und Ihre Passkey-Implementierungen zu beschleunigen. Es fehlt jedoch ein End-to-End-Tutorial, das minimalistisch und hardwarenah bleibt. Viele Entwickler haben uns angesprochen und um ein Tutorial gebeten, das Klarheit in die Implementierung von Passkeys für Web-Apps bringt.

Genau aus diesem Grund haben wir diese Anleitung erstellt. Unser Ziel? Ein minimal lauffähiges Setup für Passkeys zu erstellen, das die Frontend-, Backend- und Datenbankebene umfasst (letztere wird oft vernachlässigt, obwohl sie ernsthafte Kopfschmerzen verursachen kann).

PasskeyAssessment Icon

Get a free passkey assessment in 15 minutes.

Book free consultation

Am Ende dieser Reise werden Sie eine minimal lauffähige Webanwendung erstellt haben, in der Sie:

Für diejenigen, die es eilig haben oder eine Referenz benötigen, ist die gesamte Codebasis auf GitHub verfügbar.

Neugierig, wie das Endergebnis aussieht? Hier ist ein kleiner Vorgeschmack auf das fertige Projekt (wir geben zu, es sieht sehr einfach aus, aber das Interessante steckt unter der Oberfläche):

Wir sind uns vollkommen bewusst, dass Teile des Codes und des Projekts anders oder anspruchsvoller gestaltet werden können, aber wir wollten uns auf das Wesentliche konzentrieren. Deshalb haben wir die Dinge absichtlich einfach und auf Passkeys zentriert gehalten.

StateOfPasskeys Icon

Want to find out how many people use passkeys?

View Adoption Data

Wie füge ich Passkeys zu meiner Produktionswebsite hinzu?

Dies ist ein sehr minimales Beispiel für die Passkey-Authentifizierung. Die folgenden Dinge werden in diesem Tutorial NICHT berücksichtigt / implementiert oder nur sehr grundlegend:

Die vollständige Unterstützung all dieser Funktionen erfordert einen enormen zusätzlichen Entwicklungsaufwand. Für Interessierte empfehlen wir einen Blick auf diesen Artikel über Missverständnisse bei der Passkey-Implementierung durch Entwickler.

Slack Icon

Become part of our Passkeys Community for updates & support.

Join

2. Voraussetzungen zur Integration von Passkeys#

Bevor wir tief in die Implementierung von Passkeys eintauchen, werfen wir einen Blick auf die erforderlichen Fähigkeiten und Werkzeuge. Hier ist, was Sie für den Anfang benötigen:

2.1 Frontend: Vanilla HTML & JavaScript#

Ein solides Verständnis der Bausteine des Webs – HTML, CSS und JavaScript – ist unerlässlich. Wir haben die Dinge absichtlich einfach gehalten, auf moderne JavaScript-Frameworks verzichtet und uns auf Vanilla JavaScript / HTML verlassen. Das einzige anspruchsvollere, was wir verwenden, ist die WebAuthn-Wrapper-Bibliothek @simplewebauthn/browser.

2.2 Backend: Node.js (Express) in TypeScript + SimpleWebAuthn#

Für unser Backend verwenden wir einen Node.js (Express) Server, der in TypeScript geschrieben ist. Wir haben uns auch entschieden, mit der WebAuthn-Serverimplementierung von SimpleWebAuthn (@simplewebauthn/server zusammen mit @simplewebauthn/typescript-types) zu arbeiten. Es gibt zahlreiche WebAuthn-Serverimplementierungen, sodass Sie natürlich auch eine dieser verwenden können. Da wir uns für den TypeScript WebAuthn-Server entschieden haben, sind grundlegende Kenntnisse in Node.js und npm erforderlich.

2.3 Datenbank: MySQL#

Alle Benutzerdaten und öffentlichen Schlüssel der Passkeys werden in einer Datenbank gespeichert. Wir haben MySQL als Datenbanktechnologie ausgewählt. Ein grundlegendes Verständnis von MySQL und relationalen Datenbanken ist von Vorteil, obwohl wir Sie durch die einzelnen Schritte führen werden.

Im Folgenden verwenden wir die Begriffe WebAuthn und Passkeys oft austauschbar, auch wenn sie offiziell nicht dasselbe bedeuten mögen. Zum besseren Verständnis, insbesondere im Code-Teil, treffen wir diese Annahme jedoch.

Mit diesen Voraussetzungen sind Sie bestens gerüstet, um in die Welt der Passkeys einzutauchen.

Ben Gould Testimonial

Ben Gould

Head of Engineering

I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.

Über 10.000 Entwickler vertrauen Corbado und machen das Internet mit Passkeys sicherer. Haben Sie Fragen? Wir haben über 150 Blogbeiträge zu Passkeys verfasst.

Tritt der Passkeys Community bei

3. Architekturübersicht: Beispielimplementierung für Passkeys#

Bevor wir uns mit dem Code und den Konfigurationen befassen, werfen wir einen Blick auf die Architektur des Systems, das wir erstellen möchten. Hier ist eine Aufschlüsselung der Architektur, die wir einrichten werden:

  • Frontend: Es besteht aus zwei Schaltflächen – eine für die Benutzerregistrierung (Erstellen eines Passkeys) und die andere für die Authentifizierung (Einloggen mit dem Passkey).
  • Gerät & Browser: Sobald eine Aktion im Frontend ausgelöst wird, kommen das Gerät und der Browser ins Spiel. Sie erleichtern die Erstellung und Überprüfung des Passkeys und fungieren als Vermittler zwischen dem Benutzer und dem Backend.
  • Backend: Das Backend ist der Ort, an dem die eigentliche Magie in unserer Anwendung geschieht. Es verarbeitet alle vom Frontend initiierten Anfragen. Dieser Prozess umfasst die Erstellung und Überprüfung von Passkeys. Im Kern der Backend-Operationen steht der WebAuthn-Server. Entgegen dem, was der Name vermuten lässt, handelt es sich nicht um einen eigenständigen Server. Stattdessen ist es eine Bibliothek oder ein Paket, das den WebAuthn-Standard implementiert. Die beiden Hauptfunktionen sind: Registrierung (Sign-up), bei der neue Benutzer ihre Passkeys erstellen, und Authentifizierung (Login), bei der bestehende Benutzer sich mit ihren Passkeys einloggen. In seiner einfachsten Form bietet der WebAuthn-Server vier öffentliche API-Endpunkte, die in zwei Kategorien unterteilt sind: zwei für die Registrierung und zwei für die Authentifizierung. Sie sind so konzipiert, dass sie Daten in einem bestimmten Format empfangen, die dann vom WebAuthn-Server verarbeitet werden. Der WebAuthn-Server ist für alle notwendigen kryptografischen Operationen verantwortlich. Ein wesentlicher Aspekt ist, dass diese API-Endpunkte über HTTPS bereitgestellt werden müssen.
  • MySQL-Datenbank: Als unser Speicher-Rückgrat ist die MySQL-Datenbank für die Speicherung von Benutzerdaten und deren zugehörigen Anmeldeinformationen verantwortlich.
Analyzer Icon

Are your users passkey-ready?

Test Passkey-Readiness

Mit dieser Architekturübersicht sollten Sie eine konzeptionelle Karte haben, wie die Komponenten unserer Anwendung zusammenspielen. Im weiteren Verlauf werden wir tiefer in jede dieser Komponenten eintauchen und deren Einrichtung, Konfiguration und Zusammenspiel detailliert beschreiben.

Das folgende Diagramm beschreibt den Prozessablauf während der Registrierung (Sign-up):

Das folgende Diagramm beschreibt den Prozessablauf während der Authentifizierung (Login):

Darüber hinaus finden Sie hier die Projektstruktur (nur die wichtigsten Dateien):

passkeys-tutorial ├── src # Enthält den gesamten TypeScript-Quellcode des Backends │ ├── controllers # Geschäftslogik zur Behandlung spezifischer Anfragetypen │ │ ├── authentication.ts # Logik für die Passkey-Authentifizierung │ │ └── registration.ts # Logik für die Passkey-Registrierung │ ├── middleware │ │ ├── customError.ts # Hinzufügen benutzerdefinierter Fehlermeldungen in standardisierter Weise │ │ └── errorHandler.ts # Allgemeiner Fehlerhandler │ ├── public │ │ ├── index.html # Haupt-HTML-Datei im Frontend │ │ ├── css │ │ │ └── style.css # Grundlegendes Styling │ │ └── js │ │ └── script.js # JavaScript-Logik (inkl. WebAuthn-API) │ ├── routes # Definitionen von API-Routen und deren Handlern │ │ └── routes.ts # Spezifische Passkey-Routen │ ├── services │ │ ├── credentialService.ts# Interagiert mit der Credential-Tabelle │ │ └── userService.ts # Interagiert mit der User-Tabelle │ ├── utils # Hilfsfunktionen und Dienstprogramme │ | ├── constants.ts # Einige Konstanten (z.B. rpID) │ | └── utils.ts # Hilfsfunktion │ ├── database.ts # Stellt die Verbindung von Node.js zur MySQL-Datenbank her │ ├── index.ts # Einstiegspunkt des Node.js-Servers │ └── server.ts # Verwaltet alle Servereinstellungen ├── config.json # Einige Konfigurationen für das Node.js-Projekt ├── docker-compose.yml # Definiert Dienste, Netzwerke und Volumes für Docker-Container ├── Dockerfile # Erstellt ein Docker-Image des Projekts ├── init-db.sql # Definiert unser MySQL-Datenbankschema ├── package.json # Verwaltet Node.js-Projektabhängigkeiten und Skripte └── tsconfig.json # Konfiguriert, wie TypeScript Ihren Code kompiliert

4. Einrichtung der MySQL-Datenbank#

Bei der Implementierung von Passkeys ist die Einrichtung der Datenbank eine Schlüsselkomponente. Unser Ansatz verwendet einen Docker-Container, der MySQL ausführt und eine unkomplizierte und isolierte Umgebung bietet, die für zuverlässige Tests und Bereitstellungen unerlässlich ist.

Unser Datenbankschema ist absichtlich minimalistisch gehalten und umfasst nur zwei Tabellen. Diese Einfachheit trägt zu einem klareren Verständnis und einer einfacheren Wartung bei.

Detaillierte Tabellenstruktur

1. Credentials-Tabelle: Diese Tabelle ist zentral für die Passkey-Authentifizierung und speichert die Passkey-Anmeldeinformationen. Kritische Spalten:

  • credential_id: Ein eindeutiger Bezeichner für jede Anmeldeinformation. Die Auswahl des richtigen Datentyps für dieses Feld ist entscheidend, um Formatierungsfehler zu vermeiden.
  • public_key: Speichert den öffentlichen Schlüssel für jede Anmeldeinformation. Wie bei credential_id sind der geeignete Datentyp und die Formatierung entscheidend.

2. Users-Tabelle: Verknüpft Benutzerkonten mit ihren entsprechenden Anmeldeinformationen.

Beachten Sie, dass wir die erste Tabelle credentials genannt haben, da dies unserer Erfahrung und den Empfehlungen anderer Bibliotheken entspricht (im Gegensatz zum Vorschlag von SimpleWebAuthn, sie authenticator oder authenticator_device zu nennen).

Die Datentypen für credential_id und public_key sind entscheidend. Fehler entstehen oft durch falsche Datentypen, Kodierung oder Formatierung (insbesondere der Unterschied zwischen Base64 und Base64URL ist eine häufige Fehlerquelle), was den gesamten Registrierungs- (Sign-up) oder Authentifizierungsprozess (Login) stören kann.

Alle notwendigen SQL-Befehle zum Einrichten dieser Tabellen sind in der Datei init-db.sql enthalten. Dieses Skript gewährleistet eine schnelle und fehlerfreie Initialisierung der Datenbank.

Für anspruchsvollere Fälle können Sie credential_device_type oder credential_backed_up hinzufügen, um mehr Informationen über die Anmeldeinformationen zu speichern und die Benutzererfahrung zu verbessern. Darauf verzichten wir in diesem Tutorial jedoch.

init-db.sql
CREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );

Nachdem wir diese Datei erstellt haben, erstellen wir eine neue docker-compose.yml-Datei auf der Root-Ebene des Projekts:

docker-compose.yml
version: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql

Diese Datei startet die MySQL-Datenbank auf Port 3306 und erstellt die definierte Datenbankstruktur. Es ist wichtig zu beachten, dass der Name und das Passwort für die hier verwendete Datenbank zu Demonstrationszwecken einfach gehalten sind. In einer Produktionsumgebung sollten Sie für erhöhte Sicherheit komplexere Anmeldeinformationen verwenden.

Als Nächstes fahren wir mit dem Ausführen unseres Docker-Containers fort. An diesem Punkt enthält unsere docker-compose.yml-Datei nur diesen einen Container, aber wir werden später weitere Komponenten hinzufügen. Um den Container zu starten, verwenden Sie den folgenden Befehl:

docker compose up -d

Sobald der Container läuft, müssen wir überprüfen, ob die Datenbank wie erwartet funktioniert. Öffnen Sie ein Terminal und führen Sie den folgenden Befehl aus, um mit der MySQL-Datenbank zu interagieren:

docker exec -it <container ID> mysql -uroot -p

Sie werden aufgefordert, das Root-Passwort einzugeben, das in unserem Beispiel my-secret-pw lautet. Nach dem Einloggen wählen Sie die webauthn_db-Datenbank aus und zeigen die Tabellen mit diesen Befehlen an:

use webauthn_db; show tables;

An dieser Stelle sollten Sie die beiden in unserem Skript definierten Tabellen sehen. Anfänglich sind diese Tabellen leer, was anzeigt, dass unsere Datenbankeinrichtung abgeschlossen und für die nächsten Schritte bei der Implementierung von Passkeys bereit ist.

5. Implementierung von Passkeys: Backend-Integrationsschritte#

Das Backend ist das Herzstück jeder Passkey-Anwendung und fungiert als zentraler Knotenpunkt für die Verarbeitung von Benutzerauthentifizierungsanfragen vom Frontend. Es kommuniziert mit der WebAuthn-Serverbibliothek zur Handhabung von Registrierungs- (Sign-up) und Authentifizierungsanfragen (Login) und interagiert mit Ihrer MySQL-Datenbank, um Benutzeranmeldeinformationen zu speichern und abzurufen. Im Folgenden führen wir Sie durch die Einrichtung Ihres Backends mit Node.js (Express) mit TypeScript, das eine öffentliche API zur Bearbeitung aller Anfragen bereitstellt.

5.1 Initialisieren des Node.js (Express) Servers#

Erstellen Sie zunächst ein neues Verzeichnis für Ihr Projekt und navigieren Sie mit Ihrem Terminal oder Ihrer Eingabeaufforderung dorthin.

Führen Sie den Befehl aus

npx create-express-typescript-application passkeys-tutorial

Dies erstellt ein grundlegendes Code-Skelett einer Node.js (Express) App, die in TypeScript geschrieben ist und die wir für weitere Anpassungen verwenden können.

Ihr Projekt erfordert mehrere Schlüsselpakete, die wir zusätzlich installieren müssen:

  • @simplewebauthn/server: Eine serverseitige Bibliothek zur Erleichterung von WebAuthn-Operationen wie Benutzerregistrierung (Sign-up) und Authentifizierung (Login).
  • express-session: Middleware für Express.js zur Verwaltung von Sitzungen, Speicherung serverseitiger Sitzungsdaten und Handhabung von Cookies.
  • uuid: Ein Dienstprogramm zur Erzeugung von universell eindeutigen Bezeichnern (UUIDs), die häufig zur Erstellung eindeutiger Schlüssel oder Bezeichner in Anwendungen verwendet werden.
  • mysql2: Ein Node.js-Client für MySQL, der Funktionen zum Verbinden und Ausführen von Abfragen gegen MySQL-Datenbanken bereitstellt.

Wechseln Sie in das neue Verzeichnis und installieren Sie sie mit den folgenden Befehlen (wir installieren auch die erforderlichen TypeScript-Typen):

cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid

Um zu bestätigen, dass alles korrekt installiert ist, führen Sie aus

npm run dev:nodemon

Dies sollte Ihren Node.js-Server im Entwicklungsmodus mit Nodemon starten, der den Server bei jeder Dateiänderung automatisch neu startet.

Tipp zur Fehlerbehebung: Wenn Fehler auftreten, versuchen Sie, ts-node in der package.json-Datei auf Version 10.8.1 zu aktualisieren und führen Sie dann npm i aus, um die Updates zu installieren.

Ihre server.ts-Datei hat die grundlegende Einrichtung und Middleware für eine Express-Anwendung. Um die Passkey-Funktionalität zu integrieren, müssen Sie hinzufügen:

  • Routen: Definieren Sie neue Routen für die Passkey-Registrierung (Sign-up) und Authentifizierung (Login).
  • Controller: Erstellen Sie Controller, um die Logik für diese Routen zu handhaben.
  • Middleware: Integrieren Sie Middleware für die Anfrage- und Fehlerbehandlung.
  • Services: Erstellen Sie Services, um Daten in der Datenbank abzurufen und zu speichern.
  • Hilfsfunktionen: Fügen Sie Hilfsfunktionen für effiziente Code-Operationen hinzu.

Diese Erweiterungen sind der Schlüssel zur Aktivierung der Passkey-Authentifizierung im Backend Ihrer Anwendung. Wir richten sie später ein.

Debugger Icon

Want to experiment with passkey flows? Try our Passkeys Debugger.

Try for Free

5.2 MySQL-Datenbankverbindung#

Nachdem wir die Datenbank in Abschnitt 4 erstellt und gestartet haben, müssen wir nun sicherstellen, dass unser Backend eine Verbindung zur MySQL-Datenbank herstellen kann. Dazu erstellen wir eine neue database.ts-Datei im /src-Ordner und fügen den folgenden Inhalt hinzu:

database.ts
import mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();

Diese Datei wird später von unserem Server verwendet, um auf die Datenbank zuzugreifen.

5.3 App-Server-Konfiguration#

Werfen wir einen kurzen Blick auf unsere config.json, in der bereits zwei Variablen definiert sind: der Port, auf dem wir die Anwendung ausführen, und die Umgebung:

config.json
{ "PORT": 8080, "NODE_ENV": "development" }

package.json kann so bleiben, wie es ist, und sollte so aussehen:

package.json
{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }

index.ts sieht so aus:

index.ts
import app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });

In server.ts müssen wir noch einige Dinge anpassen. Außerdem wird ein temporärer Cache irgendeiner Art (z.B. redis, memcache oder express-session) benötigt, um temporäre Challenges zu speichern, gegen die sich Benutzer authentifizieren können. Wir haben uns für express-session entschieden und deklarieren das express-session-Modul oben, damit die Dinge mit express-session funktionieren. Zusätzlich straffen wir das Routing und entfernen die Fehlerbehandlung vorerst (diese wird später zur Middleware hinzugefügt):

server.ts
import express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;

5.4 Credential Service & User Service#

Um die Daten in unseren beiden erstellten Tabellen effektiv zu verwalten, entwickeln wir zwei separate Dienste in einem neuen src/services-Verzeichnis: authenticatorService.ts und userService.ts.

Jeder Dienst wird CRUD-Methoden (Create, Read, Update, Delete) kapseln, die es uns ermöglichen, auf modulare und organisierte Weise mit der Datenbank zu interagieren. Diese Dienste erleichtern das Speichern, Abrufen und Aktualisieren von Daten in den authenticator- und Benutzertabellen. So sollte die Struktur dieser erforderlichen Dateien angelegt sein:

userService.ts sieht so aus:

userService.ts
import { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };

credentialService.ts sieht wie folgt aus:

credentialService.ts
import { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };

5.5 Middleware#

Zur zentralen Fehlerbehandlung und zur Erleichterung des Debuggings fügen wir eine errorHandler.ts-Datei hinzu:

errorHandler.ts
import { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };

Außerdem fügen wir eine neue customError.ts-Datei hinzu, da wir später in der Lage sein wollen, benutzerdefinierte Fehler zu erstellen, um Fehler schneller zu finden:

customError.ts
export class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }

5.6 Hilfsprogramme#

Im utils-Ordner erstellen wir zwei Dateien constants.ts und utils.ts.

constant.ts enthält einige grundlegende WebAuthn-Serverinformationen, wie den Namen der relying party, die relying party ID und den Ursprung:

constant.ts
export const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;

utils.ts enthält zwei Funktionen, die wir später zum Kodieren und Dekodieren von Daten benötigen:

utils.ts
export const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));

5.7 Passkey-Controller mit SimpleWebAuthn#

Jetzt kommen wir zum Herzstück unseres Backends: den Controllern. Wir erstellen zwei Controller, einen zum Erstellen eines neuen Passkeys (registration.ts) und einen zum Einloggen mit einem Passkey (authentication.ts).

registration.ts sieht so aus:

registration.ts
import { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };

Lassen Sie uns die Funktionalitäten unserer Controller überprüfen, die die beiden Schlüsselschnittstellen im WebAuthn-Registrierungsprozess (Sign-up) behandeln. Hier liegt auch einer der größten Unterschiede zur passwortbasierten Authentifizierung: Für jeden Registrierungs- (Sign-up) oder Authentifizierungsversuch (Login) sind zwei Backend-API-Aufrufe erforderlich, die dazwischen spezifische Frontend-Inhalte benötigen. Passwörter benötigen normalerweise nur einen Endpunkt.

1. handleRegisterStart Endpunkt:

Dieser Endpunkt wird vom Frontend ausgelöst und empfängt einen Benutzernamen, um einen neuen Passkey und ein neues Konto zu erstellen. In diesem Beispiel erlauben wir die Erstellung eines neuen Kontos / Passkeys nur, wenn noch kein Konto existiert. In realen Anwendungen müssten Sie dies so handhaben, dass den Benutzern mitgeteilt wird, dass bereits ein Passkey existiert und das Hinzufügen vom selben Gerät nicht möglich ist (aber der Benutzer könnte Passkeys von einem anderen Gerät nach einer Form der Bestätigung hinzufügen). Der Einfachheit halber übersehen wir dies in diesem Tutorial.

Die PublicKeyCredentialCreationOptions werden vorbereitet. residentKey wird auf preferred und attestationType auf direct gesetzt, um mehr Daten vom authenticator für eine mögliche Datenbankspeicherung zu sammeln.

Im Allgemeinen bestehen die PublicKeyCredentialCreationOptions aus den folgenden Daten:

dictionary [PublicKeyCredentialCreationOptions](/glossary/publickeycredentialcreationoptions) { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> [excludeCredentials](/glossary/excludecredentials) = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
  • rp: Repräsentiert die Informationen der relying party (Website oder Dienst), typischerweise einschließlich ihres Namens (rp.name) und der Domain (rp.id).
  • user: Enthält Benutzerkontodetails wie user.name, user.id und user.displayName.
  • challenge: Ein sicherer, zufälliger Wert, der vom WebAuthn-Server erstellt wird, um Replay-Angriffe während des Registrierungsprozesses zu verhindern.
  • pubKeyCredParams: Gibt den Typ der zu erstellenden öffentlichen Schlüsselanmeldeinformationen an, einschließlich des verwendeten kryptografischen Algorithmus (mehr lesen).
  • timeout: Optional, legt die Zeit in Millisekunden fest, die der Benutzer hat, um die Interaktion abzuschließen.
  • excludeCredentials: Eine Liste von Anmeldeinformationen, die ausgeschlossen werden sollen; wird verwendet, um die mehrfache Registrierung eines Passkeys für dasselbe Gerät / denselben authenticator zu verhindern (mehr lesen).
  • authenticatorSelection: Kriterien zur Auswahl des authenticators, z. B. ob er die Benutzerverifizierung unterstützen muss oder wie residente Schlüssel gefördert werden sollen (mehr lesen).
  • attestation: Gibt die gewünschte Präferenz für die Übermittlung der attestation an, wie "none", "indirect" oder "direct" (mehr lesen).
  • extensions: Optional, ermöglicht zusätzliche Client-Erweiterungen.

Die User ID und die Challenge werden in einem Sitzungsobjekt gespeichert, was den Prozess für Tutorialzwecke vereinfacht. Außerdem wird die Sitzung nach jedem Registrierungs- (Sign-up) oder Authentifizierungsversuch (Login) gelöscht.

2. handleRegisterFinish Endpunkt:

Dieser Endpunkt ruft die zuvor gesetzte user ID und Challenge ab. Er verifiziert die RegistrationResponse mit der Challenge. Wenn sie gültig ist, speichert er eine neue Anmeldeinformation für den Benutzer. Sobald sie in der Datenbank gespeichert ist, werden die user ID und die Challenge aus der Sitzung entfernt.

Tipp: Beim Debuggen Ihrer Anwendung empfehlen wir dringend, Chrome als Browser und seine integrierten Funktionen zu verwenden, um die Entwicklererfahrung von passkey-basierten Anwendungen zu verbessern, z. B. den virtuellen WebAuthn-Authenticator und das Geräteprotokoll (siehe unsere Tipps für Entwickler unten für weitere Informationen).

Als Nächstes gehen wir zu authentication.ts über, das eine ähnliche Struktur und Funktionalität aufweist.

authentication.ts sieht so aus:

authentication.ts
import { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // [allowCredentials](/glossary/allowcredentials) is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };

Unser Authentifizierungsprozess (Login) umfasst zwei Endpunkte:

1. handleLoginStart Endpunkt:

Dieser Endpunkt wird aktiviert, wenn ein Benutzer versucht, sich einzuloggen. Er prüft zunächst, ob der Benutzername in der Datenbank existiert, und gibt einen Fehler zurück, wenn er nicht gefunden wird. In einem realen Szenario könnten Sie stattdessen anbieten, ein neues Konto zu erstellen.

Für bestehende Benutzer ruft er die Benutzer-ID aus der Datenbank ab, speichert sie in der Sitzung und generiert PublicKeyCredentialRequestOptions-Optionen. allowCredentials wird leer gelassen, um die Verwendung von Anmeldeinformationen nicht einzuschränken. Deshalb können im Passkey-Modal alle verfügbaren Passkeys für diese relying party ausgewählt werden.

Die generierte Challenge wird ebenfalls in der Sitzung gespeichert und die PublicKeyCredentialRequestOptions werden an das Frontend zurückgesendet.

Die PublicKeyCredentialRequestOptions bestehen aus den folgenden Daten:

dictionary [PublicKeyCredentialRequestOptions](/glossary/publickeycredentialrequestoptions) { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> [allowCredentials](/glossary/allowcredentials) = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
  • challenge: Ein sicherer, zufälliger Wert vom WebAuthn-Server, der verwendet wird, um Replay-Angriffe während des Authentifizierungsprozesses zu verhindern.
  • timeout: Optional, legt die Zeit in Millisekunden fest, die der Benutzer hat, um auf die Authentifizierungsanfrage zu antworten.
  • rpId: Die Relying Party ID, typischerweise die Domain des Dienstes.
  • allowCredentials: Eine optionale Liste von Anmeldeinformationsdeskriptoren, die angeben, welche Anmeldeinformationen für diese Authentifizierung (Login) verwendet werden dürfen.
  • userVerification: Gibt die Anforderung für die Benutzerverifizierung an, wie "required", "preferred" oder "discouraged".
  • extensions: Optional, ermöglicht zusätzliche Client-Erweiterungen.

2. handleLoginFinish Endpunkt:

Dieser Endpunkt ruft currentChallenge und loggedInUserId aus der Sitzung ab.

Er fragt die Datenbank nach der richtigen Anmeldeinformation ab, indem er die credential ID aus dem Body verwendet. Wenn die Anmeldeinformation gefunden wird, bedeutet dies, dass der mit dieser credential ID verknüpfte Benutzer nun authentifiziert (eingeloggt) werden kann. Dann können wir den Benutzer aus der Benutzertabelle über die Benutzer-ID abfragen, die wir von der Anmeldeinformation erhalten, und die authenticationResponse mit der Challenge und dem Anfrage-Body verifizieren. Wenn alles erfolgreich ist, zeigen wir die Erfolgsmeldung für den Login an. Wenn keine passende Anmeldeinformation gefunden wird, wird ein Fehler gesendet.

Zusätzlich wird, wenn die Verifizierung erfolgreich ist, der Zähler der Anmeldeinformation aktualisiert, die verwendete Challenge und die loggedInUserId werden aus der Sitzung entfernt.

Darüber hinaus können wir den Ordner src/app und src/constant zusammen mit allen darin enthaltenen Dateien löschen.

Hinweis: Eine ordnungsgemäße Sitzungsverwaltung und Routenschutz, die in realen Anwendungen entscheidend sind, werden hier der Einfachheit halber in diesem Tutorial weggelassen.

5.8 Passkey-Routen#

Zu guter Letzt müssen wir sicherstellen, dass unsere Controller erreichbar sind, indem wir die entsprechenden Routen zu routes.ts hinzufügen, das sich in einem neuen Verzeichnis src/routes befindet:

routes.ts
import [express](/blog/ nodejs - passkeys ) from 'express'; import {handleError} from '../middleware/errorHandler'; import {handleRegisterStart, handleRegisterFinish} from '../controllers/registration'; import {handleLoginStart, handleLoginFinish} from '../controllers/authentication'; const router = express.Router(); router.post('/registerStart', handleRegisterStart); router.post('/registerFinish', handleRegisterFinish); router.post('/loginStart', handleLoginStart); router.post('/loginFinish', handleLoginFinish); router.use(handleError); export {router};
Substack Icon

Subscribe to our Passkeys Substack for the latest news.

Subscribe

6. Passkeys in das Frontend integrieren#

Dieser Teil des Passkey-Tutorials konzentriert sich darauf, wie Sie Passkeys im Frontend Ihrer Anwendung unterstützen. Wir haben ein sehr einfaches Frontend, das aus drei Dateien besteht: index.html, styles.css und script.js. Alle drei Dateien befinden sich in einem neuen src/public-Ordner.

Die index.html-Datei enthält ein Eingabefeld für den Benutzernamen und zwei Schaltflächen zum Registrieren und Anmelden. Außerdem importieren wir das @simplewebauthn/browser-Skript, das die Interaktion mit der Web Authentication API des Browsers in der js/script.js-Datei vereinfacht.

index.html sieht so aus:

index.html
<!DOCTYPE html> <html lang="de"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Benutzernamen eingeben" /> <button id="registerButton">Registrieren</button> <button id="loginButton">Anmelden</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>

script.js sieht wie folgt aus:

script.js
document.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Benutzernamen aus dem Eingabefeld abrufen const username = document.getElementById("username").value; try { // Registrierungsoptionen von Ihrem Server abrufen. Hier erhalten wir auch die Challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Überprüfen, ob die Registrierungsoptionen in Ordnung sind. if (!response.ok) { throw new Error( "Benutzer existiert bereits oder Registrierungsoptionen konnten nicht vom Server abgerufen werden", ); } // Die Registrierungsoptionen in JSON konvertieren. const options = await response.json(); console.log(options); // Dies veranlasst den Browser, das Passkey / WebAuthn-Modal anzuzeigen (z.B. Face ID, Touch ID, Windows Hello). // Eine neue Attestation wird erstellt. Das bedeutet auch, dass ein neues öffentlich-privates Schlüsselpaar erstellt wird. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Senden Sie die attestationResponse zur Überprüfung und Speicherung an den Server zurück. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registrierung erfolgreich"); } else { showMessage("Registrierung fehlgeschlagen", true); } } catch (error) { showMessage("Fehler: " + error.message, true); } } async function login() { // Benutzernamen aus dem Eingabefeld abrufen const username = document.getElementById("username").value; try { // Anmeldeoptionen von Ihrem Server abrufen. Hier erhalten wir auch die Challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Überprüfen, ob die Anmeldeoptionen in Ordnung sind. if (!response.ok) { throw new Error("Anmeldeoptionen konnten nicht vom Server abgerufen werden"); } // Die Anmeldeoptionen in JSON konvertieren. const options = await response.json(); console.log(options); // Dies veranlasst den Browser, das Passkey / WebAuthn-Modal anzuzeigen (z.B. Face ID, Touch ID, Windows Hello). // Eine neue assertionResponse wird erstellt. Das bedeutet auch, dass die Challenge signiert wurde. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Senden Sie die assertionResponse zur Überprüfung an den Server zurück. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Anmeldung erfolgreich"); } else { showMessage("Anmeldung fehlgeschlagen", true); } } catch (error) { showMessage("Fehler: " + error.message, true); } }

In script.js gibt es drei Hauptfunktionen:

1. showMessage-Funktion:

Dies ist eine Hilfsfunktion, die hauptsächlich zur Anzeige von Fehlermeldungen verwendet wird und beim Debuggen hilft.

2. Register-Funktion:

Wird ausgelöst, wenn der Benutzer auf "Registrieren" klickt. Sie extrahiert den Benutzernamen aus dem Eingabefeld und sendet ihn an den passkeyRegisterStart-Endpunkt. Die Antwort enthält PublicKeyCredentialCreationOptions, die in JSON konvertiert und an SimpleWebAuthnBrowser.startRegistration übergeben werden. Dieser Aufruf aktiviert den Geräte-authenticator (wie Face ID oder Touch ID). Nach erfolgreicher lokaler Authentifizierung wird die signierte Challenge an den passkeyRegisterFinish-Endpunkt zurückgesendet, wodurch der Passkey-Erstellungsprozess abgeschlossen wird.

Während des Registrierungsprozesses (Sign-up) spielt das attestation-Objekt eine entscheidende Rolle, also werfen wir einen genaueren Blick darauf.

Das attestation-Objekt besteht hauptsächlich aus drei Komponenten: fmt, attStmt und authData. Das fmt-Element gibt das Format der attestation-Anweisung an, während attStmt die eigentliche Attestierungsanweisung darstellt. In Szenarien, in denen die attestation als unnötig erachtet wird, wird fmt als "none" bezeichnet, was zu einem leeren attStmt führt.

Der Fokus liegt auf dem authData-Segment innerhalb dieser Struktur. Dieses Segment ist der Schlüssel zum Abrufen wesentlicher Elemente wie der Relying Party ID, Flags, Zähler und attestierten Anmeldeinformationsdaten auf unserem Server. Bezüglich der Flags sind von besonderem Interesse BS (Backup State) und BE (Backup Eligibility), die mehr Informationen darüber liefern, ob ein Passkey synchronisiert ist (z.B. über iCloud Keychain oder 1Password). Außerdem liefern UV (User Verification) und UP (User Presence) weitere nützliche Informationen.

Es ist wichtig zu beachten, dass verschiedene Teile des Attestation-Objekts, einschließlich der Authenticator-Daten, der relying party ID und der attestation-Anweisung, entweder gehasht oder vom authenticator mit seinem privaten Schlüssel digital signiert werden. Dieser Prozess ist integraler Bestandteil der Aufrechterhaltung der Gesamtintegrität des Attestation-Objekts.

3. Login-Funktion:

Wird aktiviert, wenn der Benutzer auf "Anmelden" klickt. Ähnlich wie die Registrierungsfunktion extrahiert sie den Benutzernamen und sendet ihn an den passkeyLoginStart-Endpunkt. Die Antwort, die PublicKeyCredentialRequestOptions enthält, wird in JSON konvertiert und mit SimpleWebAuthnBrowser.startAuthentication verwendet. Dies löst die lokale Authentifizierung auf dem Gerät aus. Die signierte Challenge wird dann an den passkeyLoginFinish-Endpunkt zurückgesendet. Eine erfolgreiche Antwort von diesem Endpunkt zeigt an, dass sich der Benutzer erfolgreich in die App eingeloggt hat.

Zusätzlich bietet die begleitende CSS-Datei ein einfaches Styling für die Anwendung:

body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }

7. Die Passkey-Beispiel-App ausführen#

Um Ihre Anwendung in Aktion zu sehen, kompilieren und führen Sie Ihren TypeScript-Code mit folgendem Befehl aus:

npm run dev

Ihr Server sollte nun unter http://localhost:8080 laufen.

Überlegungen für die Produktion:

Denken Sie daran, was wir behandelt haben, ist ein grundlegender Überblick. Wenn Sie eine Passkey-Anwendung in einer Produktionsumgebung bereitstellen, müssen Sie sich eingehender mit folgenden Themen befassen:

  • Sicherheitsmaßnahmen: Implementieren Sie robuste Sicherheitspraktiken zum Schutz von Benutzerdaten.
  • Fehlerbehandlung: Stellen Sie sicher, dass Ihre Anwendung Fehler ordnungsgemäß behandelt und protokolliert.
  • Datenbankverwaltung: Optimieren Sie Datenbankoperationen für Skalierbarkeit und Zuverlässigkeit.

8. Passkey DevOps-Integration#

Wir haben bereits einen Docker-Container für unsere Datenbank eingerichtet. Als Nächstes erweitern wir unser Docker-Compose-Setup, um den Server mit Backend und Frontend einzubeziehen. Ihre docker-compose.yml-Datei sollte entsprechend aktualisiert werden.

Um unsere Anwendung zu containerisieren, erstellen wir ein neues Dockerfile, das die erforderlichen Pakete installiert und den Entwicklungsserver startet:

Docker
# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]

Dann erweitern wir auch die docker-compose.yml-Datei, um diesen Container zu starten:

docker-compose.yml
version: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db

Wenn Sie nun docker compose up in Ihrem Terminal ausführen und auf http://localhost:8080 zugreifen, sollten Sie die funktionierende Version Ihrer Passkey-Web-App sehen (hier ausgeführt auf Windows 11 23H2 + Chrome 119):

9. Zusätzliche Passkey-Tipps für Entwickler#

Da wir schon seit einiger Zeit mit Passkey-Implementierungen arbeiten, sind wir auf einige Herausforderungen gestoßen, wenn man an realen Passkey-Apps arbeitet:

  • Geräte- / Plattformkompatibilität und -unterstützung
  • Benutzer-Onboarding und -Schulung
  • Umgang mit verlorenen oder geänderten Geräten
  • Plattformübergreifende Authentifizierung
  • Fallback-Mechanismen
  • Kodierungskomplexität: Die Kodierung ist oft der schwierigste Teil, da man mit JSON, CBOR, uint8arrays, Buffern, Blobs, verschiedenen Datenbanken, base64 und base64url zu tun hat, wo viele Fehler auftreten können
  • Passkey-Verwaltung (z.B. zum Hinzufügen, Löschen oder Umbenennen von Passkeys)

Darüber hinaus haben wir die folgenden Tipps für Entwickler, wenn es um den Implementierungsteil geht:

Nutzen Sie den Passkeys-Debugger

Der Passkeys-Debugger hilft dabei, verschiedene WebAuthn-Servereinstellungen und Client-Antworten zu testen. Außerdem bietet er einen großartigen Parser für Authenticator-Antworten.

Debuggen mit der Chrome Device Log-Funktion

Verwenden Sie das Geräteprotokoll von Chrome (zugänglich über chrome://device-log/), um FIDO/WebAuthn-Aufrufe zu überwachen. Diese Funktion bietet Echtzeitprotokolle des Authentifizierungsprozesses (Login), sodass Sie die ausgetauschten Daten sehen und auftretende Probleme beheben können.

Ein weiterer sehr nützlicher Shortcut, um alle Ihre Passkeys in Chrome zu erhalten, ist die Verwendung von chrome://settings/passkeys.

Verwenden Sie den virtuellen WebAuthn-Authenticator von Chrome

Um die Verwendung der Touch ID-, Face ID- oder Windows Hello-Aufforderung während der Entwicklung zu vermeiden, verfügt Chrome über einen sehr praktischen virtuellen WebAuthn-Authenticator, der einen echten Authenticator emuliert. Wir empfehlen dringend, ihn zu verwenden, um die Dinge zu beschleunigen. Weitere Details finden Sie hier.

Testen Sie auf verschiedenen Plattformen und Browsern

Stellen Sie die Kompatibilität und Funktionalität auf verschiedenen Browsern und Plattformen sicher. WebAuthn verhält sich in verschiedenen Browsern unterschiedlich, daher ist gründliches Testen der Schlüssel.

Testen Sie auf verschiedenen Geräten

Hier ist es besonders nützlich, mit Tools wie ngrok zu arbeiten, mit denen Sie Ihre lokale Anwendung auf anderen (mobilen) Geräten erreichbar machen können.

Setzen Sie die Benutzerverifizierung auf „Preferred“

Wenn Sie die Eigenschaften für userVerification in den PublicKeyCredentialRequestOptions definieren, wählen Sie die Einstellung preferred, da dies ein guter Kompromiss zwischen Benutzerfreundlichkeit und Sicherheit ist. Das bedeutet, dass Sicherheitsprüfungen auf geeigneten Geräten vorhanden sind, während die Benutzerfreundlichkeit auf Geräten ohne biometrische Fähigkeiten erhalten bleibt.

10. Fazit: Passkey-Tutorial#

Wir hoffen, dieses Passkey-Tutorial vermittelt ein klares Verständnis dafür, wie man Passkeys effektiv implementiert. Im Laufe des Tutorials haben wir die wesentlichen Schritte zur Erstellung einer Passkey-Anwendung durchlaufen und uns dabei auf grundlegende Konzepte und die praktische Umsetzung konzentriert. Obwohl diese Anleitung als Ausgangspunkt dient, gibt es in der Welt von WebAuthn noch viel mehr zu entdecken und zu verfeinern.

Wir ermutigen Entwickler, tiefer in die Nuancen von Passkeys einzutauchen (z.B. das Hinzufügen mehrerer Passkeys, die Überprüfung der Passkey-Bereitschaft auf Geräten oder das Anbieten von Wiederherstellungslösungen). Es ist eine Reise, die es wert ist, angetreten zu werden, und die sowohl Herausforderungen als auch immense Belohnungen bei der Verbesserung der Benutzerauthentifizierung bietet. Mit Passkeys bauen Sie nicht nur eine Funktion; Sie tragen zu einer sichereren und benutzerfreundlicheren digitalen Welt bei.

Add passkeys to your app in <1 hour with our UI components, SDKs & guides.

Start for free

Share this article


LinkedInTwitterFacebook

Enjoyed this read?

🤝 Join our Passkeys Community

Share passkeys implementation tips and get support to free the world from passwords.

🚀 Subscribe to Substack

Get the latest news, strategies, and insights about passkeys sent straight to your inbox.

Related Articles