Adgangskoder er det svageste led i moderne webapplikationers sikkerhed. Phishing-angreb, credential stuffing og datalæk koster virksomheder og brugere milliarder hvert år, og svaret er ikke stærkere adgangskoder. Det er ingen adgangskoder. WebAuthn (Web Authentication) er W3C-standarden for passwordless login via offentlig-nøgle-kryptografi, og den er allerede understøttet af Chrome, Safari, Firefox og Edge på alle platforme. I denne tutorial bygger du en komplet WebAuthn-implementering i Node.js med Express og biblioteket SimpleWebAuthn på under 30 minutter.
Hvad er WebAuthn og FIDO2?
WebAuthn er en webstandard publiceret af W3C i 2019 og opdateret til version 3 i 2025. Den definerer, hvordan en webapplikation kan autentificere en bruger via en kryptografisk authenticator i stedet for en adgangskode. FIDO2 er den samlede betegnelse for WebAuthn plus CTAP (Client to Authenticator Protocol), som er den protokol, der forbinder browseren med den fysiske authenticator. WebAuthn er i dag understøttet i alle fire store browsere (Chrome, Edge, Firefox, Safari) og er inkorporeret i alle mobile operativsystemer fra iOS 16+ og Android 9+.
Hele sikkerhedsmodellen bygger på asymmetrisk kryptografi. Ved registrering genererer authenticatoren et nøglepar: den private nøgle forbliver sikkert gemt på brugerens enhed (fx i en TPM-chip, Secure Enclave eller hardware-sikkerhedsnøgle), mens den offentlige nøgle sendes til serveren og gemmes i din database. Ved login sender serveren en tilfældig challenge, authenticatoren signerer den med den private nøgle, og serveren verificerer signaturen med den gemte offentlige nøgle. Den private nøgle forlader aldrig enheden.
Dette design giver tre kritiske sikkerhedsegenskaber. For det første er passkeys phishing-resistente: credentials er bundet til rpID (Relying Party ID), som svarer til dit domæne. En credential oprettet til app.example.com kan ikke bruges på en falsk phishing-side som app-example.com. For det andet er der ingen delte hemmeligheder: der er ingen adgangskode at stjæle fra serverdatabasen, da kun den offentlige nøgle gemmes. For det tredje er der ingen replay-angreb: hvert login bruger en unik challenge, så en aflyttet response er ubrugelig for en angriber.
FIDO Alliance, brancheorganisationen bag FIDO2-standarden, har samlet et bredt økosystem af implementeringer fra Apple (iCloud Keychain med Face ID og Touch ID), Google (Google Password Manager) og Microsoft (Windows Hello). En passkey oprettet på én enhed kan synkroniseres sikkert til andre enheder via skybaserede keychains, hvilket løser det historiske problem med at miste adgang ved enhedsskift. FIDO Alliance rapporterer, at over 15 milliarder onlinekonti i 2025 understøtter passkeys som login-metode.
I Node.js er det mest udbredte bibliotek til WebAuthn-implementering SimpleWebAuthn, et TypeScript-first open source-projekt, der abstraherer kompleksiteten i WebAuthn-protokollen til enkle API-kald. Det deles i to npm-pakker: @simplewebauthn/server til server-side logik og @simplewebauthn/browser til browser-side logik.
Forudsætninger og krav
Sørg for at have følgende installeret og klar, inden du begynder. WebAuthn kræver HTTPS i produktion, men fungerer på localhost under udvikling uden certifikat.
| Krav | Minimum | Anbefalet | Bemærkning |
|---|---|---|---|
| Node.js | 18.x LTS | 20.x LTS | SimpleWebAuthn bruger Web Crypto API, kræver Node 18+ |
| npm | 8.x | 10.x | Inkluderet med Node.js |
| @simplewebauthn/server | 9.x | Seneste | Server-side WebAuthn logik |
| @simplewebauthn/browser | 9.x | Seneste | Browser-side WebAuthn logik |
| express | 4.18 | 4.21+ | HTTP-framework |
| express-session | 1.17 | Seneste | Challenge-lagring i session |
| dotenv | 16.x | Seneste | Miljøvariabler |
| Browser | Chrome 67+ | Chrome/Safari/Edge 2025 | Alle moderne browsere understøtter WebAuthn |
| HTTPS/TLS | Localhost OK | Certbot + Let’s Encrypt | Påkrævet i produktion |
Du behøver ikke en fysisk sikkerhedsnøgle under udvikling. Alle moderne smartphones, computere og tablets har en built-in platform-authenticator: Touch ID og Face ID på Apple-enheder, Windows Hello på Windows-computere, og Android-biometri på Android-enheder. Din udviklingscomputer fungerer som authenticator fra dag ét, forudsat at du har en biometrisk læser eller PIN-kode konfigureret.
Kontroller din Node.js-version med node --version inden du starter. SimpleWebAuthn bruger Web Crypto API, der er tilgængeligt i Node.js 18+ som en del af standardbiblioteket. Node.js 16 og ældre kræver en polyfill og anbefales ikke til nye projekter.
WebAuthn-protokollen i praksis: de to ceremonies
Inden du skriver kode, er det vigtigt at forstå de to forløb, WebAuthn definerer. Protokollen kalder dem registration ceremony og authentication ceremony. Hvert forløb er en tovejs-kommunikation med en tilfældig challenge i midten, der sikrer, at svaret aldrig kan genbruges.
Under registration ceremony sker følgende seks trin: Serveren genererer en tilfældig challenge og sender den til browseren med metadata om din app. Browseren videresender til platform-authenticatoren (fx Touch ID). Authenticatoren genererer et nøglepar og returnerer en signeret attestation, der indeholder den offentlige nøgle. Browseren sender attestationen til serveren. Serveren verificerer challenge og signatur. Serveren gemmer den offentlige nøgle, credential ID og en starttæller i databasen.
Under authentication ceremony sker følgende fem trin: Serveren genererer en ny tilfældig challenge. Browseren sender den til authenticatoren, som signerer den med den private nøgle. Den signerede assertion sendes til serveren. Serveren verificerer signaturen med den gemte offentlige nøgle. Serveren kontrollerer at tælleren er steget (clone-detection) og logger brugeren ind.
Tælleren er en særlig sikkerhedsmekanisme: hver gang en credential bruges, øges en intern tæller på authenticatoren. Serveren gemmer den seneste værdi og kontrollerer, at den nye tællerværdi er højere end den gemte. Hvis en angriber kopierer en credential og forsøger at bruge den, vil tællerværdien ikke stemme, og serveren afviser forsøget. Platform-authenticatorer med sky-synkronisering sætter ofte tæller til 0, men logikken forbliver vigtig at implementere.
Trin 1-3: Projektopsætning og installation
Start med at oprette et nyt Node.js-projekt og installere de nødvendige pakker. Du har brug for tre server-side pakker. I dette projekt serverer vi en simpel frontend direkte fra Express for at holde opsætningen fokuseret på WebAuthn-logikken.
# Trin 1: Opret projektmappe og initialiser npm
mkdir webauthn-nodejs && cd webauthn-nodejs
npm init -y
# Trin 2: Installer server-side pakker
npm install express express-session @simplewebauthn/server dotenv
# Trin 3: Installer udviklingsafhængigheder
npm install --save-dev nodemon
# Forventet output efter installation:
# added 42 packages, and audited 43 packages in 4s
# found 0 vulnerabilities
# Projektstruktur:
# webauthn-nodejs/
# ├── server.js (Express server og API-endpoints)
# ├── public/
# │ ├── index.html (Login/registrerings-UI)
# │ └── auth.js (Frontend WebAuthn-logik)
# └── .env (Miljøvariabler - aldrig i git)
Opret en .env-fil med dine miljøvariabler. I produktion skal du ændre alle tre værdier til dit faktiske domæne og en stærk, tilfældig session-hemmelighed genereret med node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".
# .env (tilføj til .gitignore straks)
SESSION_SECRET=erstat-med-64-hexadecimale-tegn-fra-crypto-randomBytes
RP_ID=localhost
RP_NAME=Min WebAuthn App
ORIGIN=http://localhost:3000
PORT=3000
NODE_ENV=development
Tilføj start-scripts i package.json og opret en .gitignore-fil med det samme. .env i versionskontrol er en af de mest udbredte årsager til credential-lækage i open source-projekter.
Trin 4-5: Express-server og relying party-konfiguration
Nu opretter du selve Express-serveren. Det vigtigste konfigurationspunkt er rpID og origin. rpID er domænet for din app, eksklusive protokol og port. I produktion vil det typisk være example.com eller app.example.com. origin er den fulde URL inkl. protokol og port, som browseren bruger. Disse to værdier skal matche præcist, og WebAuthn afviser alle requests med mismatch.
// server.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const app = express();
// Relying Party-konfiguration (rpID = domæne uden protokol og port)
const rpName = process.env.RP_NAME || 'Min App';
const rpID = process.env.RP_ID || 'localhost';
const origin = process.env.ORIGIN || 'http://localhost:3000';
// Middleware
app.use(express.json());
app.use(express.static('public'));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // true = kun HTTPS
httpOnly: true, // Ingen JavaScript-adgang til cookie
sameSite: 'lax', // CSRF-beskyttelse
maxAge: 10 * 60 * 1000, // 10 minutter til registrering/login
},
}));
// In-memory brugerstore (udskift med database i produktion)
// Struktur: username -> { id: Buffer, username: string, credentials: Array }
const users = new Map();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`WebAuthn server kører på port ${PORT}`);
console.log(`rpID: ${rpID} | origin: ${origin}`);
});
Lagringen med Map() er kun beregnet til demonstration. I en produktionsapp gemmer du brugere og credentials i en database. Credentials-tabellen skal som minimum indeholde: credentialID, credentialPublicKey, counter, userId og transports. Overvej at oprette en separat tabel for credentials med en fremmed nøgle til brugertabellen, da én bruger kan have flere registrerede enheder (telefon, laptop, hardware-nøgle).
Trin 6: Registration options-endpoint
Det første API-endpoint genererer WebAuthn-options til registrering. Serveren opretter en kryptografisk challenge og sender options til browseren, der videresender til authenticatoren. authenticatorAttachment: 'platform' beder specifikt om en built-in authenticator (Touch ID, Face ID, Windows Hello). Skift til 'cross-platform' for hardware-nøgler som YubiKey, eller udelad parameteren for at tillade begge typer.
// POST /auth/register/options
app.post('/auth/register/options', async (req, res) => {
const { username } = req.body;
if (!username || username.trim().length < 3) {
return res.status(400).json({ error: 'Brugernavn skal være mindst 3 tegn' });
}
const cleanUsername = username.trim().toLowerCase();
// Opret bruger hvis den ikke eksisterer
if (!users.has(cleanUsername)) {
users.set(cleanUsername, {
id: crypto.randomUUID(), // Unikt bruger-ID (gem i database med auto-increment)
username: cleanUsername,
credentials: [],
});
}
const user = users.get(cleanUsername);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: Buffer.from(user.id),
userName: user.username,
userDisplayName: user.username,
timeout: 60000, // 60 sekunders timeout for brugerinteraktion
attestationType: 'none', // 'none' kræver ingen producentbevis
authenticatorSelection: {
authenticatorAttachment: 'platform', // built-in enhed
userVerification: 'preferred', // brug biometri hvis muligt
residentKey: 'preferred', // gem credential på enheden (passkey)
},
excludeCredentials: user.credentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
transports: cred.transports || [],
})),
});
// Gem challenge i session til verifikation (ALDRIG client-side)
req.session.currentChallenge = options.challenge;
req.session.registrationUsername = cleanUsername;
res.json(options);
});
excludeCredentials er en vigtig parameter. Den fortæller authenticatoren, hvilke credentials brugeren allerede har registreret på dette site. Hvis brugeren forsøger at registrere den samme enhed to gange, afviser browseren forsøget automatisk med en InvalidStateError. attestationType: 'none' er det rigtige valg for de fleste webapplikationer: det kræver ikke, at authenticatoren beviser sin oprindelse hos producenten via FIDO Metadata Service, og det giver den bedste browserkompatibilitet.
Trin 7: Verificer og gem registration-response
Browseren sender authenticatorens svar tilbage til dette endpoint. Her verificerer serveren challenge, origin og rpID mod svaret og gemmer den offentlige nøgle i databasen. Husk altid at slette challenge fra session efter verifikation, hvad enten det lykkes eller ej. En genbrugt challenge er en sikkerhedsbrist.
// POST /auth/register/verify
app.post('/auth/register/verify', async (req, res) => {
const body = req.body;
const username = req.session.registrationUsername;
const expectedChallenge = req.session.currentChallenge;
// Ryd challenge fra session - engangsbrug uanset outcome
req.session.currentChallenge = undefined;
req.session.registrationUsername = undefined;
if (!username || !expectedChallenge) {
return res.status(400).json({ error: 'Session udløbet eller ugyldig' });
}
const user = users.get(username);
if (!user) {
return res.status(400).json({ error: 'Bruger ikke fundet' });
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (err) {
console.error('Registration verification fejlede:', err.message);
return res.status(400).json({ error: err.message });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
// Gem credential i brugerens credentials-array (brug database i produktion)
user.credentials.push({
credentialID: credential.id,
credentialPublicKey: credential.publicKey,
counter: credential.counter,
credentialDeviceType,
credentialBackedUp, // true = passkey synkroniseret via sky
transports: body.response.transports || [],
createdAt: new Date().toISOString(),
});
console.log(`Registrering OK: ${username} | backed up: ${credentialBackedUp}`);
return res.json({ verified: true });
}
res.status(400).json({ verified: false, error: 'Verifikation mislykkedes' });
});
credentialBackedUp er en egenskab fra WebAuthn Level 3-specifikationen. Den fortæller, om passkey’en er synkroniseret til skyen (fx iCloud Keychain eller Google Password Manager). En synkroniseret passkey er ikke bundet til én enhed og giver brugeren mere fleksibilitet. Din app kan vise en advarsel til brugere, der kun har én ikke-synkroniseret credential, og bede dem om at registrere en backup-enhed.
Trin 8-9: Authentication options og login-endpoint
Authentication-flowet ligner registration, men authenticatoren signerer blot en ny challenge med den allerede oprettede private nøgle. Serveren angiver, hvilke credentials den accepterer via allowCredentials-listen. Hvis listen er tom, tillader WebAuthn enhver credential gemt for dette rpID på enheden, hvilket muliggør “conditional UI” (passkey-autofill-lignende flow uden at kende brugernavnet på forhånd).
// POST /auth/login/options
app.post('/auth/login/options', async (req, res) => {
const { username } = req.body;
const cleanUsername = (username || '').trim().toLowerCase();
const user = users.get(cleanUsername);
if (!user || user.credentials.length === 0) {
return res.status(404).json({ error: 'Bruger ikke fundet eller ingen passkeys registreret' });
}
const options = await generateAuthenticationOptions({
rpID,
timeout: 60000,
allowCredentials: user.credentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
transports: cred.transports,
})),
userVerification: 'preferred',
});
req.session.currentChallenge = options.challenge;
req.session.loginUsername = cleanUsername;
res.json(options);
});
// POST /auth/login/verify
app.post('/auth/login/verify', async (req, res) => {
const body = req.body;
const username = req.session.loginUsername;
const expectedChallenge = req.session.currentChallenge;
req.session.currentChallenge = undefined;
req.session.loginUsername = undefined;
if (!username || !expectedChallenge) {
return res.status(400).json({ error: 'Session ugyldig eller udløbet' });
}
const user = users.get(username);
if (!user) {
return res.status(400).json({ error: 'Bruger ikke fundet' });
}
// Find matchende credential baseret på credentialID fra browser-response
const credential = user.credentials.find(c => c.credentialID === body.id);
if (!credential) {
return res.status(400).json({ error: 'Credential ikke fundet for denne bruger' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialID,
publicKey: credential.credentialPublicKey,
counter: credential.counter,
},
});
} catch (err) {
console.error('Authentication verification fejlede:', err.message);
return res.status(400).json({ error: err.message });
}
const { verified, authenticationInfo } = verification;
if (verified) {
// Opdater counter efter hvert succesfuldt login (forhindrer replay-angreb)
credential.counter = authenticationInfo.newCounter;
// Opret bruger-session
req.session.userId = username;
req.session.cookie.maxAge = 7 * 24 * 60 * 60 * 1000; // 7 dage
console.log(`Login OK: ${username} | ny counter: ${authenticationInfo.newCounter}`);
return res.json({ verified: true });
}
res.status(400).json({ verified: false, error: 'Autentificering mislykkedes' });
});
Trin 10-11: Frontend-kode med @simplewebauthn/browser
Frontend-koden bruger @simplewebauthn/browser til at håndtere interaktionen med browseren. Biblioteket eksponerer to nøglefunktioner: startRegistration() og startAuthentication(). Disse funktioner kalder Web Authentication API internt og returnerer base64url-kodede responses, som serveren forventer. Du kan indlæse biblioteket direkte fra CDN til simple projekter.
// public/auth.js
import {
startRegistration,
startAuthentication,
} from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@latest/dist/bundle/index.esm.js';
// Registrer en ny passkey
async function registerPasskey() {
const username = document.getElementById('username').value.trim();
if (!username) return showStatus('Indtast et brugernavn');
try {
// Hent options fra server (inkl. challenge)
const optionsRes = await fetch('/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!optionsRes.ok) {
const err = await optionsRes.json();
return showStatus('Fejl: ' + err.error);
}
const options = await optionsRes.json();
// Aktiverer Touch ID / Face ID / Windows Hello-dialog i browseren
const attestation = await startRegistration(options);
// Send authenticator-response til server til verifikation
const verifyRes = await fetch('/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation),
});
const result = await verifyRes.json();
if (result.verified) {
showStatus('Passkey registreret! Du kan nu logge ind.');
} else {
showStatus('Registrering fejlede: ' + (result.error || 'Ukendt fejl'));
}
} catch (err) {
// Brugeren annullerede, timeout, eller browser understøtter ikke WebAuthn
if (err.name === 'NotAllowedError') {
showStatus('Annulleret af bruger eller timeout.');
} else {
showStatus('Fejl: ' + err.message);
}
}
}
// Log ind med passkey
async function loginWithPasskey() {
const username = document.getElementById('username').value.trim();
if (!username) return showStatus('Indtast dit brugernavn');
try {
const optionsRes = await fetch('/auth/login/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
if (!optionsRes.ok) {
const err = await optionsRes.json();
return showStatus('Fejl: ' + err.error);
}
const options = await optionsRes.json();
const assertion = await startAuthentication(options);
const verifyRes = await fetch('/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertion),
});
const result = await verifyRes.json();
if (result.verified) {
window.location.href = '/dashboard';
} else {
showStatus('Login fejlede: ' + (result.error || 'Ukendt fejl'));
}
} catch (err) {
showStatus('Fejl: ' + err.message);
}
}
function showStatus(msg) {
document.getElementById('status').textContent = msg;
}
document.getElementById('btn-register').addEventListener('click', registerPasskey);
document.getElementById('btn-login').addEventListener('click', loginWithPasskey);
Trin 12: Beskyt routes, logout og credential-administration
Med en fungerende WebAuthn-implementation har du brug for at beskytte dine applikationsroutes og implementere en ordentlig logout-funktion. En simpel middleware-funktion tjekker, om brugeren har en aktiv session, og returnerer en 401-fejl ellers.
// Autentificerings-middleware
function requireAuth(req, res, next) {
if (req.session.userId) return next();
res.status(401).json({ error: 'Kræver login' });
}
// Beskyttet profil-API
app.get('/api/profile', requireAuth, (req, res) => {
const user = users.get(req.session.userId);
res.json({
username: user.username,
credentialCount: user.credentials.length,
credentials: user.credentials.map(cred => ({
id: cred.credentialID.substring(0, 16) + '...',
backedUp: cred.credentialBackedUp,
transports: cred.transports,
createdAt: cred.createdAt,
})),
});
});
// Logout - ødelæg server-side session
app.post('/auth/logout', (req, res) => {
req.session.destroy(err => {
if (err) return res.status(500).json({ error: 'Logout fejlede' });
res.clearCookie('connect.sid');
res.json({ success: true });
});
});
// Slet en specifik passkey (credential-administration)
app.delete('/auth/credential/:credentialId', requireAuth, (req, res) => {
const user = users.get(req.session.userId);
const before = user.credentials.length;
user.credentials = user.credentials.filter(
c => c.credentialID !== req.params.credentialId
);
if (user.credentials.length === before) {
return res.status(404).json({ error: 'Credential ikke fundet' });
}
res.json({ deleted: true, remaining: user.credentials.length });
});
Tilbyd altid brugere mulighed for at se og slette deres registrerede passkeys. En bruger kan have registreret 3-4 enheder over tid, og det er god praksis at give dem kontrol over, hvilke enheder der har adgang. Vis transports-feltet (['internal'] for platform-authenticator, ['usb'] for YubiKey) og credentialBackedUp-status, så brugerne ved, hvilke enheder der er synkroniseret til skyen.
Til produktion skal du erstatte den in-memory session-store med Redis via connect-redis, da den in-memory store nulstilles ved servergenstart og ikke skalerer til flere Node.js-instanser. Se vores guide til sikker session-styring i Node.js for komplet Redis-opsætning og session-hardening.
WebAuthn vs traditionel adgangskode: sikkerhedssammenligning
En direkte sammenligning viser, at WebAuthn adresserer alle de vigtigste angrebsvektorer mod adgangskodebaserede systemer. Nedenstående tabel opsummerer de centrale sikkerhedsegenskaber ved begge tilgange, plus TOTP som mellemvej.
| Angrebsvektor | Adgangskode | Adgangskode + TOTP | WebAuthn / Passkeys |
|---|---|---|---|
| Phishing | Sårbar | Sårbar (TOTP kan phishes) | Immun (rpID-binding) |
| Credential stuffing | Sårbar | Reduceret risiko | Immun (unikke nøgler per site) |
| Database-lækage | Sårbar (hashes kan crackes) | Sårbar | Immun (kun offentlig nøgle gemmes) |
| Replay-angreb | Sårbar | Reduceret (30s TOTP-vindue) | Immun (unik challenge pr. login) |
| Brute-force | Sårbar | Reduceret | Immun (privat nøgle forlader ikke enhed) |
| Man-in-the-middle | Sårbar på HTTP | Sårbar | Immun (TLS + origin-validering) |
| Brugerfrikttion | Husker/genbruger kodeord | Skifter kodeord + TOTP-kode | Touch/Face ID, ingen at huske |
| Enhedstab-risiko | Lav (kodeord i hjernen) | Moderat (backup-koder) | Lav med sky-synkronisering |
WebAuthn eliminerer ikke alle sikkerhedsrisici. En kompromitteret enhed giver en angriber adgang til platform-authenticatoren, hvis enheden ikke er beskyttet med PIN, biometri eller enhedskryptering. Ondsindede apps kan i teorien misbruge platform-authenticatoren, men operativsystemerne (iOS, Android, Windows, macOS) implementerer strenge sandboksningsregler, der forhindrer dette. Og account takeover via social engineering (brugeren overbevises til at registrere angriberens enhed) forbliver en vektor, der kræver brugeruddannelse og verificerings-policies.
5 almindelige faldgruber ved WebAuthn i Node.js
De fleste implementeringsfejl med WebAuthn falder i fem kategorier. Alle fem er typiske for udviklere, der implementerer WebAuthn for første gang.
Faldgrube 1: Forkert rpID eller origin. Den hyppigste fejl er mismatch mellem rpID og origin. Hvis din app kører på https://app.example.com, er rpID enten app.example.com eller forældreDomænet example.com, aldrig https://app.example.com. Og origin er altid den fulde URL med protokol: https://app.example.com. Blander du disse, returnerer verifyRegistrationResponse() en fejl med besked om “Invalid origin”. Log begge værdier ved opstart og sammenlign med det, browseren sender i clientDataJSON (base64url-kodet JSON i response-objektet).
Faldgrube 2: HTTP i produktion. WebAuthn kræver HTTPS undtagen på localhost. Chrome og Firefox afviser stille WebAuthn API-kald på usikrede sider med fejlen “SecurityError: The operation is insecure.” Brugeren ser blot en tom fejlbesked. Sæt altid HTTPS op med Certbot og Nginx, inden du tester WebAuthn på en server. Se vores guide til gratis TLS-certifikat med Certbot.
Faldgrube 3: Challenge gemmes ikke korrekt. Challenge skal gemmes server-side (i session) og sammenlignes med den, der returneres i response. Gem den aldrig client-side og send den ikke tilbage fra klienten som en del af request-body. Mange tutorials gemmer challenge i en global variabel i stedet for i session, hvilket giver fejl ved samtidige requests fra flere brugere.
Faldgrube 4: Counter-logik springes over. Tælleren er din primære forsvarslinje mod klonet-credential-angreb. SimpleWebAuthn kaster automatisk en fejl, hvis tællerværdien ikke er korrekt, men kun hvis du sender den rigtige credential.counter i verifyAuthenticationResponse(). Glemmer du at opdatere credential.counter i databasen efter hvert succesfuldt login, virker clone-detection ikke.
Faldgrube 5: Ingen genoprettelsesmekanisme. Hvad sker der, når en bruger mister sin enhed og ikke har registreret andre passkeys? WebAuthn giver ikke automatisk en account recovery-løsning. Implementer én af disse alternativer fra start: (a) kræv at nye brugere registrerer mindst 2 passkeys ved onboarding, (b) tilbyd backup-koder (8 alfanumeriske koder, brug CSPRNG) ved registrering, eller (c) implementer e-mail-baseret kontobekræftelse som fallback. Systemer uden recovery-flow mister brugere, der skifter telefon.
Fejlretning: 8 typiske fejl og løsninger
Her er de 8 mest frekvente fejlbeskeder og løsninger, når du implementerer WebAuthn i Node.js.
| Fejlbesked | Årsag | Løsning |
|---|---|---|
Unexpected registration response origin | ORIGIN i .env matcher ikke browserens URL | Sæt ORIGIN=http://localhost:3000 med korrekt port |
Unexpected rpID | rpID indeholder protokol eller port | RP_ID=localhost (ikke http://localhost:3000) |
SecurityError: The operation is insecure | HTTP bruges i stedet for HTTPS | Opsæt TLS med Certbot, eller test kun på localhost |
NotAllowedError: timed out waiting... | Brugeren afviste eller 60-sek timeout nået | Informer brugeren om Touch ID-dialogen, øg timeout til 120000 |
InvalidStateError: authenticator already registered | Credential allerede registreret (excludeCredentials virker) | Normalfunktion – informer brugeren om, at enheden er registreret |
session.currentChallenge is undefined | Session udløbet eller ikke gemt | Øg session-timeout eller brug Redis til session-store |
Received unexpected authenticator counter | Counter i DB er ikke opdateret | Sørg for at credential.counter opdateres i DB efter hvert login |
credential.publicKey is not a Uint8Array | Forkert serialisering ved lagring i database | Gem publicKey som Buffer – gendan med Buffer.from(stored, ‘base64’) |
Debug-teknik: clientDataJSON-feltet i svaret er base64url-kodet JSON med den origin og challenge, browseren brugte. Dekod den server-side med denne linje for at se præcis, hvad browseren sender, og sammenlign med din konfiguration:
// Dekod clientDataJSON til debug (kør i verify-endpointet)
const clientData = JSON.parse(
Buffer.from(req.body.response.clientDataJSON, 'base64url').toString()
);
console.log('Browser sendte:', {
type: clientData.type, // 'webauthn.create' eller 'webauthn.get'
origin: clientData.origin, // skal matche din ORIGIN
challenge: clientData.challenge, // skal matche session.currentChallenge
});
Et succesfuldt login-forløb giver følgende output i serverlogs:
# Server-output ved succesfuld registrering:
WebAuthn server kører på port 3000
rpID: localhost | origin: http://localhost:3000
Registrering OK: alice | backed up: true
# Server-output ved succesfuldt login:
Login OK: alice | ny counter: 1
# Server-output ved tredje login:
Login OK: alice | ny counter: 3
Avancerede tips til WebAuthn i produktion
Conditional UI (passkey autofill). WebAuthn Level 3 introducerer Conditional Mediation, som lader browseren vise passkeys direkte i brugernavnfeltet via en autocomplete-dropdown. Brugeren behøver ikke klikke en separat “Log ind med passkey”-knap. Det kræver mediation: 'conditional' i startAuthentication() og autocomplete="username webauthn" på input-feltet. Conditional UI er understøttet i Chrome 108+, Safari 16+ og Edge 108+.
// Conditional UI: passkey vises i autocomplete-dropdown
// Send allowCredentials: [] for at tillade alle credentials til dette rpID
const options = await generateAuthenticationOptions({ rpID, allowCredentials: [] });
// I auth.js (browser):
const assertion = await startAuthentication(options, true); // true = conditional UI
// HTML input:
// <input type="text" autocomplete="username webauthn" id="username" />
Cross-platform authenticatorer (YubiKey). Skift authenticatorAttachment til 'cross-platform' for at understøtte hardware-sikkerhedsnøgler som YubiKey 5 Series og Google Titan Key. Disse nøgler er de stærkeste authenticatorer, da den private nøgle sidder i en dedikeret sikkerhedschip, der er tamper-resistant og ikke synkroniseres til skyen. For organisationer med høje sikkerhedskrav (banker, myndigheder, kritisk infrastruktur, DORA-regulerede finansvirksomheder) anbefaler FIDO Alliance hardware-sikkerhedsnøgler frem for platform-authenticatorer.
Redis til session-skalering. Når du kører Node.js i cluster-mode eller bag en load balancer med flere instanser, skal session-data deles på tværs af processer. Installer connect-redis og konfigurer express-session til at bruge Redis som store. Dette sikrer, at challenge’n gemt på instans A stadig er tilgængelig, når browserens svar lander på instans B. Se vores guide til sikker session i Node.js for komplet Redis-konfiguration.
Rate limiting på auth-endpoints. Selvom WebAuthn er modstandsdygtig over for brute-force på credentials, bør du beskytte dine endpoints mod misbrugsforsøg og DDoS. Tilsæt express-rate-limit med en grænse på 10 requests per minut per IP på /auth/register/options og /auth/login/options. Se vores guide til OAuth 2.0 og OpenID Connect i Node.js for et komplet eksempel på rate-limiting i auth-flows.
Attestation til enterprise-brug. attestationType: 'none' er korrekt til forbrugerapps. Enterprise-implementeringer kan kræve 'direct' attestation, som beviser authenticatorens oprindelse hos en betroet producent via FIDO Metadata Service. Det giver organisationer mulighed for at håndhæve, at kun godkendte hardware-modeller bruges, men det reducerer privatlivets beskyttelse og øger implementeringskompleksiteten markant. Sæt altid 'none', medmindre du har et specifikt compliance-krav til attestation.
PostgreSQL-databaseskema og datapersistering
I produktion erstatter du Map()-lagringen med en relationel database. Her er et komplet PostgreSQL-skema med de felttyper og indekser, som WebAuthn-implementeringen kræver. Skemaet er designet til at understøtte multi-enhed per bruger og giver effektive opslag via credential ID.
-- PostgreSQL-skema til WebAuthn passkeys
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255) UNIQUE NOT NULL,
display_name VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE passkeys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- credentialID som base64url-streng (typisk 43-86 tegn)
credential_id TEXT UNIQUE NOT NULL,
-- credentialPublicKey som BYTEA (COSE-kodet offentlig nøgle, 77-300 bytes)
public_key BYTEA NOT NULL,
-- Counter til clone-detection (opdateres ved hvert login)
counter BIGINT NOT NULL DEFAULT 0,
-- 'singleDevice' eller 'multiDevice' (synkroniseret passkey)
device_type VARCHAR(20) NOT NULL DEFAULT 'singleDevice',
-- true = passkey synkroniseret via iCloud/Google/Microsoft sky
backed_up BOOLEAN NOT NULL DEFAULT FALSE,
-- ['internal'], ['usb'], ['nfc'], ['ble'] etc.
transports TEXT[],
-- Navn brugeren giver enheden (valgfrit, til UI)
friendly_name VARCHAR(255),
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indeks til hurtig opslag under authentication
CREATE INDEX idx_passkeys_credential_id ON passkeys(credential_id);
CREATE INDEX idx_passkeys_user_id ON passkeys(user_id);
-- Funktion til at opdatere counter og last_used_at atomisk
CREATE OR REPLACE FUNCTION update_passkey_counter(
p_credential_id TEXT,
p_new_counter BIGINT
) RETURNS VOID AS $$
BEGIN
UPDATE passkeys
SET counter = p_new_counter,
last_used_at = NOW()
WHERE credential_id = p_credential_id
AND counter < p_new_counter; -- Kun opdater hvis ny counter er større
IF NOT FOUND THEN
RAISE EXCEPTION 'Counter validation fejlede for credential %', p_credential_id;
END IF;
END;
$$ LANGUAGE plpgsql;
I Node.js gemmer du credentialPublicKey (som er en Uint8Array fra SimpleWebAuthn) direkte i PostgreSQL som BYTEA via pg-pakken. PostgreSQL behandler automatisk Buffer-objekter som binære data. Når du læser publicKey tilbage, får du en Buffer, som SimpleWebAuthn accepterer direkte i verifyAuthenticationResponse().
// Gem credential med pg-pakken (node-postgres)
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Gem ny credential efter registrering
async function saveCredential(userId, credential, deviceType, backedUp, transports) {
await pool.query(
`INSERT INTO passkeys
(user_id, credential_id, public_key, counter, device_type, backed_up, transports)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
userId,
credential.id, // base64url string
Buffer.from(credential.publicKey), // Uint8Array -> Buffer -> BYTEA
credential.counter,
deviceType,
backedUp,
transports,
]
);
}
// Hent credentials til authentication options
async function getCredentialsByUserId(userId) {
const result = await pool.query(
'SELECT * FROM passkeys WHERE user_id = $1 ORDER BY last_used_at DESC NULLS LAST',
[userId]
);
return result.rows.map(row => ({
credentialID: row.credential_id,
credentialPublicKey: row.public_key, // Buffer fra PostgreSQL
counter: Number(row.counter),
transports: row.transports || [],
credentialBackedUp: row.backed_up,
}));
}
// Opdater counter atomisk efter succesfuldt login
async function updateCounter(credentialId, newCounter) {
await pool.query(
'SELECT update_passkey_counter($1, $2)',
[credentialId, newCounter]
);
}
Brug den atomiske update_passkey_counter()-funktion frem for en simpel UPDATE, da den sikrer, at tællerværdien altid stiger. Hvis to samtidige login-requests med samme credential lander (fx ved netværksfejl og retry), vil kun den første opdatere counteren, og den anden vil kaste en fejl, der logger brugeren ud. Dette er den korrekte adfærd til clone-detection.
Overvej at tilføje en friendly_name-kolonne til passkeys-tabellen, som brugeren kan redigere. I credential-management-UI'en kan du vise "iPhone 15 Pro (Touch ID)" og "MacBook Pro (Touch ID)" i stedet for hex-strenge. Udfyld den automatisk ved registrering via req.headers['user-agent']-parsing for en bedre brugeroplevelse.
Relateret indhold
Vigtige guides til Node.js-sikkerhed
- OAuth 2.0 og OpenID Connect i Node.js: 12 trin på 30 min - standardprotokollerne til delegeret autorisation og SSO
- Sikker session i Node.js: 12 trin på 30 min - session-hardening med Redis og cookie-sikkerhed
- HMAC i Node.js: webhook-signaturer i 12 trin - kryptografisk signering af API-beskeder
- Ed25519 i Node.js: signaturer i 12 trin - moderne elliptic curve digitale signaturer
- Gratis SSL/TLS-certifikat: 12 trin med Certbot - HTTPS-opsætning med Let's Encrypt og Nginx
- Kodeordssikkerhed: længde, hashing og 2FA - grundlaget for moderne autentificering
Officielle ressourcer: SimpleWebAuthn dokumentation, FIDO Alliance, MDN Web Authentication API, Passkeys.dev udviklerdokumentation, W3C WebAuthn Level 3 specifikation.
Ofte stillede spørgsmål om WebAuthn i Node.js
Hvad er forskellen på WebAuthn og passkeys?
WebAuthn er selve protokollen, defineret af W3C og FIDO Alliance. Passkeys er Apples, Googles og Microsofts brugervenlige betegnelse for synkroniserede FIDO2-credentials implementeret via WebAuthn. En passkey er teknisk set en WebAuthn-credential med residentKey: 'required' og understøttelse af sky-synkronisering. Hardware-sikkerhedsnøgler (YubiKey, Google Titan) bruger også WebAuthn men er ikke passkeys, da de ikke synkroniseres.
Kræver WebAuthn en database?
Ja. Du skal gemme mindst credentialID, credentialPublicKey, counter og bruger-ID for hver registreret credential. I et produktionssystem har du typisk en users-tabel og en passkeys-tabel med en fremmed nøgle. credentialPublicKey er typisk 77-300 bytes afhængigt af nøgletypen og skal gemmes som binary (BYTEA i PostgreSQL, Buffer i Node.js).
Kan én bruger have flere passkeys?
Ja, og det anbefales. En bruger bør registrere mindst 2 passkeys (fx én på telefonen og én på laptopen) for at undgå at miste adgangen ved enhedstab. Din app skal understøtte credential-management, herunder visning af registrerede enheder med metadata og sletning af individuelle credentials. excludeCredentials-parameteren forhindrer dobbeltregistrering af samme enhed.
Fungerer WebAuthn med alle browsere i 2026?
Alle moderne desktop- og mobilbrowsere understøtter WebAuthn: Chrome og Chromium-baserede browsere fra version 67+, Safari på macOS og iOS fra version 14+, Firefox fra version 60+ og Edge fra version 18+. Internet Explorer understøttes ikke. @simplewebauthn/browser inkluderer feature detection, så du kan vise en fallback-besked til brugere med ikke-understøttede browsere.
Hvad sker der ved enhedstab?
Synkroniserede passkeys (iCloud Keychain, Google Password Manager) er tilgængelige fra andre enheder med samme Apple- eller Google-konto, selv efter enhedstab. Enheds-bundne credentials (hardware-nøgler, ikke-synkroniserede platform-credentials) mistes permanent ved enhedstab. Implementer altid en account recovery-mekanisme: backup-koder (8 alfanumeriske koder), e-mail-verifikation eller admin-baseret reset.
Er WebAuthn kompatibelt med GDPR og NIS2?
WebAuthn er godt tilpasset GDPR-kravene. Den biometriske data (fingeraftryk, ansigtsscanning) behandles udelukkende lokalt på enheden og sendes aldrig til serveren. Du gemmer kun den kryptografiske offentlige nøgle, credential ID og metadata. NIST SP 800-63B klassificerer WebAuthn som AAL2 (Authenticator Assurance Level 2), hvilket opfylder kravene til stærk autentificering under NIS2-direktivet, som gælder for mindst 6.000 danske virksomheder i kritisk infrastruktur.
Kan WebAuthn erstatte to-faktor-autentificering?
WebAuthn med userVerification: 'required' opfylder kravene til to-faktor-autentificering i én handling: enheden er "noget du har" og biometrien er "noget du er". Det er teknisk set stærkere end adgangskode + SMS-kode, fordi WebAuthn er phishing-resistent, mens SMS-koder kan phishes. For privilegerede handlinger i din app (fx overførsler over en beløbsgrænse eller adgang til administrator-panel) kan du kræve WebAuthn-verifikation på ny, selv for allerede-indloggede brugere.
Hvad koster implementering af WebAuthn i Node.js?
Selve WebAuthn-protokollen er gratis og kræver ingen licens. SimpleWebAuthn er open source under MIT-licens og gratis at bruge uden begrænsninger. De eneste driftsomkostninger er din sessions-infrastruktur (Redis koster typisk 15-50 USD/måned for en managed instans hos Upstash, AWS Elasticache eller Railway) og din PostgreSQL-database. Platform-authenticatorer (Touch ID, Face ID, Windows Hello) er allerede tilgængelige på alle moderne enheder uden ekstra cost. Hardware-sikkerhedsnøgler som YubiKey 5 Series koster 45-65 USD per nøgle, men det er en engangsudgift for brugeren. Samlet set er WebAuthn langt billigere end SMS-baseret 2FA, som koster 0,05-0,15 USD per besked via udbydere som Twilio.




