← Усі статті

Passkeys і WebAuthn у продакшені: вхід без пароля, який не ламає відновлення

Як влаштувати реєстрацію і вхід за ключем доступу: церемонія WebAuthn, ідентифікатор довіри, challenge, синхронізовані та прив’язані до пристрою ключі, відновлення акаунта і типові дірки на сервері.

Passkeys і WebAuthn у продакшені: вхід без пароля, який не ламає відновлення
Зміст

Пароль досі головний спосіб «увійти на сайт» — і головний спосіб викрасти акаунт. Фішинг, повторне використання, витоки баз, втома від одноразових кодів. Passkeys (ключі доступу) обіцяють інший контракт: користувач підтверджує вхід відбитком, обличчям або PIN пристрою, а сервер перевіряє криптографічний підпис, а не рядок, який можна переслати зловмиснику.

У 2026 році тема вже не експеримент. За галузевими звітами ключі доступу розгортають або пілотують у більшості великих організацій, а в «дикій» мережі підтримка зростає через власні реалізації і через провайдерів входу. На цьому сайті досі не було окремого розбору: є помилки JWT у Node.js, є зміцнення ланцюжка npm, але немає мапи «як зробити WebAuthn так, щоб він пережив прод». Нижче — інженерний стовп: церемонії браузера, обов’язки сервера, синхронізовані й прив’язані ключі, відновлення, міграція з пароля і типові провали.

Ключові висновки

Passkey — це обліковий запис на боці автентифікатора, а не «ще один пароль у менеджері». На сервері зберігають відкритий ключ і метадані, ніколи — секрет, яким підписують вхід.

Без серверної перевірки challenge, origin і RP ID ключ доступу марний. Браузер показує гарний діалог; безпека починається в бібліотеці FIDO на бекенді.

Синхронізований ключ і ключ, прив’язаний до пристрою, розв’язують різні задачі. Перший зручний для людей і переживає зміну телефона. Другий потрібен там, де політика вимагає «цей ноутбук, цей токен».

Відновлення акаунта — частина продукту, не «потім». Втратити єдиний ключ без запасного шляху означає втратити клієнта. Пароль як єдиний запасний шлях знову відкриває фішинг.

Після успішного WebAuthn ви зазвичай усе одно видаєте сесію. Ключ доступу замінює доказ особи на вході. Далі живуть cookie, refresh і короткі токени доступу — з дисципліною із сусіднього розбору про JWT.

Чим ключ доступу відрізняється від пароля й одноразового коду

Пароль — спільний секрет. Його знають користувач і сервер (в ідеалі — лише хеш на сервері). Його можна виманити формою на схожому домені, підгледіти, витягнути з витоку, підібрати. Одноразовий код з SMS або застосунку підсилює другий фактор, але код теж можна перехопити або вмовити віддати. Обидва механізми спираються на те, що людина передає секрет каналом, який атакувальник уміє імітувати.

Ключ доступу будується на парі ключів. Закритий ключ не покидає автентифікатор: Secure Enclave, TPM, модуль телефона, апаратний ключ. Відкритий ключ лежить у вас у базі. Під час входу сервер видає одноразовий challenge. Автентифікатор підписує його лише для вашого сайту (точніше — для зареєстрованого ідентифікатора довіри) і лише після локального підтвердження користувача. Підпис не можна «ввести» на підробленій сторінці: браузер не віддасть облікові дані чужому origin.

Практичний сенс для продукту:

Механізм Що крадуть Стійкість до фішингу Втома користувача
Пароль Рядок Низька Висока
Пароль + SMS Рядок і код Середня Висока
TOTP Рядок і код Середня Середня
Passkey Потрібен доступ до пристрою і біометрії/PIN Висока Низька на звичному пристрої

«Висока стійкість до фішингу» не означає «неможливо відібрати акаунт». Викрадений ноутбук із розблокованою сесією, соціальна інженерія служби підтримки, слабке відновлення — усе це лишається. Але класичний сценарій «форма на evil-login.example» перестає працювати так, як із паролем.

Словник, без якого специфікація читається як шум

