Коротка відповідь. WebAuthn – це асиметрична криптографія замість спільного секрету: при реєстрації пристрій генерує пару ключів і віддає серверу лише публічний, при вході – підписує випадковий challenge приватним. Сервер зберігає credential ID + публічний ключ + лічильник, і жоден витік бази не дає зловмиснику способу увійти. Фішинг ламається тому, що підпис прив'язаний до origin: клон сайту на іншому домені отримує підпис, який не пройде перевірку. Frontend викликає navigator.credentials, backend володіє challenge-ами і verification – і плутати ці ролі не можна.
Дві операції, які часто змішують
У WebAuthn є рівно два флоу, і половина плутанини зникає, щойно розділити їх у голові:
- Registration (
navigator.credentials.create) – «створи для цього сайту нову пару ключів». Виконується раз. - Authentication (
navigator.credentials.get) – «підпиши цей challenge ключем, який уже є». Виконується на кожен вхід.
Обидва починаються і закінчуються на сервері: він генерує challenge, він же перевіряє результат. Браузер і автентифікатор (Touch ID, ключ, менеджер паролів) – посередники, яким не можна довіряти рішення.
Registration: від challenge до запису в базі
Покроково:
- Сервер генерує challenge – мінімум 16 байт випадковості – і зберігає його з прив'язкою до сесії/користувача з коротким TTL.
- Frontend викликає
credentials.createз цим challenge,rp.id(домен), даними користувача і списком алгоритмів. - Автентифікатор просить підтвердження (біометрія/PIN), генерує пару ключів і повертає
PublicKeyCredential: credential ID, публічний ключ, attestation. - Backend перевіряє: challenge збігається з виданим, origin/RP ID – очікувані, підпис валідний. Лише після цього – запис у базу.
Спрощений фронтенд (TypeScript):
async function registerPasskey(username: string) {
// 1. Отримуємо options (із challenge) ВІД СЕРВЕРА – ніколи не генеруємо локально
const options = await fetch('/api/webauthn/register/options', {
method: 'POST',
body: JSON.stringify({ username }),
}).then((r) => r.json());
// 2. Пристрій створює пару ключів
const credential = (await navigator.credentials.create({
publicKey: {
challenge: base64urlToBuffer(options.challenge),
rp: { id: options.rpId, name: 'CookieSoftware' },
user: {
id: base64urlToBuffer(options.userId),
name: username,
displayName: username,
},
pubKeyCredParams: [{ type: 'public-key', alg: -7 }], // ES256
authenticatorSelection: { residentKey: 'required' }, // discoverable → passkey
},
})) as PublicKeyCredential;
// 3. Результат – на сервер для verification
await fetch('/api/webauthn/register/verify', {
method: 'POST',
body: JSON.stringify(serializeCredential(credential)),
});
}Що сервер кладе в базу після успішної перевірки – і це все, жодних секретів:
type StoredCredential = {
userId: string;
credentialId: string; // публічний ідентифікатор
publicKey: string; // публічний ключ (COSE)
signCount: number; // детект клонованих автентифікаторів
createdAt: Date;
};Login: підпис замість пароля
- Сервер видає новий challenge (одноразовий, із TTL).
- Frontend викликає
credentials.get; для passkeys (discoverable credentials) можна навіть не передаватиallowCredentials– пристрій сам покаже доступні акаунти, а зmediation: 'conditional'це працює як автозаповнення у полі логіна. - Автентифікатор підписує challenge + дані origin приватним ключем.
- Backend: знаходить credential за ID, перевіряє підпис публічним ключем, звіряє challenge, origin, RP ID і що
signCountзріс. Тільки тоді створює сесію.
Серверна частина концептуально (спрощено; для production бери перевірену бібліотеку верифікації – самописний розбір CBOR/COSE це класичне місце вразливостей):
// Challenge store: одноразові, з TTL – Redis тут природний
async function issueChallenge(sessionId: string): Promise<string> {
const challenge = randomBytes(32).toString('base64url');
await redis.set(`webauthn:${sessionId}`, challenge, { EX: 120 });
return challenge;
}
async function verifyLogin(sessionId: string, response: AssertionResponse) {
const expected = await redis.getdel(`webauthn:${sessionId}`); // одноразовість!
if (!expected) throw new Error('challenge expired');
const cred = await db.credentials.findById(response.credentialId);
if (!cred) throw new Error('unknown credential');
// Бібліотека перевіряє: підпис, challenge, origin, rpId, signCount
const ok = await verifyAssertion(response, {
expectedChallenge: expected,
expectedOrigin: 'https://cookiesoftware.io',
expectedRpId: 'cookiesoftware.io',
publicKey: cred.publicKey,
previousSignCount: cred.signCount,
});
if (!ok.verified) throw new Error('invalid assertion');
await db.credentials.updateSignCount(cred.credentialId, ok.newSignCount);
return createSession(cred.userId); // далі – звичайні сесії/токени
}Після верифікації починається звичайна сесійна механіка – тут усе як у статті про access/refresh-токени: passkey замінює пароль, а не сесії.
Чому фішинг ламається
Це головна причина існування WebAuthn, і вона архітектурна, а не поведінкова. Підпис обчислюється над challenge разом із даними origin, і браузер не дозволить викликати credential для чужого RP ID. Користувач може скільки завгодно вірити сайту cookiesoftware-login.evil – пристрій або не знайде credential для цього домену взагалі, або підпис не пройде перевірку на справжньому сервері. Переконувати людей «дивитись уважно на адресний рядок» більше не потрібно – і це рідкісний випадок, коли безпеку перенесли з тренінгів у протокол.
Чесні межі: WebAuthn не захищає від компрометації самого пристрою, від зловмисного розширення в браузері та від session hijacking після входу. І додає нову проблему – recovery.
Fallback і recovery – місце, де ламаються найкращі наміри
Якщо єдиний passkey жив у загубленому телефоні, у тебе інцидент підтримки. Робочий мінімум:
- заохочуй кілька credentials на акаунт (телефон + ноутбук + апаратний ключ);
- синхронізовані passkeys (екосистемні менеджери) закривають більшість побутових втрат;
- recovery-канал (email magic link тощо) – усвідомлений компроміс: він стає найслабшою ланкою твого auth, проєктуй його з тим же рівнем паранойї.
Про розподіл цієї логіки по системі добре думається у форматі frontend system design: стани, помилки, деградація – тут усе це в повний зріст.
Типові помилки
- Challenge генерується на клієнті або перевіряється «на око». Challenge – власність сервера: одноразовий, з TTL, видається і звіряється тільки там.
- Пропущена перевірка origin/RP ID у verification – дірка, що зводить нанівець усю phishing resistance.
- Самописний парсинг attestation/assertion. CBOR, COSE, авторські формати автентифікаторів – використовуй перевірену бібліотеку.
- Один credential без recovery-плану. Технічно все правильно – організаційно інцидент.
- Ігнорування signCount. Лічильник, що не зріс, – сигнал клонованого автентифікатора; мовчки пропускати його не можна.
Практичне завдання
Склади для свого (або уявного) продукту таблицю з трьох колонок: «крок флоу», «хто виконує (frontend/backend/authenticator)», «що станеться, якщо цей крок скомпрометовано». Пройди обидва флоу – registration і login. Якщо для якогось рядка відповідь у третій колонці – «нічого страшного», перевір себе: найчастіше це означає, що ти пропустив перевірку, а не що її не потрібно.