# Passkeys і WebAuthn: як працює вхід без пароля і що робить frontend, а що backend

> WebAuthn від challenge до verification: registration і login флоу покроково, що зберігає сервер, TypeScript-приклад із navigator.credentials і чесні межі phishing resistance.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: Backend і System Design
- Canonical: https://cookiesoftware.io/blog/passkeys-webauthn-typescript

---

**Коротка відповідь.** 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):

```ts
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)),
  });
}
```

Що сервер кладе в базу після успішної перевірки – і це все, жодних секретів:

```ts
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 це класичне місце вразливостей):

```ts
// 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-токени](/blog/access-refresh-tokens-nodejs): 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](/blog/frontend-system-design-interview): стани, помилки, деградація – тут усе це в повний зріст.

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

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. Якщо для якогось рядка відповідь у третій колонці – «нічого страшного», перевір себе: найчастіше це означає, що ти пропустив перевірку, а не що її не потрібно.