WebAuthn — веб-API (navigator.credentials.create / get) і модель перевірки на сервері. Це мова браузера.

FIDO2 / CTAP — як автентифікатор спілкується з платформою. Вам рідко потрібно чіпати CTAP руками, якщо ви пишете звичайний сайт.

Relying Party (RP) — ваш сервіс. У нього є rpId: зазвичай домен без схеми і порту, наприклад example.com. Ключ, створений для example.com, не спрацює на evil.com.

Authenticator — те, що зберігає закритий ключ: платформа (телефон, ноутбук) або зовнішній ключ (USB/NFC/BLE).

Credential — конкретна пара: credentialId + відкритий ключ + лічильник/прапорці + прив’язка до користувача.

Discoverable credential (resident key) — облікові дані, які автентифікатор може запропонувати без попереднього введення логіна. На цьому стоїть «увійти з ключем доступу» однією кнопкою і Conditional UI.

User verification (UV) — локальна перевірка людини: біометрія або PIN. Прапорець у відповіді показує, чи була вона.

Attestation — доказ моделі автентифікатора. У споживчих продуктах її часто не вимагають: достатньо перевірки підпису. У корпоративних сценаріях attestation іноді потрібна для білого списку пристроїв.

Синхронізований passkey — закритий ключ копіюється екосистемою (iCloud Keychain, Google Password Manager, менеджер паролів). Зручно під час зміни телефона.

Device-bound passkey — закритий ключ не їде із заліза. Суворіше для регульованих контурів, болючіше при поломці пристрою.

Дві церемонії: реєстрація і вхід

Реєстрація

  1. Користувач уже якось довів, що це його акаунт (сесія після пароля, лист, запрошення).
  2. Сервер генерує випадковий challenge (≥16 байт), зберігає його в себе з коротким TTL і віддає PublicKeyCredentialCreationOptions: rp, user, challenge, pubKeyCredParams, authenticatorSelection, attestation.
  3. Браузер викликає navigator.credentials.create({ publicKey: options }).
  4. Користувач підтверджує на пристрої.
  5. Клієнт повертає облікові дані. Сервер перевіряє challenge, origin, rpId, прапорці, зберігає credentialId, відкритий ключ, transports, лічильник.
  6. Challenge видаляють — і при успіху, і при помилці. Повторне використання того самого challenge заборонено.

Вхід

  1. Сервер знову видає свіжий challenge і PublicKeyCredentialRequestOptions. Для discoverable credentials allowCredentials може бути порожнім масивом — автентифікатор сам запропонує відповідні ключі.
  2. Браузер викликає navigator.credentials.get.
  3. Користувач підтверджує.
  4. Сервер знаходить credential за id, перевіряє підпис challenge, origin, rpId, прапорці UP/UV, за потреби лічильник, потім створює сесію застосунку.
  5. Challenge знову одноразовий.

На Node.js і в інших стеках не збирайте перевірку «з нуля з MDN». Беріть підтримувану FIDO-бібліотеку (@simplewebauthn/server і аналоги). Специфікація довга, кути гострі, оновлення приходять пакетом. Бібліотека не знімає з вас обов’язку правильно задати rpId і origin — вона знімає ризик неправильно розібрати CBOR.

Що зобов’язаний зробити сервер (і що не можна довірити клієнту)

Клієнт може надіслати будь-що. Довіряти можна лише тому, що ви переперевірили.

