Проблема
Команды часто внедряют passkeys как новый login method, но оставляют старую recovery модель, старую session cookie и прежнюю логику повышенных действий. В результате сложный фактор защищает только вход, а не аккаунт в целом.
Особенно неприятны случаи, когда пользователь прошёл WebAuthn, но сессия не ротируется, privileged action не требует fresh verification, а account recovery или email change остаются заметно слабее основной аутентификации.
Решение
Нужно смотреть на WebAuthn как на часть identity boundary: регистрация, login, step-up, session rotation, sensitive action, factor reset, recovery, device loss и audit trail.
Хорошая defensive-практика - отделять presence от higher assurance, делать session rotation после сильного подтверждения и помечать операции, для которых нужен fresh WebAuthn или другой trusted step-up.
Что проверить руками
- Проверить, меняется ли session id после passkey login и после step-up.
- Сравнить силу primary login и recovery flow: email reset, support override, backup code, device replacement.
- Проверить, требуется ли fresh verification для email change, payout change, API token creation и password reset.
- Посмотреть audit trail: видно ли регистрацию нового passkey, удаление старого фактора и fallback login.
Типичные ошибки
- Считать, что WebAuthn автоматически повышает гарантию для всех сессий.
- Не разделять login success и privileged action assurance.
- Не тестировать потерю устройства и жизненный цикл recovery отдельно от happy-path логина.
Defensive checklist
- Session rotation есть после сильного входа.
- Sensitive actions требуют fresh verification или step-up.
- Recovery не слабее primary assurance.
- Audit logs покрывают factor registration, revocation и recovery events.
Безопасный пример кода
Session rotation после WebAuthn step-up. Пример рассчитан на owned/lab-среду и показывает инженерную логику проверки, а не эксплуатационную цепочку.
def complete_webauthn_stepup(session: dict, user_id: str, assurance: str) -> dict:
fresh = {
"user_id": user_id,
"session_id": issue_new_session_id(),
"assurance": assurance,
"stepup_at": now_iso(),
"csrf_token": issue_csrf_token(),
}
revoke_session(session["session_id"])
persist_session(fresh)
return fresh
def privileged_action_allowed(session: dict, max_age_seconds: int = 900) -> bool:
if session.get("assurance") not in {"webauthn", "mfa"}:
return False
return age_seconds(session.get("stepup_at")) <= max_age_seconds
Как это должно попасть в отчёт
- Identity boundary: login, step-up, recovery, factor lifecycle.
- Evidence: cookie/session behavior, action gating, audit logs, fallback logic.
- Retest: stolen old session и weak recovery path больше не дают privileged access.