Содержание
Пароль по-прежнему главный способ «войти на сайт» — и главный способ украсть аккаунт. Фишинг, повторное использование, утечки баз, усталость от одноразовых кодов. Passkeys (ключи доступа) обещают другой контракт: пользователь подтверждает вход отпечатком, лицом или PIN устройства, а сервер проверяет криптографическую подпись, а не строку, которую можно переслать злоумышленнику.
В 2026 году тема уже не эксперимент. По отчётам отрасли ключи доступа раскатывают или пилотируют в большинстве крупных организаций, а в «дикой» сети поддержка растёт через собственные реализации и через провайдеров входа. На этом сайте до сих пор не было отдельного разбора: есть ошибки JWT в Node.js, есть укрепление цепочки npm, но нет карты «как сделать WebAuthn так, чтобы он пережил прод». Ниже — инженерный столп: церемонии браузера, обязанности сервера, синхронизированные и привязанные ключи, восстановление, миграция с пароля и типичные провалы.
Ключевые выводы
Passkey — это учётная запись на стороне аутентификатора, а не «ещё один пароль в менеджере». На сервере хранят открытый ключ и метаданные, никогда — секрет, которым подписывают вход.
Без серверной проверки challenge, origin и rpId ключ доступа бесполезен. Браузер показывает красивый диалог; безопасность начинается в библиотеке 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 — закрытый ключ не уезжает с железа. Строже для регулируемых контуров, больнее при поломке устройства.
Две церемонии: регистрация и вход
Регистрация
- Пользователь уже как-то доказал, что это его аккаунт (сессия после пароля, письмо, приглашение).
- Сервер генерирует случайный
challenge(≥16 байт), сохраняет его у себя с короткимTTLи отдаётPublicKeyCredentialCreationOptions:rp,user,challenge,pubKeyCredParams,authenticatorSelection,attestation. - Браузер вызывает
navigator.credentials.create({ publicKey: options }). - Пользователь подтверждает на устройстве.
- Клиент возвращает объект аттестации /
credentials. Сервер проверяетchallenge,origin,rpId, флаги, сохраняетcredentialId, открытый ключ,transports, счётчик. Challengeудаляют — и при успехе, и при ошибке. Повторное использование того жеchallengeзапрещено.
Вход
- Сервер снова выдаёт свежий
challengeиPublicKeyCredentialRequestOptions. Дляdiscoverable credentialsполеallowCredentialsможет быть пустым массивом — аутентификатор сам предложит подходящие ключи. - Браузер вызывает
navigator.credentials.get. - Пользователь подтверждает.
- Сервер находит
credentialпо id, проверяет подписьchallenge,origin,rpId, флаги UP/UV, при необходимости счётчик, затем создаёт сессию приложения. Challengeснова одноразовый.
На Node.js и в других стеках не собирайте проверку «с нуля из MDN». Берите поддерживаемую FIDO-библиотеку (@simplewebauthn/server и аналоги). Спецификация длинная, углы острые, обновления приходят пакетом. Библиотека не снимает с вас обязанности правильно задать rpId и origin — она снимает риск неправильно разобрать CBOR.
Что обязан сделать сервер (и что нельзя доверить клиенту)
Клиент может прислать что угодно. Доверять можно только тому, что вы перепроверили.
Минимальный контур проверки на входе:
Challengeсуществует, не истёк, не использован, привязан к этой попытке.originответа совпадает с ожидаемым (https://app.example.com), без «похожих» доменов.rpIdсовпадает с тем, под которым регистрировали.Credentialизвестен и принадлежит пользователю (или вы явно поддерживаете вход без логина и ищете поcredentialIdглобально — тогда особенно важны лимит частоты и антиперебор).- Подпись верна для сохранённого открытого ключа.
- Флаг присутствия пользователя (
UP) включён. Если вы требовалиuserVerification: "required", флаг UV тоже должен быть истинным. Уровень UV, который вы запросили, храните в серверной записи попытки, а не читайте с клиента. - Счётчик подписей (
signCount), если аутентификатор его ведёт: новое значение не должно быть меньше предыдущего. Падение счётчика — сигнал клонирования ключа; политика обычно — насторожить или отозвать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, отключили пароль, не продумали потерю телефона. Пользователь уезжает в отпуск без запасного входа — и поддержка заводит «временный пароль» вручную, открывая дыру шире, чем была.
Рабочие стратегии (их можно комбинировать):
- Несколько ключей на аккаунт. Второй телефон, аппаратный ключ, ключ на рабочем ноутбуке. Интерфейс должен просить добавить запасной, а не прятать это в настройках.
- Восстановление через уже доказанный канал. Письмо на подтверждённый адрес с одноразовой ссылкой, которая позволяет только зарегистрировать новый ключ, а не получить полную сессию без ограничений.
- Аппаратный восстановление-код (набор одноразовых строк), показанный один раз при включении безпарольного режима. Хранить хеши, как пароли.
rate limitжёсткий. - Сохранённый пароль как временный запасной путь на переходный период — с явным планом выключить и с антифишинговыми сигналами (не просить пароль на каждой странице).
- Поддержка с ритуалом. Видеозвонок, проверка документов, задержка выдачи — дорого, но для банков и админок иногда неизбежно. Главное: поддержка не должна иметь кнопку «сбросить на пароль
qwerty».
Отдельно про Signal API в новых браузерах: сервер может подсказывать аутентификатору, что credential неизвестен или что список принятых ключей изменился. Это синхронизация UX («не предлагай удалённый ключ»), а не замена вашей базы. Если браузер API не поддерживает — деградируйте мягко.
Миграция с пароля: как не устроить себе второй инцидент
Резко выключить пароли почти никто не может. Типичная лестница:
- Добавить ключ доступа рядом с паролем. После обычного входа — мягкий запрос «создать ключ на этом устройстве».
- Сделать ключ предпочтительным. На экране входа сверху —
passkey/Conditional UI, пароль — ссылкой «другой способ». - Сузить пароль. Запретить пароль для админов. Требовать ключ для чувствительных действий (смена почты, вывод средств) даже если сессия уже есть — повторной проверки.
- Выключить пароль для сегментов, где метрики показывают устойчивость: есть ≥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: [], // discoverable
},
mediation: 'optional', // Conditional UI where supported
})
await fetch('/auth/webauthn/login/verify', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(assertion),
})
Проверяйте наличие API и возможностей. На старых браузерах показывайте запасной путь, а не пустой экран. Не логируйте attestation целиком в открытые логи — там много бинарного шума и иногда лишние идентификаторы.
Типичные ошибки в продакшене
- Неверный
rpId. Зарегистрировали наwww.example.com, проверяете наexample.com— или наоборот. Ключи «внезапно» не находятся. Challengeв памяти одной ноды без привязки сессии. Пользователь получил параметры с инстанса A, аverifyпришёл на B. Нужно общее хранилище (Redis и т.п.) сTTL.ChallengeбезTTLи без одноразовости. Повторная проигрываемость — подарок для атак.- Доверие к
userVerificationс клиента. Храните требуемый уровень на сервере в записи попытки. - Один ключ на аккаунт и пароль выключен. Потеря устройства = потеря клиента.
Passkey«успешен», сессия вечная вlocalStorage. Вернитесь к короткому токену доступа и ротацииrefresh.- Игнор счётчика там, где аутентификатор его отдаёт. Клон ключа останется незамеченным.
- Смешение стенда и продакшена
rpId. Ключи с локальногоlocalhostне переносятся — и не должны. Для локальной разработки настройте отдельный контур, не «временно отключим проверкуorigin». - Слабый лимит частоты на входе без логина. Перебор
credentialId/ шум поchallengeдолжен упираться в лимиты и мониторинг. Attestation: requiredбез причины. Ломаете пользовательские менеджеры ключей и синхронизацию ради галочки, которую не проверяете политикой.
Как тестировать
Ручной сценарий минимум:
- Зарегистрировать ключ в чистом профиле браузера.
- Выйти, войти только ключом.
- Отозвать ключ в
UI, убедиться, что вход им больше не проходит иSignal(если есть) не предлагает его. - Добавить второй ключ, удалить первый — вход жив.
- Прогнать просроченный
challengeи повторно использованныйchallenge— оба должны получить отказ. - Повторить на втором браузере / телефоне для
synced-сценария. - Для
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-стека, элемент install для PWA. Внешние опоры: гиды Google по серверным ключам доступа и материалы альянса FIDO на сайте каталога библиотек.
Заключение
Passkeys переносят секрет с «строки, которую человек носит в голове и вводит в формы» на «закрытый ключ, который не покидает аутентификатор». WebAuthn — это церемония браузера плюс скучная, обязательная проверка на сервере. Продуктовая зрелость начинается там, где заканчивается демо: второй ключ, восстановление, правильный rpId, одноразовый challenge, честная миграция с пароля и сессия после входа без вечного токена в localStorage.
Если строить одним предложением: сначала докажите личность подписью, потом выдайте короткую сессию, всегда оставьте путь назад без унижения поддержки. Это скучнее рекламных роликов про «мир без паролей», зато переживает первый потерянный телефон.
Четыре вопроса к вашему контуру. Где лежит закрытый ключ? На аутентификаторе, не у вас. Что вы проверяете на verify? Challenge, origin, rpId, подпись, флаги. Что будет, если ключ один и телефон утонул? Заранее написанное восстановление. Что получает браузер после успеха? Сессия с конечным сроком, а не новый «вечный пароль» в другом формате.



Комментарии