Мінімальний контур перевірки на вході:

  1. Challenge існує, не прострочений, не використаний, прив’язаний до цієї спроби.
  2. origin відповіді збігається з очікуваним (https://app.example.com), без «схожих» доменів.
  3. rpId збігається з тим, під яким реєстрували.
  4. Credential відомий і належить користувачеві (або ви явно підтримуєте входу без логіна і шукаєте за credentialId глобально — тоді особливо важливі ліміт частоти і антиперебір).
  5. Підпис правильний для збереженого відкритого ключа.
  6. Прапорець присутності користувача (UP) увімкнений. Якщо ви вимагали userVerification: "required", прапорець UV теж має бути істинним. Рівень UV, який ви запросили, зберігайте в серверному записі спроби, а не читайте з клієнта.
  7. Лічильник (лічильника підписів), якщо автентифікатор його веде: нове значення не повинно бути меншим за попереднє. Падіння лічильника — сигнал клонування ключа; політика зазвичай — насторожити або відкликати credential.

Окремо: не приймайте alg: none і саморобні «спрощення» навколо JWT сюди не тягніть. Після WebAuthn ви створюєте сесію. Як саме ви підпишете токен доступу — уже історія з розбору JWT. Плутати «довли passkey» і «видали вічний JWT у localStorage» — часта коротка дорога до інциденту.

Синхронізовані й прив’язані ключі: продуктовий вибір

Синхронізований ключ переживає зміну телефона всередині екосистеми. Для споживчого SaaS це зазвичай правильний значення за замовчуванням: менше тікетів «не можу увійти з нового iPhone». Ціна — довіра до хмари вендора і до того, що акаунт Apple/Google теж захищений.

Ключ, прив’язаний до пристрою, не їде. Його люблять корпоративні політики, регульовані контури, адмінські панелі, де «цей YubiKey» важливіший за зручність. Ціна — обов’язковий другий фактор відновлення і навчання людей не губити єдиний токен.

На практиці зрілі розгортання часто змішують обидва типи: synced для звичайних користувачів, Device-bound для привілейованих ролей. Це збігається з галузевими опитуваннями 2026 року: половина організацій, які впроваджують ключі, не ставить на один-єдиний тип.

authenticatorSelection і вимоги resident key задають тонкість поведінки. Для «кнопки увійти без логіна» потрібні discoverable credentials. Для сценарію «спочатку логін, потім підтверджуємо відомі ключі» можна передавати allowCredentials зі списком id. Conditional UI (автозаповнення поля логіна ключами) спирається на discoverable credentials і підтримку браузера — перевіряйте можливості через PublicKeyCredential.getClientCapabilities, де вона є.

Відновлення: місце, де вмирають «ідеальні» впровадження

Найчастіший провал продукту: увімкнули passkeys, вимкнули пароль, не продумали втрату телефона. Користувач їде у відпустку без запасного входу — і підтримка заводить «тимчасовий пароль» вручну, відкриваючи діру ширшу, ніж була.

Робочі стратегії (їх можна комбінувати):

  1. Кілька ключів на акаунт. Другий телефон, апаратний ключ, ключ на робочому ноутбуці. Інтерфейс має просити додати запасний, а не ховати це в налаштуваннях.
  2. Відновлення через уже доведений канал. Лист на підтверджену адресу з одноразовим посиланням, яке дозволяє лише зареєструвати новий ключ, а не отримати повну сесію без обмежень.
  3. Апаратний відновлення-код (набір одноразових рядків), показаний один раз під час увімкнення безпарольного режиму. Зберігати хеші, як паролі. rate limit жорсткий.
  4. Збережений пароль як тимчасовий запасний шлях на перехідний період — з явним планом вимкнути і з антифішинговими сигналами (не просити пароль на кожній сторінці).
  5. Підтримка з ритуалом. Відеодзвінок, перевірка документів, затримка видачі — дорого, але для банків і адмінок іноді неминуче. Головне: підтримка не повинна мати кнопку «скинути на пароль qwerty».

Окремо про Signal API в нових браузерах: сервер може підказувати автентифікатору, що credential невідомий або що список прийнятих ключів змінився. Це синхронізація UX («не пропонуй видалений ключ»), а не заміна вашої бази. Якщо браузер API не підтримує — деградуйте м’яко.

Міграція з пароля: як не влаштувати собі другий інцидент

Різко вимкнути паролі майже ніхто не може. Типові сходи:

  1. Додати ключ доступу поруч із паролем. Після звичайного входу — м’який запит «створити ключ на цьому пристрої».
  2. Зробити ключ пріоритетним. На екрані входу зверху — passkey / Conditional UI, пароль — посиланням «інший спосіб».
  3. Звузити пароль. Заборонити пароль для адмінів. Вимагати ключ для чутливих дій (зміна пошти, вивід коштів) навіть якщо сесія вже є — повторної перевірки.
  4. Вимкнути пароль для сегментів, де метрики показують стійкість: є ≥2 ключі, є відновлення, підтримка навчена.

Не змішуйте «обов’язковий passkey» із «ми видалили всі інші фактори» в один реліз. І не зберігайте пароль «про всяк випадок» у відкритому вигляді в тікетах підтримки.

Для корпоративного SSO картина інша: часто ключ доступу живе в провайдера особистості (Google, Microsoft, Okta), а ваш застосунок продовжує приймати OIDC. Тоді ваш код WebAuthn може взагалі не знадобитися — але вам усе одно потрібні повторної перевірки і розуміння, Device-bound чи ключ у IdP. Перекладання на IdP не скасовує загрози поганого відновлення в провайдера.

Клієнтський контур на TypeScript (ескіз)

Нижче — не копіпаста «вставте в прод», а каркас відповідальності. Параметри завжди приходять із сервера.

// registration
const options = await fetch('/auth/webauthn/register/options', {
  method: 'POST',
  credentials: 'include',
}).then((r) => r.json())

const credential = await navigator.credentials.create({
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON
    ? PublicKeyCredential.parseCreationOptionsFromJSON(options)
    : options,
})

await fetch('/auth/webauthn/register/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(credential),
})
// authentication (discoverable)
const options = await fetch('/auth/webauthn/login/options', {
  method: 'POST',
  credentials: 'include',
}).then((r) => r.json())

