Коротка відповідь. 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 до запису в базі

Покроково:

  1. Сервер генерує challenge – мінімум 16 байт випадковості – і зберігає його з прив'язкою до сесії/користувача з коротким TTL.
  2. Frontend викликає credentials.create з цим challenge, rp.id (домен), даними користувача і списком алгоритмів.
  3. Автентифікатор просить підтвердження (біометрія/PIN), генерує пару ключів і повертає PublicKeyCredential: credential ID, публічний ключ, attestation.
  4. 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: підпис замість пароля

  1. Сервер видає новий challenge (одноразовий, із TTL).
  2. Frontend викликає credentials.get; для passkeys (discoverable credentials) можна навіть не передавати allowCredentials – пристрій сам покаже доступні акаунти, а з mediation: 'conditional' це працює як автозаповнення у полі логіна.
  3. Автентифікатор підписує challenge + дані origin приватним ключем.
  4. 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: стани, помилки, деградація – тут усе це в повний зріст.

Типові помилки

  1. Challenge генерується на клієнті або перевіряється «на око». Challenge – власність сервера: одноразовий, з TTL, видається і звіряється тільки там.
  2. Пропущена перевірка origin/RP ID у verification – дірка, що зводить нанівець усю phishing resistance.
  3. Самописний парсинг attestation/assertion. CBOR, COSE, авторські формати автентифікаторів – використовуй перевірену бібліотеку.
  4. Один credential без recovery-плану. Технічно все правильно – організаційно інцидент.
  5. Ігнорування signCount. Лічильник, що не зріс, – сигнал клонованого автентифікатора; мовчки пропускати його не можна.

Практичне завдання

Склади для свого (або уявного) продукту таблицю з трьох колонок: «крок флоу», «хто виконує (frontend/backend/authenticator)», «що станеться, якщо цей крок скомпрометовано». Пройди обидва флоу – registration і login. Якщо для якогось рядка відповідь у третій колонці – «нічого страшного», перевір себе: найчастіше це означає, що ти пропустив перевірку, а не що її не потрібно.