Это финальная, пятая статья серии. Четыре предыдущие доказывали, что passkey криптографически прочен: секрета нет (№1), серверная верификация держит origin/RP ID/UV (№2), экосистемные барьеры проходимы (№3), мобильные API переносят ту же церемонию (№4). И именно поэтому здесь будет неприятный вывод, который ломает наивную картину миграции: проходит она или проваливается — решает не криптография, а восстановление (recovery). Когда сам вход стал нефишингуемым, весь вектор атаки и все потерянные аккаунты переезжают в самый слабый способ входа, который вы оставили рядом, — в сброс пароля по email и в «код на телефон».
Главный честный тезис: единственная метрика, по которой судят миграцию, — не «% включивших passkey», а lockout-rate и доля проваленных восстановлений. Процент регистрации — vanity-метрика: она растёт, пока всё хорошо, и продолжает расти, пока сотни пользователей уже не могут войти. Пользователь с потерянным устройством и незакрытым recovery-путём — это потерянный аккаунт независимо от того, что ключ не украсть. Миграция, у которой recovery спроектирован после принуждения, а не до, не «медленная» — она создаёт инциденты и тикеты, и к моменту «мы включили всем» часть базы уже не входит.
Оговорка о цифрах. Часть цифр в статье — из отчётов альянсов и вендоров (FIDO, Microsoft, Corbado, Mojoauth): их давать как контекст масштаба и направления, а не как доказательство, что «у вас будет так же»; у своего продукта вы меряете своими метриками. Номера версий/статусов (NIST-редакция, поддержка Credential Exchange) — поле живое, переснимайте в день внедрения.
php -l/рантайм прогоняйте локально: линтера PHP в этой среде нет, скетчи ниже — форма решения, а не прод-код.
Часть I. Почему ломается именно восстановление, а не подпись
Сместите фокус с того, что все обсуждают, на то, из-за чего всё тонет.
Асимметрия атаки после внедрения. Пока пароль — самый слабый фактор, фишинг бьёт в пароль. Вы включили passkey: вход стал структурно нефишингуемым (№1, №2 — аутентификатор не подпишет за чужой origin). Атакующий не «ломает» passkey — он обходит его через то, что вы оставили: через сброс пароля, «не пришёл код», «потерял телефон, восстановите аккаунт», саппорт-соци-инжиниринг в службу поддержки. Чем прочнее основной вход, тем ценнее станов recovery как точка входа — это буквально следует из того, что №2 предупреждала про fallback: AitM работает не по крипте, а по оставленному слабому методу.
Асимметрия доступности. У synced-ключа (№4) есть свой парадокс: он синхронизируется под аккаунтом менеджера, поэтому «потерял телефон» для пользователя iCloud/Google обычно решается новым телефоном под тем же аккаунтом. Но не у всех synced: device-bound-ключ (roaming YubiKey, часть корпоративных политик, Apple-устройства без общей синхронизации), потеря/сброс устройства, смена e-mail-провайдера, выход из аккаунта синхронизации, ротация signing-ключа приложения (№4) — и человек без recovery-пути снаружи. Это и есть lockout. Он не шифро-проблема, это проблема проектирования жизненного цикла учётки.
Корень: recovery проектируют постфактум. Типичная ошибка rollout: сначала «включаем всем passkeys — это же безопаснее!», потом через месяц всплывает очередь «не могу войти, потерял телефон» и «меня заблокировали». К этому моменту принуждение уже включено, и каждый такой тикет — потерянный пользователь и потенциально угнанный аккаунт (если саппорт «по доброте» открывает дверь email-подтверждением). Правильный порядок обратный: сначала работающий, измеренный recovery-путь, потом расширение passkey-принуждения. Это и есть тема всей этой статьи, завязанная на то, что вы уже знаете из №1–№4.
<?php
// sketch: порядок зрелости аккаунта. Не «есть ли passkey», а «есть ли ЗАКРЫТЫЙ recovery».
// Принуждаем следующий шаг только когда предыдущий условие истинно.
function can_enforce_passkey_primary(array $acct): array {
$reasons = [];
if ((int)$acct['passkey_count'] < 1)
$reasons[] = 'нет ни одного passkey';
if (!has_independent_recovery($acct)) // 2-й фактор НЕ из того же аккаунта синхронизации
$reasons[] = 'recovery не закрыт (не считаем synced-ключ своим же recovery)';
if (!$acct['recovery_verified_at']) // recovery-путь проверен вживую хотя бы раз
$reasons[] = 'recovery не подтверждён (никогда не использовался)';
return ['ready' => empty($reasons), 'blockers' => $reasons];
}Обратите внимание на тонкость в коде: synced-ключ не считается своим же recovery. Если оба ваших «способа» зависят от одного Apple ID / Google-аккаунта, то потеря этого аккаунта или вход в него злоумышленника роняют оба — это не независимый путь. Независимый recovery — другой аккаунт-менеджер, device-bound-ключ, отдельный recovery-код-секрет (Часть IV).
Часть II. Метрики миграции: что мерить и почему «% включивших» врёт
Corbado и FIDO в KPI-библиотеках прямо называют ведущий индикатор: доля успешных входов через passkey от всех успешных входов (share of successful passkey logins). Это лучше «% включивших», потому что измеряет реальное предпочтение, а не «галочку в настройках». Но рядом обязана стоять обратная сторона доступа:
| Метрика | Что говорит | Почему нельзя без неё |
|---|---|---|
| Lockout-rate (заблокировано / пытались войти) | главный риск-индикатор миграции | без него «успешный рост passkey» маскирует тех, кто уже не входит |
| Recovery success rate + time-to-recover | работает ли дверь при потерянном факторе | низкий = потерянные аккаунты; высокий, но дешёвый по проверке = дыра (открываете всем подряд) |
| Share of successful logins через passkey | реальное принятие (ведущий KPI FIDO/Corbado) | не равен «включили»: включить можно, а входить привычно не выходить |
| Support tickets per 1000 logins (по способу входа) | экономика: passkey снимает сбросы пароля | если растёт, а не падает — recovery/UX сломан |
| Funnel drop на enrolment-шагах | где теряется вкатывание | high intent → отказ = промпт/UX/ассоциация №4 не сработали |
| Доля аккаунтов с 0 passkey и 0 recovery | ваши будущие «orphans» | это не провал входа, это зародыш провала через месяц |
Что дают внешние ориентиры (контекст, не обещание):
- FIDO Passkey Index 2026 (fidoalliance.org): более 1 из 3 успешных входов (36%) идёт через passkey — рост с ~26% в 2025; «переход от развертывания к принятию». Это подтверждает направление, но это агрегат по индустрии.
- Support-экономика. По данным FIDO (World Passkey Day 2025) в названных внедрениях passkeys снижали объём обращений в поддержку на 10–35%; вендор-кейс Zoho (100 млн+ клиентов) — около 10% падения запросов на сброс пароля. Это аргумент «зачем» с оговоркой: цифры из промо-отчётов альянса и вендора, у вас своя база.
- Success-rate входа. Mojoauth (four-states, вендор-блог, иллюстративно): passkey даёт ~93% успешных входов против ~63% у пароля и 13.6 с против 27.5 с на вход. Направление правильное (passkey побеждает и по удобству), но абсолютные числа — этого вендора, не ваш замер.
Главная ловушка метрик: считать «adoption %» и радоваться. Рост adoption не противоречит росту lockout — они могут расти одновременно, пока вы не смотрели на lockout-rate. Поэтому первую версию дашборда миграции делайте вокруг lockout + recovery success, а adoption — вторым рядом.
-- sketch: lockout-rate и recovery success по окну. Агрегат из security-событий (см. №2/логирование).
-- события: auth_attempt, auth_success, passkey_ceremony_failed, recovery_started, recovery_succeeded.
SELECT
window_ts,
SUM(method='passkey' AND event='auth_success') AS passkey_ok,
SUM(method IS NOT NULL AND event='auth_success') AS all_ok,
SUM(event='passkey_ceremony_failed' AND code='no_credential') AS locked_by_no_key,
SUM(event='recovery_succeeded') / NULLIF(SUM(event='recovery_started'),0) AS recovery_rate
FROM auth_events
WHERE window_ts >= :from AND window_ts < :to
GROUP BY window_ts;
recovery_rate ниже порога (или рост locked_by_no_key) — стоп-сигнал для следующего шага принуждения, ещё до «сбросить пароль всем». Именно на этой паре метрик, а не на adoption %, принимаются решения в Части III.
Часть III. Кривая внедрения: четыре состояния и точка невозврата
Не «big-bang: выключили пароли, включили passkeys». Между «только пароль» и «пароля нет» есть четыре рабочих состояния, и в каждом свой риск. Это тот же offer-at-login из №2, развёрнутый в полную траекторию.
Состояние 1 — passkey как второй способ, пароль жив. Включаете добавление ключа (с recent-auth перед изменением — №2), на входе passkey опция рядом с паролем. Ничего не теряется: нет ключа — входит паролем. Риск минимальный, adoption растёт сам через offer-at-login. Здесь нужно НЕ торопиться: это единственное состояние, где ошибка recovery не фатальна (есть пароль).
Состояние 2 — passkey как предпочтительный (offer-at-login / conditional). Ключ предлагается в самом акте входа (веб conditional-UI, мобила-автозаполнение — №4), пароль всё ещё доступен, но пользователь по умолчанию идёт в passkey. Здесь и начинается измерение lockout/recovery (Часть II): вы уже видите, у кого «нет ни passkey, ни recovery». Готовьте recovery-архитектуру (Часть IV) именно в этом состоянии, пока принуждения ещё нет.
Состояние 3 — passkey как основной (primary), пароль вынесен из happy-path. Вход без пароля; пароль остаётся как явно выбранный, логируемый, политикой заданный резерв (не молча показанный — №2). Это «preventing passwords» для новых и добровольно-перешедших аккаунтов. Здесь критично: к этому состоянию аккаунт допускается только если can_enforce_passkey_primary() истинна — закрыт независимый recovery. Именно на этом шаге без recovery вы и создадите тикеты пачками.
Состояние 4 — пароль выключен (phishing-resistant by default). Microsoft в Entra ID сделал passkey методом аутентификации по умолчанию и заявляет цель в перспективе убрать поддержку пароля вовсе; у потребительских провайдеров новые аккаунты создаются passkey-default. Для новых аккаунтов это оправдано (сразу строится правильный lifecycle). Для существующей базы «убить пароль» — решение, которое откатывается дорого, и точка невозврата именно здесь: выключив пароль, вы обязаны были убедиться, что recovery-дверь широкая, прочная и измеренная. Не делайте Состояние 4 массово до стабильного recovery success rate на Состоянии 3.
Кого нельзя пускать в Состояния 3–4 по одной политичке. Семейные/разделяемые устройства (несколько людей, один телефон), kiosk/библиотечные аккаунты, пользователи без второго устройства и без цифрового следа, корпоративные устройства под MDM с частым сбросом. Им passkey-primary = гарантированная future-lockout. Для них держите Состояние 1–2 и/или корпоративный recovery через администратора-идентификатора (не email-ссылку).
Часть IV. Архитектура восстановления: прочнее email-ссылки, не «код на телефон»
Здесь самая содержательная часть для того, чтобы «не потерять аккаунты». Разберём, какие recovery вообще бывают и чем они не являются.
Почему email-ссылка — слабый recovery именно для passkey-аккаунта. Логическая ловушка: вы поставили вход, который нельзя украсть фишингом, и повесили рядом «забыл доступ? подтверди через почту». Но почту угнать можно (перехват сессии веб-почты, SIM-подобные атаки на почтовый вход, AitM на саму страницу почты), а значит «восстанови через email-ссылку» = отдать учётку тому, кто контролирует почту. И это не моё мнение, а нормативка: NIST SP 800-63B запрещает email как out-of-band-канал аутентификации (§3.1.3.1) прямо потому, что он «may be vulnerable». Для аккаунта, где passkey обязан быть гарантией, recovery обязан быть прочнее, чем то, что email не допускает как OOB. (Email остаётся нормальным каналом уведомлений — «к вам просили recovery» — но не каналом подтверждения личности для открытия двери.)
Иерархия recovery-путей (от прочного к слабому):
- Второй независимый passkey — на другом аккаунте-менеджере/устройстве (не из того же Apple ID/Google — см. Часть I). Прочный и тот же UX.
- Device-bound ключ (roaming YubiKey) — не синхронизируется, не зависит от аккаунта синхронизации; идеален как recovery для high-value, но свой же риск «потерял брелок».
- Одноразовые recovery-коды как секреты (не SMS, не email-ссылка) — сгенерированы при enrolment, показаны один раз, хранятся у пользователя офлайн; на сервере — только хэш, потребление одноразовое. Это ровно те «печатаете ключ с клавиатуры» коды, что делают Windows/Google как fallback. Слабость: низкая энтропия и риск brute force → лимит попыток, поштучная трата, алерт на использование.
- High-assurance out-of-band — отдельный доверенный канал, не почта (апп в уже залогиненном trusted-клиенте, push на доверенное устройство, видеозвонок/документ для корпоративной поддержки).
- (слабо, не для high-value) код на телефон/SMS, email-ссылка — только если аккаунт низкорисковый ИЛИ покрыт заморозкой высокорисковых действий после такого входа + уведомлением (см. fallback-политику в №2).
Противоречие, которое надо принять вслух: recovery прочный → он неудобен (2 фактор, доки, звонок); recovery удобный → он уязвим (email-ссылка). Нельзя одновременно «вход всегда без меня за секунду» и «дверь не откроют». Решение — дифференцировать по риску аккаунта: массовому низкорисковому допустим code-фолбэк с алертами; платёжному/админскому/деплойному — только прочные пути, даже ценой саппорт-трения.
Смена/добавление/удаление фактора — отдельный класс высокорисковых событий. Требуйте recent-auth, ставьте тайм-делэй на «добавил recovery → стал использовать» (чтобы угон сессии не прикрепил дверь мгновенно), шлите уведомление в уже доверенный канал, логируйте как security-событие (у вас это ровно тема статьи про логирование и трекинг и обнаружение атаки).
<?php
// sketch: одноразовые recovery-коды как секреты (НЕ email/SMS). Форма решения.
// шаг 1: Генерация при enrolment — 8–10 бит на код, N кодов; на сервер ХРАНИМ ТОЛЬКО хэш.
function generate_recovery_codes(int $count = 10): array {
$out = [];
for ($i = 0; $i < $count; $i++) {
$raw = strtoupper(bin2hex(random_bytes(5))); // ~10 символов base32-алфавит
$out['plain'][] = $raw;
$out['store'][] = password_hash($raw, PASSWORD_DEFAULT); // только хэш
}
return $out;
}
// шаг 2: Потребление — ровно один код, одноразово, лимит попыток, алерт.
function consume_recovery_code(array $acct, string $raw): bool {
$tries = rate_limit('recovery_code', $acct['user_id'], 3 /* /окно */);
if ($tries > 3) { security_alert('recovery_code_bruteforce', $acct['user_id']); return false; }
foreach ($acct['recovery_codes'] as $idx => $hash) {
if (password_verify($raw, $hash)) {
unset($acct['recovery_codes'][$idx]); // одноразово: израсходован
persist($acct);
security_notify_trusted_channel($acct, 'был использован recovery-код');
return true;
}
}
return false;
}Ключевые свойства кода выше: секрет (код) ≠ канал (почта/телефон); сервер не знает сам код (только хэш); потребление одноразовое и с rate-limit + алертом. Это ровно то, чего лишён «восстанови по email-ссылке».
FIDO Credential Exchange (CX). Обещание №4 — перенос passkeys между экосистемами/менеджерами (iCloud↔Google↔1Password/Bitwarden) — это задача протокола Credential Exchange; в момент написания он незавершён и не везде в проде. Не обещайте пользователю «смените экосистему — ключи переедут одним тапом», пока не проверили, что CX реально в тех клиентах, что у вас. Заранее закладывайте: при смене экосистемы пользователю может понадобиться re-enrolment, а не «магический перенос» — это тоже часть recovery-UX (см. Часть IV).
Часть V. Механика перевода существующей базы: enrolment без «orphans»
Состояния (Часть III) — политика; здесь — как буквально перекатить тех, кто уже входит паролем, никого не бросив.
1. Enrolment в момент входа, а не в настройках. Люди не заходят в «Настройки → Безопасность → Добавить passkey» по своей воле. Рабочий путь: когда пользователь успешно вошёл паролем (и/или recent-auth выполнен), показать «войдите быстрее в следующий раз — создайте passkey». Конверсия enrolment-в-момент-входа в разы выше, чем «включите в профиле». (И это же состояние-2 из №2.)
2. Не требуйте passkey для existing-аккаунтов в лоб. Existing база входит паролем — принуждение passkey на первой же сессии без enrolment = блокировка того, кто не успел/не смог создать ключ. Порядок: сначала предложи и дай создать, параллельный период оба метода, и только для добровольно создавших поднимайте статус до Состояния 3.
3. Параллельный период и grace-окна. Заведите server-side флаг по аккаунту (не глобальный тумблер): password_enabled, passkey_primary, recovery_state. Перевод аккаунта в primary — только когда can_enforce_passkey_primary() (Часть I). Глобальное «убить пароль» выкатывайте поэтапно по когортам (новые → voluntary → employees → массово), замеряя lockout/recovery на каждой когорте (Часть II) до следующей.
4. «Orphans» — нулевой passkey и нулевой recovery. Это отдельный сегмент, который нужно найти заранее: у пользователя нет ни одного добавленного ключа и нет проверенного recovery. Если он войдёт в «must have passkey» — он не войдёт вообще. Действия: (а) не пускать в принуждение пока не будет хотя бы passkey+recovery; (б) дать путь «пригласить/подтвердить личность» через high-assurance канал, не email-ссылку; (в) для корпоративных — recovery через администратора-идентификатора.
5. Миграция user handle и данных — не «пере-регистрация всего». Если на существующих аккаунтах уже были WebAuthn/токен-креденшелы (например из №3-плагинов) и вы меняете RP ID/домен — credential не переносится между RP ID (скоуп RP ID = вся суть, №1). Значите либо сохраняйте RP ID, либо закладывайте, что старые ключи надо заново создать: миграция RP ID = массовый re-enrolment = гарантированные lockout если recovery слабый. Это и есть тот самый «точка невозврата» из Части III: не меняйте RP ID и не делайте «passwordless by default» одним PR.
6. Коммуникация и обратный путь. Миграция без «как выйти обратно» не принимается. Держите: понятное объяснение «почему», явную кнопку «войти иначе» (не молча, а по действию — №2 fallback), поддержку с прописанным recovery-SOP (саппорт не открывает дверь email-подтверждением — иначе весь passkey обнуливают через человека на другой стороне трубки).
Финальный чек-лист именно этой статьи (закрытие серии):
- закрыт recovery *до* принуждения: независимый от аккаунта синхронизации путь существует (synced-ключ сам по себе ≠ recovery);
- у существующей базы нет «orphans» (0 passkey + 0 recovery) в зоне принуждения;
- метрики lockout-rate и recovery success rate собраны и на дашборде *выше* adoption %;
- email-ссылка/SMS **не** используются как recovery для аккаунтов, где passkey обязан быть гарантией (NIST §3.1.3.1: email не OOB);
- recovery-коды — секреты (только хэш на сервере, одноразово, rate-limit, алерт), а не канал-почта;
- добавление/удаление фактора и смена recovery = recent-auth + тайм-делэй + уведомление в доверенный канал + security-лог;
- принуждение идёт когортами (new → voluntary → employees → mass), с замером между когортами;
- RP ID / домен не меняются одним махом (credential не переносится между RP ID; смена = массовый re-enrolment);
- есть явный обратный путь и саппорт-SOP «не открывать дверь по email»;
- на shared/family/kiosk и без-устройства — отдельная политика (не гнать в primary);
- CX-«перенос между экосистемами» не обещан, пока не проверен в используемых клиентах.
Инструменты под задачу
Собрать по вашим данным когорты и lockout-воронку (кто в базе «0 passkey + 0 recovery», где рвётся enrolment) — типичная задача для чата-ассистента для кода: опишите события входа и recovery и спросите, какой SQL/PHP-агрегат даст lockout-rate и recovery success по окну. Перед выкаткой прогоните состояние ассоциаций и secure-контекста — .well-known/assetlinks.json и AASA отдаются по HTTPS без редиректов, это же проверка HTTP-заголовков и проверка SSL-сертификата: один несовпавший origin или кривой сертификат = тихие «passkey не предлагается» в каждой когорте.
Чек-лист граблей
- Меряете adoption % и радуетесь. Это vanity: «% включивших» растёт одновременно с lockout. Судите по lockout-rate и recovery success rate.
- Принуждаете passwordless до закрытия recovery. «Потерял телефон — не могу войти» пачками; каждый тикет — потерянный аккаунт или угон через саппорт.
- Считаете synced-ключ своим же recovery. Два пути зависят от одного Apple ID/Google — потеря аккаунта роняет оба; recovery обязан быть независимым.
- Родной recovery — email-ссылка для high-value аккаунта. NIST §3.1.3.1 запрещает email как OOB; почту угоняют, значит дверь открывается тому, кто контролирует почту.
- «Код на телефон» как основной recovery. SMS/voice фишингуемы (№1/№2); допустимо только на низкорисковых или с заморозкой высокорисковых действий + алертом (см. fallback-политику в №2).
- Recovery-код хранится как канал-письмо, а не как хэшированный секрет. Правильно: только хэш на сервере, одноразовое потребление, rate-limit, алерт на использование.
- Открываете дверь саппортом «по email-подтверждению». Обнуляет весь passkey через социнжиниринг; нужен SOP с high-assurance проверкой личности.
- Меняете RP ID/домен и ожидаете «старые ключи переедут». Credential скоупнут по RP ID (№1) и не переносится; смена = массовый re-enrolment = lockout при слабом recovery.
- Big-bang «убить пароль всем». Принуждение без когорт и замера; откатывается дорого, точка невозврата пройдена на Состоянии 4.
- Гонишь в primary shared/family/kiosk и «без второго устройства». Для них passkey-primary = future-lockout по построению; нужна отдельная политика/корп-recovery.
- Обещаете перенос passkey между iOS↔Android «из коробки». FIDO Credential Exchange незавершён/не везде; может понадобиться re-enrolment (см. Часть IV).
- Игнорируете «orphans» (0 ключ + 0 recovery). Это зародыш провала; найдите сегмент до принуждения, не постфактум в тикетах.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