const assertion = await navigator.credentials.get({
  publicKey: {
    ...options,
    allowCredentials: [],
  },
  mediation: 'optional',
})

await fetch('/auth/webauthn/login/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(assertion),
})

Перевіряйте наявність API і можливостей. На старих браузерах показуйте запасний шлях, а не порожній екран. Не логуйте attestation цілком у відкриті логи — там багато бінарного шуму і іноді зайві ідентифікатори.

Типові помилки в продакшені

  1. Неправильний rpId. Зареєстрували на www.example.com, перевіряєте на example.com — або навпаки. Ключі «раптом» не знаходяться.
  2. Challenge у пам’яті однієї ноди без прив’язки сесії. Користувач отримав параметри з інстансу A, а verify прийшов на B. Потрібне спільне сховище (Redis тощо) з TTL.
  3. Challenge без TTL і без одноразовості. Повторна програваємість — подарунок для атак.
  4. Довіра до userVerification з клієнта. Зберігайте потрібний рівень на сервері в записі спроби.
  5. Один ключ на акаунт і пароль вимкнено. Втрата пристрою = втрата клієнта.
  6. Passkey «успішний», сесія вічна в localStorage. Поверніться до короткого токена доступу і ротації refresh.
  7. Ігнор лічильника там, де автентифікатор його віддає. Клон ключа лишиться непоміченим.
  8. Змішування стенда і продакшену rpId. Ключі з локального localhost не переносяться — і не повинні. Для локальної розробки налаштуйте окремий контур, не «тимчасово вимкнемо перевірку origin».
  9. Слабкий ліміт частоти на вході без логіна. Перебір credentialId / шум по challenge має впиратися в ліміти і моніторинг.
  10. Attestation «обов’язковим» без причини. Ламаєте користувацькі менеджери ключів і синхронізацію заради галочки, яку не перевіряєте політикою.

Як тестувати

Ручний сценарій мінімум:

  1. Зареєструвати ключ у чистому профілі браузера.
  2. Вийти, увійти лише ключем.
  3. Відкликати ключ у UI, переконатися, що вхід ним більше не проходить і Signal (якщо є) не пропонує його.
  4. Додати другий ключ, видалити перший — вхід живий.
  5. Прогнати прострочений challenge і повторно використаний challenge — обидва мають отримати відмову.
  6. Повторити на другому браузері / телефоні для synced-сценарію.
  7. Для Device-bound — переконатися, що ключ не з’явився на другому пристрої.

