{"id":109,"date":"2026-06-17T20:40:59","date_gmt":"2026-06-17T20:40:59","guid":{"rendered":"https:\/\/shattered.io\/dk\/2026\/06\/17\/webauthn-nodejs-passkeys\/"},"modified":"2026-06-17T20:42:54","modified_gmt":"2026-06-17T20:42:54","slug":"webauthn-nodejs-passkeys","status":"publish","type":"post","link":"https:\/\/shattered.io\/dk\/webauthn-nodejs-passkeys\/","title":{"rendered":"WebAuthn i Node.js: Passwordless login i 12 trin [2026]"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Adgangskoder er det svageste led i moderne webapplikationers sikkerhed. Phishing-angreb, credential stuffing og datal\u00e6k koster virksomheder og brugere milliarder hvert \u00e5r, og svaret er ikke st\u00e6rkere adgangskoder. Det er <strong>ingen<\/strong> adgangskoder. <strong>WebAuthn (Web Authentication)<\/strong> er W3C-standarden for passwordless login via offentlig-n\u00f8gle-kryptografi, og den er allerede underst\u00f8ttet af Chrome, Safari, Firefox og Edge p\u00e5 alle platforme. I denne tutorial bygger du en komplet WebAuthn-implementering i Node.js med Express og biblioteket SimpleWebAuthn p\u00e5 under 30 minutter.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"hvad-er-webauthn-og-fido2\">Hvad er WebAuthn og FIDO2?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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. <strong>FIDO2<\/strong> 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\u00f8ttet i alle fire store browsere (Chrome, Edge, Firefox, Safari) og er inkorporeret i alle mobile operativsystemer fra iOS 16+ og Android 9+.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Hele sikkerhedsmodellen bygger p\u00e5 asymmetrisk kryptografi. Ved registrering genererer authenticatoren et n\u00f8glepar: den private n\u00f8gle forbliver sikkert gemt p\u00e5 brugerens enhed (fx i en TPM-chip, Secure Enclave eller hardware-sikkerhedsn\u00f8gle), mens den offentlige n\u00f8gle sendes til serveren og gemmes i din database. Ved login sender serveren en tilf\u00e6ldig <strong>challenge<\/strong>, authenticatoren signerer den med den private n\u00f8gle, og serveren verificerer signaturen med den gemte offentlige n\u00f8gle. Den private n\u00f8gle forlader aldrig enheden.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Dette design giver tre kritiske sikkerhedsegenskaber. For det f\u00f8rste er passkeys <strong>phishing-resistente<\/strong>: credentials er bundet til <strong>rpID<\/strong> (Relying Party ID), som svarer til dit dom\u00e6ne. En credential oprettet til <code>app.example.com<\/code> kan ikke bruges p\u00e5 en falsk phishing-side som <code>app-example.com<\/code>. For det andet er der <strong>ingen delte hemmeligheder<\/strong>: der er ingen adgangskode at stj\u00e6le fra serverdatabasen, da kun den offentlige n\u00f8gle gemmes. For det tredje er der <strong>ingen replay-angreb<\/strong>: hvert login bruger en unik challenge, s\u00e5 en aflyttet response er ubrugelig for en angriber.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">FIDO Alliance, brancheorganisationen bag FIDO2-standarden, har samlet et bredt \u00f8kosystem af implementeringer fra Apple (iCloud Keychain med Face ID og Touch ID), Google (Google Password Manager) og Microsoft (Windows Hello). En passkey oprettet p\u00e5 \u00e9n enhed kan synkroniseres sikkert til andre enheder via skybaserede keychains, hvilket l\u00f8ser det historiske problem med at miste adgang ved enhedsskift. FIDO Alliance rapporterer, at over 15 milliarder onlinekonti i 2025 underst\u00f8tter passkeys som login-metode.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I Node.js er det mest udbredte bibliotek til WebAuthn-implementering <strong>SimpleWebAuthn<\/strong>, et TypeScript-first open source-projekt, der abstraherer kompleksiteten i WebAuthn-protokollen til enkle API-kald. Det deles i to npm-pakker: <code>@simplewebauthn\/server<\/code> til server-side logik og <code>@simplewebauthn\/browser<\/code> til browser-side logik.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"forudsaetninger-og-krav\">Foruds\u00e6tninger og krav<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">S\u00f8rg for at have f\u00f8lgende installeret og klar, inden du begynder. WebAuthn kr\u00e6ver HTTPS i produktion, men fungerer p\u00e5 <code>localhost<\/code> under udvikling uden certifikat.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Krav<\/th><th>Minimum<\/th><th>Anbefalet<\/th><th>Bem\u00e6rkning<\/th><\/tr><\/thead><tbody><tr><td>Node.js<\/td><td>18.x LTS<\/td><td>20.x LTS<\/td><td>SimpleWebAuthn bruger Web Crypto API, kr\u00e6ver Node 18+<\/td><\/tr><tr><td>npm<\/td><td>8.x<\/td><td>10.x<\/td><td>Inkluderet med Node.js<\/td><\/tr><tr><td>@simplewebauthn\/server<\/td><td>9.x<\/td><td>Seneste<\/td><td>Server-side WebAuthn logik<\/td><\/tr><tr><td>@simplewebauthn\/browser<\/td><td>9.x<\/td><td>Seneste<\/td><td>Browser-side WebAuthn logik<\/td><\/tr><tr><td>express<\/td><td>4.18<\/td><td>4.21+<\/td><td>HTTP-framework<\/td><\/tr><tr><td>express-session<\/td><td>1.17<\/td><td>Seneste<\/td><td>Challenge-lagring i session<\/td><\/tr><tr><td>dotenv<\/td><td>16.x<\/td><td>Seneste<\/td><td>Milj\u00f8variabler<\/td><\/tr><tr><td>Browser<\/td><td>Chrome 67+<\/td><td>Chrome\/Safari\/Edge 2025<\/td><td>Alle moderne browsere underst\u00f8tter WebAuthn<\/td><\/tr><tr><td>HTTPS\/TLS<\/td><td>Localhost OK<\/td><td>Certbot + Let&#8217;s Encrypt<\/td><td>P\u00e5kr\u00e6vet i produktion<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Du beh\u00f8ver ikke en fysisk sikkerhedsn\u00f8gle under udvikling. Alle moderne smartphones, computere og tablets har en built-in platform-authenticator: Touch ID og Face ID p\u00e5 Apple-enheder, Windows Hello p\u00e5 Windows-computere, og Android-biometri p\u00e5 Android-enheder. Din udviklingscomputer fungerer som authenticator fra dag \u00e9t, forudsat at du har en biometrisk l\u00e6ser eller PIN-kode konfigureret.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Kontroller din Node.js-version med <code>node --version<\/code> inden du starter. SimpleWebAuthn bruger Web Crypto API, der er tilg\u00e6ngeligt i Node.js 18+ som en del af standardbiblioteket. Node.js 16 og \u00e6ldre kr\u00e6ver en polyfill og anbefales ikke til nye projekter.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"webauthn-protokollen-i-praksis-de-to-ceremonies\">WebAuthn-protokollen i praksis: de to ceremonies<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Inden du skriver kode, er det vigtigt at forst\u00e5 de to forl\u00f8b, WebAuthn definerer. Protokollen kalder dem <strong>registration ceremony<\/strong> og <strong>authentication ceremony<\/strong>. Hvert forl\u00f8b er en tovejs-kommunikation med en tilf\u00e6ldig challenge i midten, der sikrer, at svaret aldrig kan genbruges.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under <strong>registration ceremony<\/strong> sker f\u00f8lgende seks trin: Serveren genererer en tilf\u00e6ldig challenge og sender den til browseren med metadata om din app. Browseren videresender til platform-authenticatoren (fx Touch ID). Authenticatoren genererer et n\u00f8glepar og returnerer en signeret attestation, der indeholder den offentlige n\u00f8gle. Browseren sender attestationen til serveren. Serveren verificerer challenge og signatur. Serveren gemmer den offentlige n\u00f8gle, credential ID og en startt\u00e6ller i databasen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Under <strong>authentication ceremony<\/strong> sker f\u00f8lgende fem trin: Serveren genererer en ny tilf\u00e6ldig challenge. Browseren sender den til authenticatoren, som signerer den med den private n\u00f8gle. Den signerede assertion sendes til serveren. Serveren verificerer signaturen med den gemte offentlige n\u00f8gle. Serveren kontrollerer at t\u00e6lleren er steget (clone-detection) og logger brugeren ind.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">T\u00e6lleren er en s\u00e6rlig sikkerhedsmekanisme: hver gang en credential bruges, \u00f8ges en intern t\u00e6ller p\u00e5 authenticatoren. Serveren gemmer den seneste v\u00e6rdi og kontrollerer, at den nye t\u00e6llerv\u00e6rdi er h\u00f8jere end den gemte. Hvis en angriber kopierer en credential og fors\u00f8ger at bruge den, vil t\u00e6llerv\u00e6rdien ikke stemme, og serveren afviser fors\u00f8get. Platform-authenticatorer med sky-synkronisering s\u00e6tter ofte t\u00e6ller til 0, men logikken forbliver vigtig at implementere.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-1-3-projektopsaetning-og-installation\">Trin 1-3: Projektops\u00e6tning og installation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start med at oprette et nyt Node.js-projekt og installere de n\u00f8dvendige pakker. Du har brug for tre server-side pakker. I dette projekt serverer vi en simpel frontend direkte fra Express for at holde ops\u00e6tningen fokuseret p\u00e5 WebAuthn-logikken.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Trin 1: Opret projektmappe og initialiser npm\nmkdir webauthn-nodejs && cd webauthn-nodejs\nnpm init -y\n\n# Trin 2: Installer server-side pakker\nnpm install express express-session @simplewebauthn\/server dotenv\n\n# Trin 3: Installer udviklingsafh\u00e6ngigheder\nnpm install --save-dev nodemon\n\n# Forventet output efter installation:\n# added 42 packages, and audited 43 packages in 4s\n# found 0 vulnerabilities\n\n# Projektstruktur:\n# webauthn-nodejs\/\n# \u251c\u2500\u2500 server.js          (Express server og API-endpoints)\n# \u251c\u2500\u2500 public\/\n# \u2502   \u251c\u2500\u2500 index.html     (Login\/registrerings-UI)\n# \u2502   \u2514\u2500\u2500 auth.js        (Frontend WebAuthn-logik)\n# \u2514\u2500\u2500 .env               (Milj\u00f8variabler - aldrig i git)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Opret en <code>.env<\/code>-fil med dine milj\u00f8variabler. I produktion skal du \u00e6ndre alle tre v\u00e6rdier til dit faktiske dom\u00e6ne og en st\u00e6rk, tilf\u00e6ldig session-hemmelighed genereret med <code>node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .env (tilf\u00f8j til .gitignore straks)\nSESSION_SECRET=erstat-med-64-hexadecimale-tegn-fra-crypto-randomBytes\nRP_ID=localhost\nRP_NAME=Min WebAuthn App\nORIGIN=http:\/\/localhost:3000\nPORT=3000\nNODE_ENV=development<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tilf\u00f8j start-scripts i <code>package.json<\/code> og opret en <code>.gitignore<\/code>-fil med det samme. <code>.env<\/code> i versionskontrol er en af de mest udbredte \u00e5rsager til credential-l\u00e6kage i open source-projekter.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-4-5-express-server-og-relying-party-konfiguration\">Trin 4-5: Express-server og relying party-konfiguration<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Nu opretter du selve Express-serveren. Det vigtigste konfigurationspunkt er <strong>rpID<\/strong> og <strong>origin<\/strong>. <code>rpID<\/code> er dom\u00e6net for din app, eksklusive protokol og port. I produktion vil det typisk v\u00e6re <code>example.com<\/code> eller <code>app.example.com<\/code>. <code>origin<\/code> er den fulde URL inkl. protokol og port, som browseren bruger. Disse to v\u00e6rdier skal matche pr\u00e6cist, og WebAuthn afviser alle requests med mismatch.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ server.js\nrequire('dotenv').config();\nconst express = require('express');\nconst session = require('express-session');\nconst crypto = require('crypto');\nconst {\n  generateRegistrationOptions,\n  verifyRegistrationResponse,\n  generateAuthenticationOptions,\n  verifyAuthenticationResponse,\n} = require('@simplewebauthn\/server');\n\nconst app = express();\n\n\/\/ Relying Party-konfiguration (rpID = dom\u00e6ne uden protokol og port)\nconst rpName = process.env.RP_NAME || 'Min App';\nconst rpID   = process.env.RP_ID   || 'localhost';\nconst origin = process.env.ORIGIN  || 'http:\/\/localhost:3000';\n\n\/\/ Middleware\napp.use(express.json());\napp.use(express.static('public'));\napp.use(session({\n  secret: process.env.SESSION_SECRET,\n  resave: false,\n  saveUninitialized: false,\n  cookie: {\n    secure:   process.env.NODE_ENV === 'production', \/\/ true = kun HTTPS\n    httpOnly: true,    \/\/ Ingen JavaScript-adgang til cookie\n    sameSite: 'lax',   \/\/ CSRF-beskyttelse\n    maxAge:   10 * 60 * 1000, \/\/ 10 minutter til registrering\/login\n  },\n}));\n\n\/\/ In-memory brugerstore (udskift med database i produktion)\n\/\/ Struktur: username -> { id: Buffer, username: string, credentials: Array }\nconst users = new Map();\n\nconst PORT = process.env.PORT || 3000;\napp.listen(PORT, () => {\n  console.log(`WebAuthn server k\u00f8rer p\u00e5 port ${PORT}`);\n  console.log(`rpID: ${rpID} | origin: ${origin}`);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Lagringen med <code>Map()<\/code> er kun beregnet til demonstration. I en produktionsapp gemmer du brugere og credentials i en database. Credentials-tabellen skal som minimum indeholde: <code>credentialID<\/code>, <code>credentialPublicKey<\/code>, <code>counter<\/code>, <code>userId<\/code> og <code>transports<\/code>. Overvej at oprette en separat tabel for credentials med en fremmed n\u00f8gle til brugertabellen, da \u00e9n bruger kan have flere registrerede enheder (telefon, laptop, hardware-n\u00f8gle).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-6-registration-options-endpoint\">Trin 6: Registration options-endpoint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Det f\u00f8rste API-endpoint genererer WebAuthn-options til registrering. Serveren opretter en kryptografisk challenge og sender options til browseren, der videresender til authenticatoren. <code>authenticatorAttachment: 'platform'<\/code> beder specifikt om en built-in authenticator (Touch ID, Face ID, Windows Hello). Skift til <code>'cross-platform'<\/code> for hardware-n\u00f8gler som YubiKey, eller udelad parameteren for at tillade begge typer.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ POST \/auth\/register\/options\napp.post('\/auth\/register\/options', async (req, res) => {\n  const { username } = req.body;\n\n  if (!username || username.trim().length < 3) {\n    return res.status(400).json({ error: 'Brugernavn skal v\u00e6re mindst 3 tegn' });\n  }\n\n  const cleanUsername = username.trim().toLowerCase();\n\n  \/\/ Opret bruger hvis den ikke eksisterer\n  if (!users.has(cleanUsername)) {\n    users.set(cleanUsername, {\n      id: crypto.randomUUID(), \/\/ Unikt bruger-ID (gem i database med auto-increment)\n      username: cleanUsername,\n      credentials: [],\n    });\n  }\n\n  const user = users.get(cleanUsername);\n\n  const options = await generateRegistrationOptions({\n    rpName,\n    rpID,\n    userID:          Buffer.from(user.id),\n    userName:        user.username,\n    userDisplayName: user.username,\n    timeout:         60000,         \/\/ 60 sekunders timeout for brugerinteraktion\n    attestationType: 'none',        \/\/ 'none' kr\u00e6ver ingen producentbevis\n    authenticatorSelection: {\n      authenticatorAttachment: 'platform',    \/\/ built-in enhed\n      userVerification:        'preferred',   \/\/ brug biometri hvis muligt\n      residentKey:             'preferred',   \/\/ gem credential p\u00e5 enheden (passkey)\n    },\n    excludeCredentials: user.credentials.map(cred => ({\n      id:         cred.credentialID,\n      type:       'public-key',\n      transports: cred.transports || [],\n    })),\n  });\n\n  \/\/ Gem challenge i session til verifikation (ALDRIG client-side)\n  req.session.currentChallenge      = options.challenge;\n  req.session.registrationUsername  = cleanUsername;\n\n  res.json(options);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>excludeCredentials<\/code> er en vigtig parameter. Den fort\u00e6ller authenticatoren, hvilke credentials brugeren allerede har registreret p\u00e5 dette site. Hvis brugeren fors\u00f8ger at registrere den samme enhed to gange, afviser browseren fors\u00f8get automatisk med en <code>InvalidStateError<\/code>. <code>attestationType: 'none'<\/code> er det rigtige valg for de fleste webapplikationer: det kr\u00e6ver ikke, at authenticatoren beviser sin oprindelse hos producenten via FIDO Metadata Service, og det giver den bedste browserkompatibilitet.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-7-verificer-og-gem-registration-response\">Trin 7: Verificer og gem registration-response<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Browseren sender authenticatorens svar tilbage til dette endpoint. Her verificerer serveren challenge, origin og rpID mod svaret og gemmer den offentlige n\u00f8gle i databasen. Husk altid at slette challenge fra session efter verifikation, hvad enten det lykkes eller ej. En genbrugt challenge er en sikkerhedsbrist.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ POST \/auth\/register\/verify\napp.post('\/auth\/register\/verify', async (req, res) => {\n  const body              = req.body;\n  const username          = req.session.registrationUsername;\n  const expectedChallenge = req.session.currentChallenge;\n\n  \/\/ Ryd challenge fra session - engangsbrug uanset outcome\n  req.session.currentChallenge     = undefined;\n  req.session.registrationUsername = undefined;\n\n  if (!username || !expectedChallenge) {\n    return res.status(400).json({ error: 'Session udl\u00f8bet eller ugyldig' });\n  }\n\n  const user = users.get(username);\n  if (!user) {\n    return res.status(400).json({ error: 'Bruger ikke fundet' });\n  }\n\n  let verification;\n  try {\n    verification = await verifyRegistrationResponse({\n      response:          body,\n      expectedChallenge,\n      expectedOrigin:    origin,\n      expectedRPID:      rpID,\n    });\n  } catch (err) {\n    console.error('Registration verification fejlede:', err.message);\n    return res.status(400).json({ error: err.message });\n  }\n\n  const { verified, registrationInfo } = verification;\n\n  if (verified && registrationInfo) {\n    const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;\n\n    \/\/ Gem credential i brugerens credentials-array (brug database i produktion)\n    user.credentials.push({\n      credentialID:        credential.id,\n      credentialPublicKey: credential.publicKey,\n      counter:             credential.counter,\n      credentialDeviceType,\n      credentialBackedUp,   \/\/ true = passkey synkroniseret via sky\n      transports:           body.response.transports || [],\n      createdAt:            new Date().toISOString(),\n    });\n\n    console.log(`Registrering OK: ${username} | backed up: ${credentialBackedUp}`);\n    return res.json({ verified: true });\n  }\n\n  res.status(400).json({ verified: false, error: 'Verifikation mislykkedes' });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>credentialBackedUp<\/code> er en egenskab fra WebAuthn Level 3-specifikationen. Den fort\u00e6ller, om passkey&#8217;en er synkroniseret til skyen (fx iCloud Keychain eller Google Password Manager). En synkroniseret passkey er ikke bundet til \u00e9n enhed og giver brugeren mere fleksibilitet. Din app kan vise en advarsel til brugere, der kun har \u00e9n ikke-synkroniseret credential, og bede dem om at registrere en backup-enhed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-8-9-authentication-options-og-login-endpoint\">Trin 8-9: Authentication options og login-endpoint<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Authentication-flowet ligner registration, men authenticatoren signerer blot en ny challenge med den allerede oprettede private n\u00f8gle. Serveren angiver, hvilke credentials den accepterer via <code>allowCredentials<\/code>-listen. Hvis listen er tom, tillader WebAuthn enhver credential gemt for dette rpID p\u00e5 enheden, hvilket muligg\u00f8r &#8220;conditional UI&#8221; (passkey-autofill-lignende flow uden at kende brugernavnet p\u00e5 forh\u00e5nd).<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ POST \/auth\/login\/options\napp.post('\/auth\/login\/options', async (req, res) => {\n  const { username } = req.body;\n  const cleanUsername = (username || '').trim().toLowerCase();\n  const user = users.get(cleanUsername);\n\n  if (!user || user.credentials.length === 0) {\n    return res.status(404).json({ error: 'Bruger ikke fundet eller ingen passkeys registreret' });\n  }\n\n  const options = await generateAuthenticationOptions({\n    rpID,\n    timeout:          60000,\n    allowCredentials: user.credentials.map(cred => ({\n      id:         cred.credentialID,\n      type:       'public-key',\n      transports: cred.transports,\n    })),\n    userVerification: 'preferred',\n  });\n\n  req.session.currentChallenge = options.challenge;\n  req.session.loginUsername    = cleanUsername;\n\n  res.json(options);\n});\n\n\/\/ POST \/auth\/login\/verify\napp.post('\/auth\/login\/verify', async (req, res) => {\n  const body              = req.body;\n  const username          = req.session.loginUsername;\n  const expectedChallenge = req.session.currentChallenge;\n\n  req.session.currentChallenge = undefined;\n  req.session.loginUsername    = undefined;\n\n  if (!username || !expectedChallenge) {\n    return res.status(400).json({ error: 'Session ugyldig eller udl\u00f8bet' });\n  }\n\n  const user = users.get(username);\n  if (!user) {\n    return res.status(400).json({ error: 'Bruger ikke fundet' });\n  }\n\n  \/\/ Find matchende credential baseret p\u00e5 credentialID fra browser-response\n  const credential = user.credentials.find(c => c.credentialID === body.id);\n  if (!credential) {\n    return res.status(400).json({ error: 'Credential ikke fundet for denne bruger' });\n  }\n\n  let verification;\n  try {\n    verification = await verifyAuthenticationResponse({\n      response:          body,\n      expectedChallenge,\n      expectedOrigin:    origin,\n      expectedRPID:      rpID,\n      credential: {\n        id:        credential.credentialID,\n        publicKey: credential.credentialPublicKey,\n        counter:   credential.counter,\n      },\n    });\n  } catch (err) {\n    console.error('Authentication verification fejlede:', err.message);\n    return res.status(400).json({ error: err.message });\n  }\n\n  const { verified, authenticationInfo } = verification;\n\n  if (verified) {\n    \/\/ Opdater counter efter hvert succesfuldt login (forhindrer replay-angreb)\n    credential.counter = authenticationInfo.newCounter;\n\n    \/\/ Opret bruger-session\n    req.session.userId              = username;\n    req.session.cookie.maxAge       = 7 * 24 * 60 * 60 * 1000; \/\/ 7 dage\n\n    console.log(`Login OK: ${username} | ny counter: ${authenticationInfo.newCounter}`);\n    return res.json({ verified: true });\n  }\n\n  res.status(400).json({ verified: false, error: 'Autentificering mislykkedes' });\n});<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-10-11-frontend-kode-med-simplewebauthn-browser\">Trin 10-11: Frontend-kode med @simplewebauthn\/browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Frontend-koden bruger <code>@simplewebauthn\/browser<\/code> til at h\u00e5ndtere interaktionen med browseren. Biblioteket eksponerer to n\u00f8glefunktioner: <code>startRegistration()<\/code> og <code>startAuthentication()<\/code>. Disse funktioner kalder Web Authentication API internt og returnerer base64url-kodede responses, som serveren forventer. Du kan indl\u00e6se biblioteket direkte fra CDN til simple projekter.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ public\/auth.js\nimport {\n  startRegistration,\n  startAuthentication,\n} from 'https:\/\/cdn.jsdelivr.net\/npm\/@simplewebauthn\/browser@latest\/dist\/bundle\/index.esm.js';\n\n\/\/ Registrer en ny passkey\nasync function registerPasskey() {\n  const username = document.getElementById('username').value.trim();\n  if (!username) return showStatus('Indtast et brugernavn');\n\n  try {\n    \/\/ Hent options fra server (inkl. challenge)\n    const optionsRes = await fetch('\/auth\/register\/options', {\n      method:  'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body:    JSON.stringify({ username }),\n    });\n\n    if (!optionsRes.ok) {\n      const err = await optionsRes.json();\n      return showStatus('Fejl: ' + err.error);\n    }\n\n    const options = await optionsRes.json();\n\n    \/\/ Aktiverer Touch ID \/ Face ID \/ Windows Hello-dialog i browseren\n    const attestation = await startRegistration(options);\n\n    \/\/ Send authenticator-response til server til verifikation\n    const verifyRes = await fetch('\/auth\/register\/verify', {\n      method:  'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body:    JSON.stringify(attestation),\n    });\n    const result = await verifyRes.json();\n\n    if (result.verified) {\n      showStatus('Passkey registreret! Du kan nu logge ind.');\n    } else {\n      showStatus('Registrering fejlede: ' + (result.error || 'Ukendt fejl'));\n    }\n  } catch (err) {\n    \/\/ Brugeren annullerede, timeout, eller browser underst\u00f8tter ikke WebAuthn\n    if (err.name === 'NotAllowedError') {\n      showStatus('Annulleret af bruger eller timeout.');\n    } else {\n      showStatus('Fejl: ' + err.message);\n    }\n  }\n}\n\n\/\/ Log ind med passkey\nasync function loginWithPasskey() {\n  const username = document.getElementById('username').value.trim();\n  if (!username) return showStatus('Indtast dit brugernavn');\n\n  try {\n    const optionsRes = await fetch('\/auth\/login\/options', {\n      method:  'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body:    JSON.stringify({ username }),\n    });\n\n    if (!optionsRes.ok) {\n      const err = await optionsRes.json();\n      return showStatus('Fejl: ' + err.error);\n    }\n\n    const options       = await optionsRes.json();\n    const assertion     = await startAuthentication(options);\n\n    const verifyRes = await fetch('\/auth\/login\/verify', {\n      method:  'POST',\n      headers: { 'Content-Type': 'application\/json' },\n      body:    JSON.stringify(assertion),\n    });\n    const result = await verifyRes.json();\n\n    if (result.verified) {\n      window.location.href = '\/dashboard';\n    } else {\n      showStatus('Login fejlede: ' + (result.error || 'Ukendt fejl'));\n    }\n  } catch (err) {\n    showStatus('Fejl: ' + err.message);\n  }\n}\n\nfunction showStatus(msg) {\n  document.getElementById('status').textContent = msg;\n}\n\ndocument.getElementById('btn-register').addEventListener('click', registerPasskey);\ndocument.getElementById('btn-login').addEventListener('click', loginWithPasskey);<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"trin-12-beskyt-routes-logout-og-credential-administration\">Trin 12: Beskyt routes, logout og credential-administration<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Autentificerings-middleware\nfunction requireAuth(req, res, next) {\n  if (req.session.userId) return next();\n  res.status(401).json({ error: 'Kr\u00e6ver login' });\n}\n\n\/\/ Beskyttet profil-API\napp.get('\/api\/profile', requireAuth, (req, res) => {\n  const user = users.get(req.session.userId);\n  res.json({\n    username:        user.username,\n    credentialCount: user.credentials.length,\n    credentials:     user.credentials.map(cred => ({\n      id:        cred.credentialID.substring(0, 16) + '...',\n      backedUp:  cred.credentialBackedUp,\n      transports: cred.transports,\n      createdAt: cred.createdAt,\n    })),\n  });\n});\n\n\/\/ Logout - \u00f8del\u00e6g server-side session\napp.post('\/auth\/logout', (req, res) => {\n  req.session.destroy(err => {\n    if (err) return res.status(500).json({ error: 'Logout fejlede' });\n    res.clearCookie('connect.sid');\n    res.json({ success: true });\n  });\n});\n\n\/\/ Slet en specifik passkey (credential-administration)\napp.delete('\/auth\/credential\/:credentialId', requireAuth, (req, res) => {\n  const user   = users.get(req.session.userId);\n  const before = user.credentials.length;\n\n  user.credentials = user.credentials.filter(\n    c => c.credentialID !== req.params.credentialId\n  );\n\n  if (user.credentials.length === before) {\n    return res.status(404).json({ error: 'Credential ikke fundet' });\n  }\n\n  res.json({ deleted: true, remaining: user.credentials.length });\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>transports<\/code>-feltet (<code>['internal']<\/code> for platform-authenticator, <code>['usb']<\/code> for YubiKey) og <code>credentialBackedUp<\/code>-status, s\u00e5 brugerne ved, hvilke enheder der er synkroniseret til skyen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Til <strong>produktion<\/strong> skal du erstatte den in-memory session-store med Redis via <code>connect-redis<\/code>, da den in-memory store nulstilles ved servergenstart og ikke skalerer til flere Node.js-instanser. Se vores guide til <a href=\"\/da\/sikker-session-nodejs\/\">sikker session-styring i Node.js<\/a> for komplet Redis-ops\u00e6tning og session-hardening.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"webauthn-vs-traditionel-adgangskode-sikkerhedssammenligning\">WebAuthn vs traditionel adgangskode: sikkerhedssammenligning<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">En direkte sammenligning viser, at WebAuthn adresserer alle de vigtigste angrebsvektorer mod adgangskodebaserede systemer. Nedenst\u00e5ende tabel opsummerer de centrale sikkerhedsegenskaber ved begge tilgange, plus TOTP som mellemvej.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Angrebsvektor<\/th><th>Adgangskode<\/th><th>Adgangskode + TOTP<\/th><th>WebAuthn \/ Passkeys<\/th><\/tr><\/thead><tbody><tr><td>Phishing<\/td><td>S\u00e5rbar<\/td><td>S\u00e5rbar (TOTP kan phishes)<\/td><td>Immun (rpID-binding)<\/td><\/tr><tr><td>Credential stuffing<\/td><td>S\u00e5rbar<\/td><td>Reduceret risiko<\/td><td>Immun (unikke n\u00f8gler per site)<\/td><\/tr><tr><td>Database-l\u00e6kage<\/td><td>S\u00e5rbar (hashes kan crackes)<\/td><td>S\u00e5rbar<\/td><td>Immun (kun offentlig n\u00f8gle gemmes)<\/td><\/tr><tr><td>Replay-angreb<\/td><td>S\u00e5rbar<\/td><td>Reduceret (30s TOTP-vindue)<\/td><td>Immun (unik challenge pr. login)<\/td><\/tr><tr><td>Brute-force<\/td><td>S\u00e5rbar<\/td><td>Reduceret<\/td><td>Immun (privat n\u00f8gle forlader ikke enhed)<\/td><\/tr><tr><td>Man-in-the-middle<\/td><td>S\u00e5rbar p\u00e5 HTTP<\/td><td>S\u00e5rbar<\/td><td>Immun (TLS + origin-validering)<\/td><\/tr><tr><td>Brugerfrikttion<\/td><td>Husker\/genbruger kodeord<\/td><td>Skifter kodeord + TOTP-kode<\/td><td>Touch\/Face ID, ingen at huske<\/td><\/tr><tr><td>Enhedstab-risiko<\/td><td>Lav (kodeord i hjernen)<\/td><td>Moderat (backup-koder)<\/td><td>Lav med sky-synkronisering<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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\u00e6ver brugeruddannelse og verificerings-policies.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"5-almindelige-faldgruber-ved-webauthn-i-node-js\">5 almindelige faldgruber ved WebAuthn i Node.js<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">De fleste implementeringsfejl med WebAuthn falder i fem kategorier. Alle fem er typiske for udviklere, der implementerer WebAuthn for f\u00f8rste gang.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Faldgrube 1: Forkert rpID eller origin.<\/strong> Den hyppigste fejl er mismatch mellem <code>rpID<\/code> og <code>origin<\/code>. Hvis din app k\u00f8rer p\u00e5 <code>https:\/\/app.example.com<\/code>, er <code>rpID<\/code> enten <code>app.example.com<\/code> eller for\u00e6ldreDom\u00e6net <code>example.com<\/code>, aldrig <code>https:\/\/app.example.com<\/code>. Og <code>origin<\/code> er altid den fulde URL med protokol: <code>https:\/\/app.example.com<\/code>. Blander du disse, returnerer <code>verifyRegistrationResponse()<\/code> en fejl med besked om &#8220;Invalid origin&#8221;. Log begge v\u00e6rdier ved opstart og sammenlign med det, browseren sender i <code>clientDataJSON<\/code> (base64url-kodet JSON i response-objektet).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Faldgrube 2: HTTP i produktion.<\/strong> WebAuthn kr\u00e6ver HTTPS undtagen p\u00e5 <code>localhost<\/code>. Chrome og Firefox afviser stille WebAuthn API-kald p\u00e5 usikrede sider med fejlen &#8220;SecurityError: The operation is insecure.&#8221; Brugeren ser blot en tom fejlbesked. S\u00e6t altid HTTPS op med Certbot og Nginx, inden du tester WebAuthn p\u00e5 en server. Se vores <a href=\"\/da\/ssl-tls-certifikat-certbot-2026\/\">guide til gratis TLS-certifikat med Certbot<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Faldgrube 3: Challenge gemmes ikke korrekt.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Faldgrube 4: Counter-logik springes over.<\/strong> T\u00e6lleren er din prim\u00e6re forsvarslinje mod klonet-credential-angreb. SimpleWebAuthn kaster automatisk en fejl, hvis t\u00e6llerv\u00e6rdien ikke er korrekt, men kun hvis du sender den rigtige <code>credential.counter<\/code> i <code>verifyAuthenticationResponse()<\/code>. Glemmer du at opdatere <code>credential.counter<\/code> i databasen efter hvert succesfuldt login, virker clone-detection ikke.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Faldgrube 5: Ingen genoprettelsesmekanisme.<\/strong> Hvad sker der, n\u00e5r en bruger mister sin enhed og ikke har registreret andre passkeys? WebAuthn giver ikke automatisk en account recovery-l\u00f8sning. Implementer \u00e9n af disse alternativer fra start: (a) kr\u00e6v 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\u00e6ftelse som fallback. Systemer uden recovery-flow mister brugere, der skifter telefon.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"fejlretning-8-typiske-fejl-og-loesninger\">Fejlretning: 8 typiske fejl og l\u00f8sninger<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Her er de 8 mest frekvente fejlbeskeder og l\u00f8sninger, n\u00e5r du implementerer WebAuthn i Node.js.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Fejlbesked<\/th><th>\u00c5rsag<\/th><th>L\u00f8sning<\/th><\/tr><\/thead><tbody><tr><td><code>Unexpected registration response origin<\/code><\/td><td>ORIGIN i .env matcher ikke browserens URL<\/td><td>S\u00e6t ORIGIN=http:\/\/localhost:3000 med korrekt port<\/td><\/tr><tr><td><code>Unexpected rpID<\/code><\/td><td>rpID indeholder protokol eller port<\/td><td>RP_ID=localhost (ikke http:\/\/localhost:3000)<\/td><\/tr><tr><td><code>SecurityError: The operation is insecure<\/code><\/td><td>HTTP bruges i stedet for HTTPS<\/td><td>Ops\u00e6t TLS med Certbot, eller test kun p\u00e5 localhost<\/td><\/tr><tr><td><code>NotAllowedError: timed out waiting...<\/code><\/td><td>Brugeren afviste eller 60-sek timeout n\u00e5et<\/td><td>Informer brugeren om Touch ID-dialogen, \u00f8g timeout til 120000<\/td><\/tr><tr><td><code>InvalidStateError: authenticator already registered<\/code><\/td><td>Credential allerede registreret (excludeCredentials virker)<\/td><td>Normalfunktion &#8211; informer brugeren om, at enheden er registreret<\/td><\/tr><tr><td><code>session.currentChallenge is undefined<\/code><\/td><td>Session udl\u00f8bet eller ikke gemt<\/td><td>\u00d8g session-timeout eller brug Redis til session-store<\/td><\/tr><tr><td><code>Received unexpected authenticator counter<\/code><\/td><td>Counter i DB er ikke opdateret<\/td><td>S\u00f8rg for at credential.counter opdateres i DB efter hvert login<\/td><\/tr><tr><td><code>credential.publicKey is not a Uint8Array<\/code><\/td><td>Forkert serialisering ved lagring i database<\/td><td>Gem publicKey som Buffer &#8211; gendan med Buffer.from(stored, &#8216;base64&#8217;)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Debug-teknik:<\/strong> <code>clientDataJSON<\/code>-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\u00e6cis, hvad browseren sender, og sammenlign med din konfiguration:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Dekod clientDataJSON til debug (k\u00f8r i verify-endpointet)\nconst clientData = JSON.parse(\n  Buffer.from(req.body.response.clientDataJSON, 'base64url').toString()\n);\nconsole.log('Browser sendte:', {\n  type:      clientData.type,      \/\/ 'webauthn.create' eller 'webauthn.get'\n  origin:    clientData.origin,    \/\/ skal matche din ORIGIN\n  challenge: clientData.challenge, \/\/ skal matche session.currentChallenge\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Et succesfuldt login-forl\u00f8b giver f\u00f8lgende output i serverlogs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Server-output ved succesfuld registrering:\nWebAuthn server k\u00f8rer p\u00e5 port 3000\nrpID: localhost | origin: http:\/\/localhost:3000\nRegistrering OK: alice | backed up: true\n\n# Server-output ved succesfuldt login:\nLogin OK: alice | ny counter: 1\n\n# Server-output ved tredje login:\nLogin OK: alice | ny counter: 3<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"avancerede-tips-til-webauthn-i-produktion\">Avancerede tips til WebAuthn i produktion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Conditional UI (passkey autofill).<\/strong> WebAuthn Level 3 introducerer Conditional Mediation, som lader browseren vise passkeys direkte i brugernavnfeltet via en autocomplete-dropdown. Brugeren beh\u00f8ver ikke klikke en separat &#8220;Log ind med passkey&#8221;-knap. Det kr\u00e6ver <code>mediation: 'conditional'<\/code> i <code>startAuthentication()<\/code> og <code>autocomplete=\"username webauthn\"<\/code> p\u00e5 input-feltet. Conditional UI er underst\u00f8ttet i Chrome 108+, Safari 16+ og Edge 108+.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Conditional UI: passkey vises i autocomplete-dropdown\n\/\/ Send allowCredentials: [] for at tillade alle credentials til dette rpID\nconst options = await generateAuthenticationOptions({ rpID, allowCredentials: [] });\n\/\/ I auth.js (browser):\nconst assertion = await startAuthentication(options, true); \/\/ true = conditional UI\n\/\/ HTML input:\n\/\/ &lt;input type=\"text\" autocomplete=\"username webauthn\" id=\"username\" \/&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Cross-platform authenticatorer (YubiKey).<\/strong> Skift <code>authenticatorAttachment<\/code> til <code>'cross-platform'<\/code> for at underst\u00f8tte hardware-sikkerhedsn\u00f8gler som YubiKey 5 Series og Google Titan Key. Disse n\u00f8gler er de st\u00e6rkeste authenticatorer, da den private n\u00f8gle sidder i en dedikeret sikkerhedschip, der er tamper-resistant og ikke synkroniseres til skyen. For organisationer med h\u00f8je sikkerhedskrav (banker, myndigheder, kritisk infrastruktur, DORA-regulerede finansvirksomheder) anbefaler FIDO Alliance hardware-sikkerhedsn\u00f8gler frem for platform-authenticatorer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Redis til session-skalering.<\/strong> N\u00e5r du k\u00f8rer Node.js i cluster-mode eller bag en load balancer med flere instanser, skal session-data deles p\u00e5 tv\u00e6rs af processer. Installer <code>connect-redis<\/code> og konfigurer <code>express-session<\/code> til at bruge Redis som store. Dette sikrer, at challenge&#8217;n gemt p\u00e5 instans A stadig er tilg\u00e6ngelig, n\u00e5r browserens svar lander p\u00e5 instans B. Se vores guide til <a href=\"\/da\/sikker-session-nodejs\/\">sikker session i Node.js<\/a> for komplet Redis-konfiguration.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Rate limiting p\u00e5 auth-endpoints.<\/strong> Selvom WebAuthn er modstandsdygtig over for brute-force p\u00e5 credentials, b\u00f8r du beskytte dine endpoints mod misbrugsfors\u00f8g og DDoS. Tils\u00e6t <code>express-rate-limit<\/code> med en gr\u00e6nse p\u00e5 10 requests per minut per IP p\u00e5 <code>\/auth\/register\/options<\/code> og <code>\/auth\/login\/options<\/code>. Se vores guide til <a href=\"\/da\/oauth2-openid-connect-nodejs\/\">OAuth 2.0 og OpenID Connect i Node.js<\/a> for et komplet eksempel p\u00e5 rate-limiting i auth-flows.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Attestation til enterprise-brug.<\/strong> <code>attestationType: 'none'<\/code> er korrekt til forbrugerapps. Enterprise-implementeringer kan kr\u00e6ve <code>'direct'<\/code> attestation, som beviser authenticatorens oprindelse hos en betroet producent via FIDO Metadata Service. Det giver organisationer mulighed for at h\u00e5ndh\u00e6ve, at kun godkendte hardware-modeller bruges, men det reducerer privatlivets beskyttelse og \u00f8ger implementeringskompleksiteten markant. S\u00e6t altid <code>'none'<\/code>, medmindre du har et specifikt compliance-krav til attestation.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"postgresql-databaseskema-og-datapersistering\">PostgreSQL-databaseskema og datapersistering<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I produktion erstatter du <code>Map()<\/code>-lagringen med en relationel database. Her er et komplet PostgreSQL-skema med de felttyper og indekser, som WebAuthn-implementeringen kr\u00e6ver. Skemaet er designet til at underst\u00f8tte multi-enhed per bruger og giver effektive opslag via credential ID.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- PostgreSQL-skema til WebAuthn passkeys\n\nCREATE TABLE users (\n  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  username      VARCHAR(255) UNIQUE NOT NULL,\n  display_name  VARCHAR(255),\n  created_at    TIMESTAMPTZ DEFAULT NOW(),\n  updated_at    TIMESTAMPTZ DEFAULT NOW()\n);\n\nCREATE TABLE passkeys (\n  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n  user_id               UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n  -- credentialID som base64url-streng (typisk 43-86 tegn)\n  credential_id         TEXT UNIQUE NOT NULL,\n  -- credentialPublicKey som BYTEA (COSE-kodet offentlig n\u00f8gle, 77-300 bytes)\n  public_key            BYTEA NOT NULL,\n  -- Counter til clone-detection (opdateres ved hvert login)\n  counter               BIGINT NOT NULL DEFAULT 0,\n  -- 'singleDevice' eller 'multiDevice' (synkroniseret passkey)\n  device_type           VARCHAR(20) NOT NULL DEFAULT 'singleDevice',\n  -- true = passkey synkroniseret via iCloud\/Google\/Microsoft sky\n  backed_up             BOOLEAN NOT NULL DEFAULT FALSE,\n  -- ['internal'], ['usb'], ['nfc'], ['ble'] etc.\n  transports            TEXT[],\n  -- Navn brugeren giver enheden (valgfrit, til UI)\n  friendly_name         VARCHAR(255),\n  last_used_at          TIMESTAMPTZ,\n  created_at            TIMESTAMPTZ DEFAULT NOW()\n);\n\n-- Indeks til hurtig opslag under authentication\nCREATE INDEX idx_passkeys_credential_id ON passkeys(credential_id);\nCREATE INDEX idx_passkeys_user_id ON passkeys(user_id);\n\n-- Funktion til at opdatere counter og last_used_at atomisk\nCREATE OR REPLACE FUNCTION update_passkey_counter(\n  p_credential_id TEXT,\n  p_new_counter   BIGINT\n) RETURNS VOID AS $$\nBEGIN\n  UPDATE passkeys\n  SET    counter      = p_new_counter,\n         last_used_at = NOW()\n  WHERE  credential_id = p_credential_id\n    AND  counter < p_new_counter; -- Kun opdater hvis ny counter er st\u00f8rre\n  IF NOT FOUND THEN\n    RAISE EXCEPTION 'Counter validation fejlede for credential %', p_credential_id;\n  END IF;\nEND;\n$$ LANGUAGE plpgsql;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">I Node.js gemmer du <code>credentialPublicKey<\/code> (som er en <code>Uint8Array<\/code> fra SimpleWebAuthn) direkte i PostgreSQL som <code>BYTEA<\/code> via <code>pg<\/code>-pakken. PostgreSQL behandler automatisk <code>Buffer<\/code>-objekter som bin\u00e6re data. N\u00e5r du l\u00e6ser publicKey tilbage, f\u00e5r du en <code>Buffer<\/code>, som SimpleWebAuthn accepterer direkte i <code>verifyAuthenticationResponse()<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Gem credential med pg-pakken (node-postgres)\nconst { Pool } = require('pg');\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL });\n\n\/\/ Gem ny credential efter registrering\nasync function saveCredential(userId, credential, deviceType, backedUp, transports) {\n  await pool.query(\n    `INSERT INTO passkeys\n       (user_id, credential_id, public_key, counter, device_type, backed_up, transports)\n     VALUES ($1, $2, $3, $4, $5, $6, $7)`,\n    [\n      userId,\n      credential.id,                    \/\/ base64url string\n      Buffer.from(credential.publicKey), \/\/ Uint8Array -> Buffer -> BYTEA\n      credential.counter,\n      deviceType,\n      backedUp,\n      transports,\n    ]\n  );\n}\n\n\/\/ Hent credentials til authentication options\nasync function getCredentialsByUserId(userId) {\n  const result = await pool.query(\n    'SELECT * FROM passkeys WHERE user_id = $1 ORDER BY last_used_at DESC NULLS LAST',\n    [userId]\n  );\n  return result.rows.map(row => ({\n    credentialID:        row.credential_id,\n    credentialPublicKey: row.public_key,  \/\/ Buffer fra PostgreSQL\n    counter:             Number(row.counter),\n    transports:          row.transports || [],\n    credentialBackedUp:  row.backed_up,\n  }));\n}\n\n\/\/ Opdater counter atomisk efter succesfuldt login\nasync function updateCounter(credentialId, newCounter) {\n  await pool.query(\n    'SELECT update_passkey_counter($1, $2)',\n    [credentialId, newCounter]\n  );\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Brug den atomiske <code>update_passkey_counter()<\/code>-funktion frem for en simpel UPDATE, da den sikrer, at t\u00e6llerv\u00e6rdien altid stiger. Hvis to samtidige login-requests med samme credential lander (fx ved netv\u00e6rksfejl og retry), vil kun den f\u00f8rste opdatere counteren, og den anden vil kaste en fejl, der logger brugeren ud. Dette er den korrekte adf\u00e6rd til clone-detection.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Overvej at tilf\u00f8je en <code>friendly_name<\/code>-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 <code>req.headers['user-agent']<\/code>-parsing for en bedre brugeroplevelse.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"relateret-indhold\">Relateret indhold<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"vigtige-guides-til-node-js-sikkerhed\">Vigtige guides til Node.js-sikkerhed<\/h3>\n\n\n\n<ul class=\"wp-block-list\"><li><a href=\"\/da\/oauth2-openid-connect-nodejs\/\">OAuth 2.0 og OpenID Connect i Node.js: 12 trin p\u00e5 30 min<\/a> - standardprotokollerne til delegeret autorisation og SSO<\/li><li><a href=\"\/da\/sikker-session-nodejs\/\">Sikker session i Node.js: 12 trin p\u00e5 30 min<\/a> - session-hardening med Redis og cookie-sikkerhed<\/li><li><a href=\"\/da\/hmac-webhook-signaturer-nodejs\/\">HMAC i Node.js: webhook-signaturer i 12 trin<\/a> - kryptografisk signering af API-beskeder<\/li><li><a href=\"\/da\/ed25519-signaturer-nodejs\/\">Ed25519 i Node.js: signaturer i 12 trin<\/a> - moderne elliptic curve digitale signaturer<\/li><li><a href=\"\/da\/ssl-tls-certifikat-certbot-2026\/\">Gratis SSL\/TLS-certifikat: 12 trin med Certbot<\/a> - HTTPS-ops\u00e6tning med Let's Encrypt og Nginx<\/li><li><a href=\"\/da\/kodeordssikkerhed\/\">Kodeordssikkerhed: l\u00e6ngde, hashing og 2FA<\/a> - grundlaget for moderne autentificering<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Officielle ressourcer: <a href=\"https:\/\/simplewebauthn.dev\/\" rel=\"noopener noreferrer\" target=\"_blank\">SimpleWebAuthn dokumentation<\/a>, <a href=\"https:\/\/fidoalliance.org\/\" rel=\"noopener noreferrer\" target=\"_blank\">FIDO Alliance<\/a>, <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Web_Authentication_API\" rel=\"noopener noreferrer\" target=\"_blank\">MDN Web Authentication API<\/a>, <a href=\"https:\/\/passkeys.dev\/\" rel=\"noopener noreferrer\" target=\"_blank\">Passkeys.dev udviklerdokumentation<\/a>, <a href=\"https:\/\/www.w3.org\/TR\/webauthn-3\/\" rel=\"noopener noreferrer\" target=\"_blank\">W3C WebAuthn Level 3 specifikation<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"ofte-stillede-spoergsmaal-om-webauthn-i-node-js\">Ofte stillede sp\u00f8rgsm\u00e5l om WebAuthn i Node.js<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvad-er-forskellen-paa-webauthn-og-passkeys\">Hvad er forskellen p\u00e5 WebAuthn og passkeys?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn er selve protokollen, defineret af W3C og FIDO Alliance. <strong>Passkeys<\/strong> er Apples, Googles og Microsofts brugervenlige betegnelse for synkroniserede FIDO2-credentials implementeret via WebAuthn. En passkey er teknisk set en WebAuthn-credential med <code>residentKey: 'required'<\/code> og underst\u00f8ttelse af sky-synkronisering. Hardware-sikkerhedsn\u00f8gler (YubiKey, Google Titan) bruger ogs\u00e5 WebAuthn men er ikke passkeys, da de ikke synkroniseres.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kraever-webauthn-en-database\">Kr\u00e6ver WebAuthn en database?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja. Du skal gemme mindst <code>credentialID<\/code>, <code>credentialPublicKey<\/code>, <code>counter<\/code> og bruger-ID for hver registreret credential. I et produktionssystem har du typisk en <code>users<\/code>-tabel og en <code>passkeys<\/code>-tabel med en fremmed n\u00f8gle. <code>credentialPublicKey<\/code> er typisk 77-300 bytes afh\u00e6ngigt af n\u00f8gletypen og skal gemmes som binary (BYTEA i PostgreSQL, Buffer i Node.js).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kan-en-bruger-have-flere-passkeys\">Kan \u00e9n bruger have flere passkeys?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ja, og det anbefales. En bruger b\u00f8r registrere mindst 2 passkeys (fx \u00e9n p\u00e5 telefonen og \u00e9n p\u00e5 laptopen) for at undg\u00e5 at miste adgangen ved enhedstab. Din app skal underst\u00f8tte credential-management, herunder visning af registrerede enheder med metadata og sletning af individuelle credentials. <code>excludeCredentials<\/code>-parameteren forhindrer dobbeltregistrering af samme enhed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"fungerer-webauthn-med-alle-browsere-i-2026\">Fungerer WebAuthn med alle browsere i 2026?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Alle moderne desktop- og mobilbrowsere underst\u00f8tter WebAuthn: Chrome og Chromium-baserede browsere fra version 67+, Safari p\u00e5 macOS og iOS fra version 14+, Firefox fra version 60+ og Edge fra version 18+. Internet Explorer underst\u00f8ttes ikke. <code>@simplewebauthn\/browser<\/code> inkluderer feature detection, s\u00e5 du kan vise en fallback-besked til brugere med ikke-underst\u00f8ttede browsere.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvad-sker-der-ved-enhedstab\">Hvad sker der ved enhedstab?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Synkroniserede passkeys (iCloud Keychain, Google Password Manager) er tilg\u00e6ngelige fra andre enheder med samme Apple- eller Google-konto, selv efter enhedstab. Enheds-bundne credentials (hardware-n\u00f8gler, 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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"er-webauthn-kompatibelt-med-gdpr-og-nis2\">Er WebAuthn kompatibelt med GDPR og NIS2?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn er godt tilpasset GDPR-kravene. Den biometriske data (fingeraftryk, ansigtsscanning) behandles udelukkende lokalt p\u00e5 enheden og sendes aldrig til serveren. Du gemmer kun den kryptografiske offentlige n\u00f8gle, credential ID og metadata. NIST SP 800-63B klassificerer WebAuthn som AAL2 (Authenticator Assurance Level 2), hvilket opfylder kravene til st\u00e6rk autentificering under NIS2-direktivet, som g\u00e6lder for mindst 6.000 danske virksomheder i kritisk infrastruktur.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"kan-webauthn-erstatte-to-faktor-autentificering\">Kan WebAuthn erstatte to-faktor-autentificering?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">WebAuthn med <code>userVerification: 'required'<\/code> opfylder kravene til to-faktor-autentificering i \u00e9n handling: enheden er \"noget du har\" og biometrien er \"noget du er\". Det er teknisk set st\u00e6rkere end adgangskode + SMS-kode, fordi WebAuthn er phishing-resistent, mens SMS-koder kan phishes. For privilegerede handlinger i din app (fx overf\u00f8rsler over en bel\u00f8bsgr\u00e6nse eller adgang til administrator-panel) kan du kr\u00e6ve WebAuthn-verifikation p\u00e5 ny, selv for allerede-indloggede brugere.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hvad-koster-implementering-af-webauthn-i-node-js\">Hvad koster implementering af WebAuthn i Node.js?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Selve WebAuthn-protokollen er gratis og kr\u00e6ver ingen licens. SimpleWebAuthn er open source under MIT-licens og gratis at bruge uden begr\u00e6nsninger. De eneste driftsomkostninger er din sessions-infrastruktur (Redis koster typisk 15-50 USD\/m\u00e5ned 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\u00e6ngelige p\u00e5 alle moderne enheder uden ekstra cost. Hardware-sikkerhedsn\u00f8gler som YubiKey 5 Series koster 45-65 USD per n\u00f8gle, 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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Adgangskoder er det svageste led i moderne webapplikationers sikkerhed. Phishing-angreb, credential stuffing og datal\u00e6k koster virksomheder og brugere milliarder hvert \u00e5r, og svaret er ikke st\u00e6rkere adgangskoder. Det er ingen\u2026<\/p>\n","protected":false},"author":3,"featured_media":110,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-109","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security"],"_links":{"self":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/109","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/comments?post=109"}],"version-history":[{"count":1,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/109\/revisions"}],"predecessor-version":[{"id":111,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/posts\/109\/revisions\/111"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/media\/110"}],"wp:attachment":[{"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/media?parent=109"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/categories?post=109"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shattered.io\/dk\/wp-json\/wp\/v2\/tags?post=109"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}