Lösenord är fortfarande den vanligaste vägen in vid kontokapningar, och en SMS-kod stoppar knappt en riktad phishingattack längre. Sedan NIST publicerade den slutgiltiga versionen av SP 800-63B-4 i juli 2025 finns det ett tydligt svar från branschen: phishing-resistent multifaktorautentisering, med passkeys byggda på FIDO2 och WebAuthn högst upp på listan. OWASP:s uppdaterade topplista, Top 10:2025, backar upp samma linje och pekar ut A07:2025 Authentication Failures som en av de mer utnyttjade riskerna i webbapplikationer.
Den här guiden visar hur du bygger ett fungerande passkey-flöde i Node.js, från registrering till inloggning, med biblioteket SimpleWebAuthn. Du får kod att klistra in direkt, ett databasschema, felsökningstips och en checklista mot kraven i NIST 800-63B-4 och OWASP A07:2025. Målgruppen är utvecklare som redan kör ett Express-baserat backend och vill lägga till, eller ersätta, lösenord och SMS-baserad tvåfaktorsautentisering med en metod som faktiskt håller mot phishing.
För svenska och nordiska tjänster kommer passkeys sällan som en ersättning för BankID, utan som ett komplement på ställen där BankID är fel verktyg, interna admin-paneler, SaaS-produkter riktade mot företag utanför Sverige, eller mobilappar där du vill undvika att skicka användaren ut ur flödet till en extern legitimeringstjänst. Samma kryptografiska grundprincip gäller oavsett bransch: håll den privata nyckeln på enheten och lita aldrig på en delad hemlighet som kan läcka, gissas eller fiskas.
Vad är passkeys och varför ersätter de lösenord 2026?
En passkey är ett kryptografiskt nyckelpar som ersätter lösenordet helt eller fungerar som en stark andra faktor. Den privata nyckeln lämnar aldrig enheten, den lagras i en säker hårdvarukomponent (Secure Enclave, TPM eller motsvarande) och låses upp med biometri eller PIN-kod. Den publika nyckeln skickas till servern vid registrering och används sedan för att verifiera varje inloggning. Ingen delad hemlighet skickas över nätet, vilket gör hela klassen av attacker mot lösenordsdatabaser irrelevant.
“Passkeys are phishing resistant and secure by design.”
FIDO Alliance, branschorganisation bakom FIDO2-standarden (fidoalliance.org)
FIDO Alliance driver poängen vidare i samma material: passkeys “inherently help reduce attacks from cybercriminals such as phishing, credential stuffing, and other remote attacks” (FIDO Alliance). I praktiken betyder det att en angripare som lurar en användare till en falsk inloggningssida ändå inte får något användbart. Webbläsaren vägrar helt enkelt att skicka en signatur till fel domän, oavsett hur övertygande sidan ser ut.
Det som gör 2026 till ett brytpunktsår är regelverket snarare än tekniken, som funnits sedan 2019. NIST SP 800-63B-4 blev slutgiltig i juli 2025 och pekar tydligt ut FIDO2/WebAuthn och PKI-baserade autentiserare som att föredra framför lösenord kombinerat med SMS eller e-post-koder. OWASP Top 10:2025, som blev den officiella listan i januari 2026, håller kvar Authentication Failures på plats A07 och nämner uttryckligen MFA och FIDO2-passkeys som huvudsakliga motåtgärder mot credential stuffing och brute force. Sammantaget gör det passkeys till standardvalet snarare än ett nice-to-have för alla som bygger inloggning 2026.
Det är också värt att skilja på syncade passkeys och enhetsbundna nycklar, eftersom skillnaden påverkar både säkerhetsmodellen och din UX. En syncad passkey, till exempel en som skapas i Chrome på en dator kopplad till ett Google-konto, kopieras krypterat till molnleverantörens nyckelvalv och blir automatiskt tillgänglig på användarens andra enheter i samma ekosystem. En enhetsbunden nyckel, som en fysisk säkerhetsnyckel av typen YubiKey, lämnar aldrig hårdvaran och måste registreras separat på varje ny enhet. Ingen av varianterna är fel, men de kräver olika UX-texter och olika svar när en användare frågar “var är min inloggning”.
Så fungerar WebAuthn och FIDO2 under huven
WebAuthn är webbläsarens API för att skapa och använda dessa nyckelpar, standardiserat av W3C. FIDO2 är samlingsnamnet för hela protokollstacken, inklusive CTAP (Client to Authenticator Protocol) som webbläsaren använder för att prata med en säkerhetsnyckel eller en plattformsautentiserare som Touch ID. Flödet består av två separata steg som du bygger var för sig: registrering (attestation) och inloggning (assertion).
Vid registrering genererar servern en slumpad utmaning (challenge) och skickar den till klienten tillsammans med metadata om vem som registrerar sig. Webbläsaren ber autentiseraren skapa ett nytt nyckelpar, signerar utmaningen med den nya privata nyckeln och skickar tillbaka den publika nyckeln plus signaturen. Servern verifierar signaturen och sparar bara den publika nyckeln. Vid inloggning upprepas samma mönster fast med den redan sparade nyckeln: servern skickar en ny utmaning, klienten signerar den med den privata nyckeln som redan finns på enheten, och servern kontrollerar signaturen mot den publika nyckel den sparade tidigare.
“FIDO authentication is considered phishing resistant because the decision about whether a particular scoped credential may be used and the results shared with a server endpoint is delegated to security mechanisms within a trusted computer program such as the browser rather than the human having to visually recognise a phishing attempt themselves.”
Shane Weeden, säkerhetsskribent, IBM Community (IBM Community)
Den mekanism som gör det möjligt kallas origin binding. Varje nyckelpar knyts kryptografiskt till den exakta domän (origin) där det skapades. Som identitetsplattformen WorkOS beskriver det: “Passkeys, built on the FIDO2/WebAuthn standards, eliminate this entire class of vulnerability by replacing shared secrets with asymmetric cryptography and enforcing a property known as cryptographic origin binding” (WorkOS). Skapas nyckeln för shattered.io går den aldrig att använda för shattered-io.evil.example, oavsett hur snygg phishingsidan är. Det är precis den logiken din serverkod måste replikera genom att alltid kontrollera rpID och origin, vilket du ser i koden längre ner.
Ett tekniskt begrepp värt att känna till är attestation, det vill säga bevis från autentiseraren om att nyckeln faktiskt skapades av äkta, certifierad hårdvara. I den här guiden används attestationType: 'none', vilket räcker för de flesta konsumentinriktade tjänster och håller registreringssvaret litet. Vill du bygga något som kräver bevis på exakt vilken modell säkerhetsnyckel en anställd använder, till exempel i en hög-assurance-miljö, byter du till 'direct' och validerar mot en lista över godkända autentiserartillverkare. De flesta team behöver aldrig göra det bytet.
Förutsättningar innan du börjar
Du behöver inte mycket för att komma igång, men WebAuthn kräver en secure context (HTTPS eller localhost) och en modern webbläsare. Så här ser den minsta uppsättningen ut:
- Node.js 20 LTS eller senare (använd gärna den senaste aktiva LTS-versionen)
- npm 10 eller senare, medföljer Node.js
- Paketen
@simplewebauthn/serveroch@simplewebauthn/browser, senaste versionen från npm - Express 4.x som webbramverk
- SQLite via
better-sqlite3för utveckling, byt till PostgreSQL i produktion - mkcert eller motsvarande för att köra lokal HTTPS, WebAuthn vägrar köra osäkrat utom på exakt
localhost - En modern webbläsare med WebAuthn-stöd: Chrome, Edge, Safari eller Firefox i sina nuvarande versioner
- Grundläggande kunskap i JavaScript, Express och REST-API:er
Du behöver även minst en fysisk eller virtuell autentiserare att testa med. Har du en dator med Touch ID, Windows Hello eller en Android-telefon med skärmlås fungerar det direkt. Chrome har dessutom en inbyggd virtuell autentiserare i devtools (fliken WebAuthn) som är praktisk för automatiserade tester utan riktig hårdvara.
Ha också klart för dig vilken databas du landar i innan du börjar. SQLite duger fint under utveckling och för mindre tjänster, men flyttar du till flera serverinstanser bakom en lastbalanserare behöver du antingen PostgreSQL eller motsvarande delad databas, eller en delad sessionslagring som Redis, så att en användares utmaning (challenge) går att hitta oavsett vilken instans som tar emot verifieringsanropet.
Steg 1-3: Sätt upp Node.js-projektet
Börja med ett tomt projekt och installera de tre kärnberoendena: Express för servern, SimpleWebAuthn för själva protokollet, och better-sqlite3 för att lagra användare och credentials.
mkdir passkey-demo && cd passkey-demo
npm init -y
npm install express @simplewebauthn/server better-sqlite3 express-session dotenv
npm install --save-dev nodemon
Projektstrukturen håller sig medvetet enkel, så hela demot går att läsa på under tio minuter:
server.js– Express-app och alla WebAuthn-routesdb.js– SQLite-anslutning och hjälpfunktioner för användare och credentialspublic/index.html– registrerings- och inloggningsformulärpublic/client.js– anrop tillstartRegistrationochstartAuthentication.env–SESSION_SECREToch portinställningar
Sätt sedan upp grundservern med sessionshantering. Sessionen används för att tillfälligt lagra utmaningen (challenge) mellan det att klienten hämtar registreringsalternativ och skickar tillbaka svaret. Cookien måste vara httpOnly, secure och sameSite: strict för att inte öppna en ny attackyta bredvid den du just stängt. Sätt en kort livstid på sessionen, fem minuter räcker gott om marginal, eftersom utmaningen bara behöver överleva den korta stund det tar användaren att slutföra dialogen med sin autentiserare.
// server.js
import express from 'express';
import session from 'express-session';
import 'dotenv/config';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'strict', maxAge: 5 * 60 * 1000 },
}));
const rpName = 'Shattered Demo';
const rpID = 'localhost';
const origin = `https://${rpID}:3000`;
app.listen(3000, () => console.log('Server igång på port 3000'));
rpID är den domän som passkeyn knyts till, och origin måste matcha exakt den URL webbläsaren faktiskt kör på, protokoll inräknat. I produktion sätter du rpID till din riktiga domän, till exempel shattered.io, aldrig till en subdomän eller ett IP-nummer.
Sista biten i grundsetupet är db.js, som håller resten av koden ren från SQL. Den skapar tabellerna om de saknas och exponerar tre enkla funktioner som resten av servern anropar.
// db.js
import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
const db = new Database('passkeys.db');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT
);
`);
export function createUser(username) {
const id = randomUUID();
db.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, username);
return { id, username, credentials: [] };
}
export function getUser(username) {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return null;
user.credentials = db.prepare('SELECT * FROM credentials WHERE user_id = ?').all(user.id);
return user;
}
export function saveCredential(username, cred) {
const user = getUser(username);
db.prepare(
'INSERT INTO credentials (id, user_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)'
).run(cred.id, user.id, cred.publicKey, cred.counter, (cred.transports || []).join(','));
}
Steg 4-6: Bygg registreringsflödet
Registreringen börjar med att servern genererar alternativ, inklusive en slumpad utmaning, och skickar dem till klienten. Sätt residentKey: 'required' så att passkeyn blir “discoverable”, det vill säga att användaren kan logga in utan att först skriva ett användarnamn.
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { getUser, createUser, saveCredential } from './db.js';
app.post('/register/options', async (req, res) => {
const { username } = req.body;
const user = getUser(username) ?? createUser(username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
userID: Buffer.from(user.id),
attestationType: 'none',
excludeCredentials: user.credentials.map((c) => ({ id: c.id, transports: c.transports })),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
});
req.session.challenge = options.challenge;
req.session.username = username;
res.json(options);
});
På klientsidan anropar du startRegistration från @simplewebauthn/browser, som pratar med webbläsarens WebAuthn-API åt dig. Det är här användaren ser dialogrutan för Touch ID, Windows Hello eller en säkerhetsnyckel. Funktionen kastar ett fel om användaren avbryter dialogen, så omge alltid anropet med try/catch i verklig kod och visa ett neutralt meddelande i stället för att krascha gränssnittet.
import { startRegistration } from 'https://esm.sh/@simplewebauthn/browser';
async function registerPasskey(username) {
const optionsRes = await fetch('/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const options = await optionsRes.json();
const attestation = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch('/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation),
});
const result = await verifyRes.json();
if (result.verified) console.log('Passkey skapad');
}
Sista biten på servern är att verifiera svaret och spara den publika nyckeln. Kontrollera alltid expectedOrigin och expectedRPID mot din faktiska domän, annars öppnar du för att en angripare registrerar en nyckel från fel plats. Notera också att verifieringsfunktionen kastar ett fel snarare än att returnera false om exempelvis signaturen inte matchar formatet den förväntar sig, så en try/catch runt anropet är att rekommendera i produktionskod, tillsammans med strukturerad loggning av vilken typ av fel som uppstod.
app.post('/register/verify', async (req, res) => {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
saveCredential(req.session.username, {
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: req.body.response.transports,
});
}
res.json({ verified: verification.verified });
});
Steg 7-9: Bygg inloggningsflödet
Inloggningen speglar registreringen, men nu skickar servern en ny utmaning tillsammans med listan över kända credentials för kontot, om användaren skrivit ett namn, eller ingen lista alls om du litar på discoverable credentials och låter webbläsaren visa alla passkeys som matchar rpID. Det senare ger den smidigaste upplevelsen, ett enda “logga in med passkey”-klick utan att användaren behöver komma ihåg sitt användarnamn, men kräver att du sparade residentKey: 'required' vid registreringen.
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
app.post('/login/options', async (req, res) => {
const { username } = req.body;
const user = getUser(username);
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'preferred',
allowCredentials: user?.credentials.map((c) => ({ id: c.id, transports: c.transports })),
});
req.session.challenge = options.challenge;
req.session.username = username;
res.json(options);
});
Verifieringen på servern är den viktigaste säkerhetskontrollen i hela flödet. Den kontrollerar signaturen mot den sparade publika nyckeln, matchar utmaningen mot den som skickades ut, och jämför räknaren (counter) för att upptäcka klonade autentiserare.
app.post('/login/verify', async (req, res) => {
const user = getUser(req.session.username);
const credential = user.credentials.find((c) => c.id === req.body.id);
if (!credential) return res.status(400).json({ verified: false });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential,
});
if (verification.verified) {
credential.counter = verification.authenticationInfo.newCounter;
req.session.loggedIn = true;
}
res.json({ verified: verification.verified });
});
Räknaren stiger med ett för varje lyckad inloggning på de flesta säkerhetsnycklar. Ligger det inkommande värdet under vad du redan har sparat är det ett tecken på att någon klonat autentiseraren, och du bör avvisa inloggningen och flagga kontot. Syncade plattformspasskeys, som Apples eller Googles, rapporterar ofta counter 0 hela tiden eftersom nyckeln aldrig lever i ett enda hårdvaruelement, vilket är förväntat och inte en varningssignal i sig.
Ett vanligt nybörjarmisstag är att bara kontrollera verification.verified och glömma bort att faktiskt spara det uppdaterade räknarvärdet efteråt. Skriver du aldrig tillbaka newCounter till databasen tappar hela mekanismen sitt syfte, nästa inloggning jämförs fortfarande mot det gamla, orörda värdet och klondetekteringen slutar fungera helt i tysthet.
Steg 10-11: Databasschema och lagring av credentials
Håll schemat enkelt: en tabell för användare och en för credentials, med en till många-relation eftersom samma konto ska kunna ha flera passkeys på flera enheter.
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE credentials (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
device_type TEXT,
backed_up BOOLEAN DEFAULT 0,
nickname TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_credentials_user_id ON credentials(user_id);
Spara aldrig den privata nyckeln, den finns inte ens tillgänglig för din server. public_key lagras som binärdata (BLOB), transports som en kommaseparerad sträng eller JSON (till exempel usb,nfc eller internal), och nickname är valfri men gör livet lättare för användare med flera enheter, “Jobbdator”, “Privat telefon” och så vidare. Kolumnen backed_up speglar om autentiseraren rapporterar att nyckeln är molnsynkad, vilket du kan använda för att visa återställningsstatus i kontoinställningarna.
Indexet på user_id är inte kosmetiskt. Varje inloggning slår upp samtliga credentials för ett konto, och utan index blir det en full tabellskanning så fort användarbasen växer förbi några tusen konton. Lägg samma tanke på att aldrig exponera public_key-kolumnen i något API-svar, den behövs bara internt av verifieringsfunktionen och läcker ingen hemlighet om den skulle synas, men det finns ingen anledning att skicka onödig binärdata till klienten.
Steg 12-14: Flera enheter, reservmetoder och borttagning
Ett komplett flöde slutar inte vid första passkeyn. Tre saker återstår innan det håller för produktion.
- Steg 12, flera enheter: Låt inloggade användare lägga till fler passkeys från kontoinställningarna genom att köra samma registreringsflöde igen, bara utan att skapa ett nytt konto. Visa varje sparad credential med enhetstyp och skapandedatum så användaren kan känna igen sina egna enheter.
- Steg 13, reservmetod: Ha alltid en väg tillbaka in om en användare tappar bort samtliga enheter, engångskoder som visas en gång vid registrering och som användaren själv sparar offline, eller en manuell verifieringsprocess via support. Undvik att falla tillbaka på SMS som enda reserv, det river ner precis den phishing-resistens du just byggt.
- Steg 14, borttagning: Ge användaren möjlighet att radera enskilda passkeys, till exempel när en gammal telefon säljs vidare. Kräv omautentisering innan borttagning så att en kapad session inte kan låsa ut den riktiga ägaren genom att ta bort alla nycklar utom angriparens egen.
Sätter du ihop de tre stegen får du ett flöde som klarar de vanliga verkliga scenarierna: en anställd som byter dator, en användare som köper en ny telefon, och supportärendet som oundvikligen dyker upp när någon rensar webbläsarens sparade data av misstag. Bygg gärna en enkel logg över registreringar och borttagningar per konto också, den blir ovärderlig när en användare hör av sig och undrar varför de plötsligt loggades ut från en enhet.
Testa hela flödet: exempel på output
Starta servern med node --env-file=.env server.js, öppna https://localhost:3000 och registrera en testanvändare. Ett lyckat svar från /register/verify ser ut ungefär så här:
{
"verified": true,
"registrationInfo": {
"fmt": "none",
"credentialType": "public-key",
"credentialDeviceType": "multiDevice",
"credentialBackedUp": true,
"origin": "https://localhost:3000",
"rpID": "localhost"
}
}
credentialDeviceType: "multiDevice" betyder att autentiseraren rapporterar sig som en syncad passkey, till skillnad från "singleDevice" som pekar på en fysisk säkerhetsnyckel utan molnbackup. Logga in direkt efter, och kontrollera i devtools nätverksflik att /login/verify svarar med {"verified": true} innan du bygger vidare på resten av gränssnittet. Testa gärna hela flödet i Chromes devtools under fliken WebAuthn också, den låter dig simulera en säkerhetsnyckel utan att behöva riktig hårdvara, vilket är användbart i CI.
Vill du kontrollera att en användare faktiskt har en registrerad passkey innan du visar inloggningsknappen kan du bygga ett litet statusendpoint, till exempel GET /users/:username/has-passkey, som svarar med {"hasPasskey": true} eller false. Var dock försiktig med att inte läcka information om vilka användarnamn som existerar i systemet via ett sådant endpoint, en generisk rate limiter räcker ofta långt för att göra uppräkning opraktisk.
Passkeys vs TOTP vs SMS-koder: jämförelse
Innan du bestämmer om passkeys ska ersätta eller komplettera din befintliga tvåfaktorslösning är det värt att se skillnaderna i klartext. TOTP-appar (som Google Authenticator) är fortfarande ett stort steg bättre än SMS, men ingen av de två håller mot en riktad phishingattack på samma sätt som WebAuthn gör.
| Egenskap | Passkeys (FIDO2/WebAuthn) | TOTP (autentiseringsapp) | SMS-kod |
|---|---|---|---|
| Phishing-resistens | Ja, kryptografisk origin binding | Nej, koden kan vidarebefordras manuellt | Nej, sårbar för SIM-swap och vidarebefordran |
| Delad hemlighet mellan server och klient | Nej, endast publik nyckel lagras | Ja, en delad seed-nyckel | Ja, kopplat till telefonnummer |
| Ställning i NIST SP 800-63B-4 | Rekommenderad phishing-resistent metod | Godkänd som en av flera faktorer | Avrådd som enda eller primär faktor |
| Beroende av mobilnät | Nej | Nej | Ja |
| Användarupplevelse | Biometri eller PIN, ett klick | Öppna app, läs och skriv sex siffror | Vänta på leverans, skriv koden |
| Återställning vid tappad enhet | Molnsynk eller reservnyckel på annan enhet | Kräver sparade backup-koder | Nytt SIM-kort eller nytt nummer |
Slutsatsen är inte att TOTP är värdelöst, det skyddar fortfarande mot rent lösenordsläckage och massautomatiserade attacker. Men för konton som är attraktiva mål, administratörskonton, betalsystem, e-post som används för lösenordsåterställning, är passkeys det enda alternativet som faktiskt stoppar en välgjord phishingsida.
Vilken typ av autentiserare din kod behöver stödja beror på vem som använder tjänsten. En konsumentapp bör acceptera alla tre typerna nedan utan att tvinga fram en specifik, medan ett internt admin-system kan ha goda skäl att kräva roaming-nycklar för högsta assurance.
| Typ | Exempel | Var den privata nyckeln lagras |
|---|---|---|
| Plattformsautentiserare (syncad passkey) | Touch ID/Face ID, Windows Hello, Google Password Manager | Molnsynkat och krypterat i användarens ekosystemkonto |
| Roaming-autentiserare (säkerhetsnyckel) | YubiKey och andra FIDO2-nycklar över USB eller NFC | Lokalt på hårdvaruenheten, aldrig synkad |
| Hybrid (cross-device) | Skanna en QR-kod och logga in med telefonens passkey på en annan dator | Telefonens säkra element, ansluter via caBLE/Bluetooth |
7 vanliga fallgropar när du implementerar passkeys
De flesta buggarna i en WebAuthn-implementation är inte kryptografiska, de är konfigurationsfel som visar sig först i produktion eller på en enhet du inte testade på. Här är de sju som återkommer oftast i kod som annars ser korrekt ut.
- Fel eller varierande rpID. Sätter du
rpIDtill en subdomän i utveckling och en annan i produktion blir gamla passkeys oanvändbara. HållrpIDtill den registrerbara toppdomänen och variera baraorigin. - Glömma jämföra counter. Hoppar du över countervalideringen förlorar du ett av de få tecknen på klonad hårdvara som WebAuthn faktiskt ger dig.
- Behandla “ingen passkey hittades” som ett fel istället för ett normalt UX-läge. Många användare har inte satt upp en passkey än, visa ett tydligt registreringsflöde i stället för ett generiskt felmeddelande.
- Kräva
userVerification: 'required'när'preferred'räcker. Det tvingar fram PIN eller biometri i situationer där enheten redan är låst upp, vilket bara skapar friktion utan säkerhetsvinst. - Bygga fallback via SMS som enda reservmetod. Det river ner phishing-resistensen du just implementerat, eftersom en angripare bara behöver rikta in sig på den svagare kanalen istället.
- Testa enbart i en webbläsare. Safari, Chrome och Firefox skiljer sig i hur de exponerar
transportsoch hanterar cross-device-flöden. Testa registrering och inloggning i minst två olika webbläsarmotorer innan lansering. - Glömma att en användare kan ha noll passkeys kvar. Om alla enheter tappas bort eller raderas måste kontot fortfarande gå att komma åt, annars har du byggt ett lås utan reservnyckel.
Felsökning: 9 vanliga problem och lösningar
Sparar du de här nio i en intern wiki sparar du dig själv ett antal supportärenden. De flesta problemen dyker upp första gången kod flyttas från localhost till en riktig domän, eller när en användare loggar in från en enhet du inte testade under utvecklingen.
- “SecurityError: The relying party ID is not a registrable domain suffix.” Din
rpIDmatchar inte domänen sidan faktiskt körs på. Kontrollera att du inte har en trailing punkt eller port i värdet. - Registreringen fungerar lokalt men inte i produktion. Oftast saknas HTTPS, eller så pekar
originfortfarande motlocalhosti din konfiguration. WebAuthn kräver en secure context överallt utom exaktlocalhost. - “Challenge mismatch” vid verifiering. Sessionscookien gick förlorad mellan options-anropet och verify-anropet, ofta för att
sameSiteellersecureär felinställt för din miljö, eller för att en load balancer inte behåller sessionsaffinitet. - Ingen dialogruta dyker upp i webbläsaren. Kontrollera att sidan körs i en toppnivå-flik och inte i en iframe, WebAuthn blockeras som standard i inbäddade ramar utan explicit tillåtelse.
- Counter minskar mellan inloggningar. Ett äkta varningstecken på en klonad autentiserare, avvisa inloggningen, logga händelsen och tvinga fram en ny registrering från en känd enhet.
- Användaren ser sin passkey på telefonen men inte på datorn. Molnsynk av passkeys är knuten till respektive ekosystem (Apple, Google, Microsoft). En passkey skapad i Chrome på Windows syns inte automatiskt på en iPhone om inte cross-device-inloggning (QR-kod) används.
- “InvalidStateError” vid registrering. Användaren försöker registrera en autentiserare som redan finns i
excludeCredentials. Det är förväntat beteende, visa ett meddelande om att enheten redan är kopplad till kontot. - Verifieringen misslyckas trots korrekt UI-flöde. Kontrollera att servertiden är synkroniserad (NTP), signaturvalideringen är känslig för stora tidsavvikelser i vissa bibliotek, samt att du skickar rätt
Content-Type: application/json. - Passkeyn fungerar inte efter att domänen bytt från
www.exempel.setillexempel.se. EftersomrpIDär knuten till en specifik registrerbar domän måste du planera en migrering med parallell registrering, gamla nycklar följer inte automatiskt med vid domänbyte.
Ingen av dessa nio kräver att du gräver i WebAuthn-specifikationen för att lösa. De flesta går att slå fast med ett par rader loggning kring vilket rpID, origin och challenge som faktiskt skickades, jämfört med vad servern förväntade sig.
Avancerade tips: conditional UI, cross-device och företagsutrullning
När grundflödet fungerar finns tre förbättringar som gör skillnad i praktiken. Den första är conditional UI (även kallat autofill-inloggning), där webbläsaren visar sparade passkeys direkt i användarnamnsfältet utan att du behöver ett separat “logga in med passkey”-klick. Det kräver mediation: 'conditional' i anropet till startAuthentication och ett vanligt <input autocomplete="username webauthn">-fält, resten sköter webbläsaren. Den här formen av inloggning brukar ge den högsta konverteringen eftersom den tar bort ett helt klicksteg, användaren ser bara sin passkey dyka upp som ett autofyll-förslag precis där de ändå skulle klicka.
Den andra är cross-device-inloggning (hybrid transport), där en användare skannar en QR-kod på datorn med telefonens kamera och loggar in med telefonens passkey via en Bluetooth-baserad anslutning (caBLE). Det här är standardläget om ingen passkey finns lokalt på datorn, och du behöver inte skriva extra kod för det, SimpleWebAuthn och webbläsaren hanterar det automatiskt så länge hints och transports inte begränsas i onödan.
Den tredje gäller företagsutrullning. Identitetsplattformar pekar redan ut passkeys som förstahandsval för att stoppa riktade attacker mot anställda.
“Passkeys (FIDO2 WebAuthn) and Okta FastPass (which comes with Okta Verify) are phishing-resistant authenticators that prevent email, SMS, and social media phishing attacks.”
Okta, dokumentation för identitets- och åtkomsthantering (Okta Help Center)
Vid en företagsutrullning är stegvis migrering nyckeln. Låt användare registrera en passkey som ett alternativ parallellt med befintlig inloggning i minst en period, samla statistik på registreringsgrad per avdelning, och tvinga inte fram fullständig migrering förrän supportvolymen kring “jag har tappat min passkey” är låg och förutsägbar.
Sätt också upp mätning från dag ett: andel inloggningar via passkey kontra lösenord, andel misslyckade verifieringar per webbläsare, och tid från att en användare startar registreringen till att den faktiskt slutförs. Faller registreringen ofta av mitt i flödet är det oftast ett tecken på otydlig kopiering i UI:t snarare än ett tekniskt fel, användare som inte förstår varför de plötsligt ombeds trycka på en fysisk knapp eller titta in i kameran avbryter dialogen.
Efterlevnad: NIST 800-63B-4 och OWASP Top 10 2025
Att bygga ett tekniskt korrekt WebAuthn-flöde räcker inte alltid för en revision. Nedan är en direkt koppling mellan kraven i de aktuella standarderna och vad som faktiskt behöver finnas i koden ovan för att uppfylla dem.
| Krav | Källa | Hur du uppfyller det |
|---|---|---|
| Phishing-resistent autentisering som förstahandsval | NIST SP 800-63B-4 | Origin- och rpID-kontroll i verifyRegistrationResponse och verifyAuthenticationResponse |
| Blockera kända läckta lösenord om lösenord fortfarande används som fallback | NIST SP 800-63B-4 | Kontrollera nya lösenord mot en känd läckedatabas innan de accepteras |
| Mildra credential stuffing och brute force mot inloggningsendpoints | OWASP A07:2025 Authentication Failures | Rate limiting på /login/options och /login/verify |
| Undvik felkonfigurerade autentiseringsendpoints | OWASP A02:2025 Security Misconfiguration | Hårdkoda exakt rpID och origin, aldrig wildcard eller värden från request-headers |
| Härdad sessionshantering kring utmaningen (challenge) | OWASP A07:2025 Authentication Failures | httpOnly, secure och sameSite: strict på sessionscookien, kort livstid på utmaningen |
| Bevaka beroendekedjan för autentiseringsbiblioteket | OWASP A03:2025 Software Supply Chain Failures | Lås versioner av @simplewebauthn/*, granska uppdateringar innan de tas in i produktion |
Ingen av de här kontrollerna kräver extra bibliotek utöver det du redan installerat i steg 1. Det handlar om att faktiskt aktivera rätt inställningar, sätta gränser på endpoints som saknar dem idag, och dokumentera att du gjort det, vilket är precis vad en revisor letar efter när OWASP A07:2025 dyker upp i en säkerhetsgranskning.
Ett sista, ofta förbisett krav gäller loggning. Både NIST och OWASP förutsätter att du kan visa vem som loggade in, när och med vilken metod, inte bara att autentiseringen fungerade tekniskt. Logga verifieringsutfall (lyckad/misslyckad), vilken credentialID som användes och tidsstämpel, men logga aldrig själva den publika nyckeln eller rådata från WebAuthn-svaret i klartextloggar, det är onödig data att exponera om loggsystemet någon gång läcker.
Vanliga frågor om passkeys och WebAuthn
Måste jag ta bort lösenord helt för att använda passkeys?
Nej. De flesta tjänster kör passkeys som ett alternativ parallellt med lösenord under en övergångsperiod, och låter användaren själv välja när lösenordet tas bort helt från kontot.
Fungerar passkeys utan internetuppkoppling?
Själva signeringen sker lokalt på enheten och kräver ingen uppkoppling, men appen eller webbsidan som pratar med din server behöver förstås nätverk för att skicka options- och verify-anropen.
Vad händer om jag tappar bort telefonen med min passkey?
Är passkeyn synkad via iCloud, Google Password Manager eller Windows Hello går den att återställa på en ny enhet via samma molnkonto. Är det en fristående säkerhetsnyckel utan synk behöver du en reservmetod eller en andra registrerad nyckel.
Är SimpleWebAuthn det enda biblioteket för Node.js?
Nej, men det är ett av de mest använda open source-alternativen för WebAuthn i Node.js-ekosystemet och håller sig nära W3C-specifikationen, vilket gör det till ett rimligt förstahandsval för egna implementationer.
Kan jag använda passkeys tillsammans med TOTP under en övergångsperiod?
Ja, och det är en vanlig migreringsstrategi. Låt användare registrera en passkey frivilligt, mät andelen som gör det, och höj successivt hur mycket flödet uppmuntrar till det innan TOTP fasas ut som förvalt alternativ.
Kräver WebAuthn alltid biometri?
Nej. En autentiserare kan låsas upp med PIN-kod, mönster eller biometri beroende på enhet och inställningar, WebAuthn bryr sig bara om att någon form av lokal verifiering skett, inte om exakt vilken metod som användes.
Vilka webbläsare stöder passkeys 2026?
Samtliga stora webbläsare, Chrome, Edge, Safari och Firefox, stöder WebAuthn och passkeys i sina nuvarande versioner på både desktop och mobil.
Kostar det något att implementera passkeys själv?
Standarden och biblioteken som @simplewebauthn/server är öppen källkod och gratis att använda. Kostnaden ligger i utvecklingstid och eventuell infrastruktur för sessionshantering och databaslagring, inte i licensavgifter. Skiljer sig implementationen mellan en webbapp och en mobilapp? Grundprincipen och serverkoden är densamma, eftersom det är samma WebAuthn-protokoll som ligger under. På native mobil (iOS/Android) anropar du plattformens egna passkey-API:er istället för webbläsarens navigator.credentials, men options- och verify-anropen mot din server ser i princip identiska ut. Skillnaden ligger nästan uteslutande i klientkoden, medan hela backend-implementationen från den här guiden går att återanvända rakt av oavsett om anropen kommer från en webbläsare eller en nativ app.
Relaterad läsning
- Tvåfaktorsautentisering i Node.js: TOTP-baserad 2FA i 12 steg
- Helmet.js i Node.js: 12 steg för HTTP-säkerhetshuvuden
- Node.js indatavalidering: 12 steg mot injektionsattacker
- ECDH i Node.js: elliptisk kurva Diffie-Hellman i 12 steg
- Proton Pass vs Dashlane: lösenordshanterare jämförda
Fler artiklar om autentisering, kryptering och applikationssäkerhet hittar du på vår säkerhetssida.