Автоматизація можлива через віртуальні автентифікатори в Chrome DevTools Protocol і Playwright. Це не замінює перевірку на реальному телефоні, але ловить регресії options/verify. Окремо ганяйте мульти-інстанс verify: параметри на одній ноді, перевірка на іншій.

Де ключі доступу особливо доречні

  • Споживчі кабінети з частим входом із телефона.
  • Адмінки й внутрішні панелі (часто Device-bound + апаратний ключ).
  • Повторна перевірка перед грошима, зміною пошти, вивантаженням даних.
  • Польові застосунки, де пароль на чужій клавіатурі — окремий ризик; тут же дивіться зв’язку з offline-first записом — сесія і локальна черга ортогональні, але UX входу спільний.

Де обережніше: аудиторії без смартфонів, кіоски, спільні комп’ютери бібліотек, регіони з жорстко обмеженими екосистемами. Там passkeys — доповнення, не єдині двері.

Часті питання

Passkeys повністю замінюють паролі вже зараз?

Технічно — так, для сегмента користувачів із ключем і відновлення. Організаційно майже завжди потрібен перехідний період. Вимикайте пароль, коли метрики і підтримка готові, а не коли написали нотатку в блозі.

Чи потрібно мені реалізовувати WebAuthn, якщо є вхід через Google / Apple?

Не обов’язково. Якщо IdP уже дає вам сильний фактор, зосередьтеся на коректній OIDC-сесії і повторної перевірки. Власний WebAuthn потрібен, коли ви тримаєте ідентичність у себе або хочете ключ саме для вашого RP.

Чим passkey відрізняється від «увійти через Windows Hello», який ми бачили раніше?

Часто це той самий автентифікатор платформи. Різниця в тому, чи прив’язаний credential до вашого rpId через WebAuthn і чи перевіряєте ви підпис на своєму сервері, а не лише локальний розблокувальник ОС.

Що зберігати в базі?

credentialId, відкритий ключ (або COSE), алгоритм, transports, signCount, дату створення, дружнє ім’я («iPhone Марії»), тип (synced / unknown / device-bound, якщо знаєте), userId. Секрет користувача не зберігається.

Чи можна використовувати ключ доступу для API-токенів сервісів?

Не як довгоживучий секрет у CI. Passkey — інтерактивний фактор людини. Для машин — окремі облікові дані клієнта, ключі підпису CI, OIDC ідентичність навантаження. Не змішуйте.

Як зв’язати це з JWT?

WebAuthn відповідає на питання «це та людина на вході?». JWT/сесія cookie відповідають на питання «цей браузер уже увійшов на найближчі N хвилин?». Після verify видайте короткоживучий токен доступу і керований refresh — див. помилки JWT.

Що почитати далі

Суміжні матеріали з безпеки і клієнтського контуру: JWT у Node.js, зміцнення npm-ланцюжка, offline-first запис у React, мапа стека React, елемент install для PWA. Зовнішні опори: гіди Google щодо серверних ключів доступу і матеріали альянсу FIDO на сайті каталогу бібліотек.

Висновок

Passkeys переносять секрет із «рядка, який людина носить у голові і вводить у форми» на «закритий ключ, який не покидає автентифікатор». WebAuthn — це церемонія браузера плюс нудна, обов’язкова перевірка на сервері. Продуктова зрілість починається там, де закінчується демо: другий ключ, відновлення, правильний rpId, одноразовий challenge, чесна міграція з пароля і сесія після входу без вічного токена в localStorage.

Якщо будувати одним реченням: спочатку доведіть особу підписом, потім видайте коротку сесію, завжди лишіть шлях назад без приниження підтримки. Це нудніше за рекламні ролики про «світ без паролів», зато переживає перший загублений телефон.

Чотири питання до вашого контуру. Де лежить закритий ключ? На автентифікаторі, не у вас. Що ви перевіряєте на перевірку? Challenge, origin, rpId, підпис, прапорці. Що буде, якщо ключ один і телефон втонув? Заздалегідь написаний відновлення. Що отримує браузер після успіху? Сесія з кінцевим строком, а не новий «вічний пароль» в іншому форматі.

Коментарі

Завантаження коментарів…