Passkey-аутентификация

Асимметричная криптография вместо паролей

21 февраля 2026

Пароли - слабое звено безопасности. Их крадут фишингом, утекают из баз данных, угадывают брутфорсом. Passkey меняет модель: вместо секрета, который пользователь помнит и вводит, используется асимметричная криптография. Приватный ключ хранится на устройстве и защищён биометрией или PIN, публичный - на сервере. Результат: фишинг невозможен на уровне протокола, утечка базы бесполезна, пользователь не запоминает ничего. Статья прослеживает путь от Diffie-Hellman и RSA до WebAuthn, разбирает, почему привязка к origin делает фишинг математически невозможным, показывает истории внедрения GitHub и Cloudflare, разбирает антипаттерны (отсутствие fallback, слепая опора на платформенный sync, игнорирование sign count) и предлагает гибридные стратегии для перехода существующей user base.

Эволюция криптографии в аутентификации

Чтобы понять, почему passkey работает именно так, полезно увидеть его как очередной шаг сорокалетней траектории public-key cryptography. До 1976 года вся криптография была симметричной: обе стороны должны были заранее знать общий секрет. Это удобно для личной переписки между двумя людьми, но катастрофично для аутентификации миллионов пользователей в открытой сети - общий секрет нужно как-то безопасно передать, и любая утечка с одной стороны компрометирует обе.

Прорыв сделали Whitfield Diffie и Martin Hellman в статье «New Directions in Cryptography» (IEEE Transactions on Information Theory, 1976). Они показали, что две стороны могут договориться об общем секрете, обмениваясь только публично наблюдаемыми сообщениями - механизм, который сегодня известен как Diffie-Hellman key exchange. Идея, что секрет можно вырастить из публичного диалога, перевернула криптографию: безопасность теперь опиралась не на охрану канала передачи, а на математическую сложность дискретного логарифма.

Через год Ron Rivest, Adi Shamir и Leonard Adleman опубликовали алгоритм RSA («A Method for Obtaining Digital Signatures and Public-Key Cryptosystems», Communications of the ACM, 1978), реализовавший идею пары ключей в практической форме: один ключ шифрует или верифицирует подпись (публичный), другой расшифровывает или подписывает (приватный). RSA сделал возможным то, что в passkey используется буквально - сервер хранит публичный ключ, клиент подписывает приватным, никакого общего секрета на двоих больше не существует. Утечка серверной базы перестаёт компрометировать пользователей; это и есть архитектурное основание passkey, заложенное за полвека до самого WebAuthn.

Следующий концептуальный шаг сделали Neal Koblitz и Victor Miller, независимо предложившие в 1985 году elliptic curve cryptography (ECC). RSA при сравнимом уровне безопасности требует ключи в 2048-3072 бита; ECC даёт ту же стойкость на 256 битах. Это не «приятный бонус», а технологическое условие для passkey: подпись на 256-битной кривой укладывается в десятки байт, генерируется и проверяется за миллисекунды на телефоне без специальных ускорителей. Daniel J. Bernstein в 2005 году предложил Curve25519 - конкретную кривую, спроектированную с учётом устойчивости к timing-атакам и простоты реализации. Современный WebAuthn в большинстве реализаций по умолчанию использует ES256 (ECDSA на P-256, NIST-кривая), но идейно стоит на той же линии: ECC - это то, что сделало asymmetric cryptography пригодной для массового потребительского устройства.

Параллельно индустрия искала формат для пользовательской аутентификации. FIDO Alliance был основан в 2012 году консорциумом, включавшим PayPal, Lenovo и Nok Nok Labs, с задачей стандартизировать сильную аутентификацию без паролей. Первый стандарт - U2F (Universal 2nd Factor) 2014 года - решал только задачу второго фактора с физическим ключом. Второй - UAF (Universal Authentication Framework) - локальную биометрию. В 2018-2019 годах эти линии слились в FIDO2 (CTAP2 - протокол общения с аутентификатором) и WebAuthn (W3C-стандарт для браузера), который в марте 2019 года получил статус W3C Recommendation. Это и есть моментальный снимок, на котором вырос passkey.

Финальная веха - совместное заявление Apple, Google и Microsoft 5 мая 2022 года (World Password Day) о синхронизируемых passkey. До этого WebAuthn-credential по умолчанию был привязан к конкретному устройству; потеря телефона означала потерю credential. Соглашение трёх платформ ввело cross-device sync через iCloud Keychain, Google Password Manager и (с задержкой) Windows Hello, что превратило WebAuthn из «удобной альтернативы для гиков» в массовую технологию, доступную обычному пользователю смартфона.

Полезный исторический урок: каждый шаг этой траектории решал не «как сделать криптографию сильнее», а «как сделать её удобнее в массовом сценарии». Diffie-Hellman убрал необходимость канала для обмена секретом; RSA дал работающую пару ключей; ECC уменьшил их до размеров, пригодных для смартфона; FIDO2 стандартизировал API; синхронизация 2022 года решила проблему «потерянного устройства». Passkey - не изобретение, а конец длинной цепочки инженерных компромиссов.

Проблемы паролей

Пароли существуют с 1960-х годов и до сих пор остаются основным способом аутентификации - несмотря на то, что за это время стало очевидно: они плохо работают в реальном мире.

Повторное использование

Среднестатистический пользователь имеет десятки, а то и сотни аккаунтов. Запомнить уникальный сложный пароль для каждого невозможно. Результат предсказуем: один пароль используется на многих сервисах. Когда утекает один сервис - злоумышленники проверяют учётные данные на всех остальных (credential stuffing).

Фишинг

Пользователь не может надёжно отличить настоящий сайт от поддельного. Фишинговые страницы, имитирующие популярные сервисы, воруют пароли даже у технически грамотных людей. По данным Google, фишинг - причина большинства захватов аккаунтов.

Утечки баз данных

Даже если сервис хранит пароли правильно - в виде bcrypt или Argon2 хэшей - при утечке базы злоумышленники могут перебрать слабые пароли офлайн на GPU. Хэш "password123" будет взломан за секунды.

Масштаб проблемы: база Have I Been Pwned содержит более 12 миллиардов скомпрометированных учётных данных из реальных утечек. Это не теоретическая угроза.

Усталость от паролей и слабые пароли

Требования к сложности паролей (заглавная буква, цифра, спецсимвол) не делают их безопаснее - они делают их неудобными. Пользователи приспосабливаются предсказуемо: Password1! формально удовлетворяет всем требованиям, но взламывается мгновенно.

Трение от MFA

Двухфакторная аутентификация снижает риски, но добавляет трение: нужно доставать телефон, переключаться между приложениями, вводить одноразовый код. Часть пользователей отключает MFA из-за неудобства. SMS-коды вдобавок уязвимы к SIM-swapping и перехвату.

Что такое Passkey

Passkey - это учётные данные на основе стандарта FIDO2 / WebAuthn, разработанного альянсом FIDO (Fast IDentity Online) совместно с W3C. Вместо пароля используется пара криптографических ключей.

Асимметричная криптография

При создании passkey устройство генерирует пару ключей:

При аутентификации сервер отправляет случайный вызов (challenge). Устройство подписывает его приватным ключом. Сервер проверяет подпись публичным ключом. Приватный ключ при этом никуда не передаётся.

Пользователь              Устройство                  Сервер
    |                          |                            |
    |  Вход по биометрии       |                            |
    |------------------------->|                            |
    |                          |  (приватный ключ в        |
    |                          |   Secure Enclave)          |
    |                          |                            |
    |                          |  Подписать challenge       |
    |                          |<---------------------------|
    |                          |                            |
    |                          |  Подпись(challenge)        |
    |                          |--------------------------->|
    |                          |                            |
    |                          |  Проверить подпись         |
    |                          |  публичным ключом          |
    |                          |         OK                 |
    |<----------------------------------------------- 200 --|

Привязка к домену (origin binding)

Каждый passkey привязан к конкретному домену (RP ID - Relying Party ID). Passkey, созданный для example.com, физически не может быть использован на evil-example.com. Это делает фишинг невозможным на уровне протокола, а не на уровне «пользователь должен быть внимательным». Подробнее о механике - в следующем разделе.

Верификация пользователя

Перед использованием приватного ключа устройство требует подтвердить личность пользователя через:

Биометрические данные обрабатываются локально на устройстве и никогда не покидают его - ни на сервер, ни к Apple/Google.

Синхронизация между устройствами

Платформенные passkey синхронизируются через облако платформы:

Roaming vs Platform authenticators: Платформенные аутентификаторы (встроены в устройство, синхронизируются через облако) отличаются от роуминговых (физические ключи безопасности, например YubiKey). Роуминговые ключи не синхронизируются - при потере нужен резервный способ входа.

Почему фишинг невозможен: origin binding on protocol level

Фраза «passkey устойчив к фишингу» звучит маркетингово, но за ней стоит конкретный протокольный механизм, который полезно разобрать формально. Ключевая разница с TOTP, SMS, magic link и любой другой схемой, переносящей секрет через пользователя - в том, что подпись passkey включает в себя имя сайта, на котором эта подпись была создана, и сервер проверяет это имя криптографически.

Что подписывает аутентификатор

При вызове navigator.credentials.get() браузер собирает структуру clientDataJSON, которая включает три поля, критичных для anti-phishing:

Затем аутентификатор отдельно проверяет: rp.id, переданный сервером в запросе, должен соответствовать domain или быть его суффиксом. Если страница на shop.example.com просит подписать challenge с rp.id: example.com - можно (suffix совпадает); если просит подписать с rp.id: other.com - аутентификатор отказывает. Хеш rp.id (rpIdHash) включается в подписываемые данные authenticatorData.

В итоге подпись создаётся над комбинацией authenticatorData || SHA-256(clientDataJSON), и эта подпись доказывает: пользователь аутентифицировался на сайте, чей origin равен https://github.com, а не на каком-либо другом.

Что проверяет сервер

На стороне сервера верификация (которую за вас сделает любая нормальная webauthn-библиотека) включает:

  1. Распарсить clientDataJSON.
  2. Сверить clientDataJSON.origin с ожидаемым origin сервиса. Если ожидается https://github.com, а пришло https://github.evil.com - отказ.
  3. Сверить clientDataJSON.challenge с ранее выданным challenge из сессии.
  4. Проверить, что authenticatorData.rpIdHash == SHA-256(rp.id).
  5. Проверить подпись публичным ключом из БД.

Контраст: как ломается TOTP

Сравним с реалистичной фишинговой атакой на TOTP. Пользователь получает письмо «ваш аккаунт под угрозой, войдите» со ссылкой на github.evil.com. Сайт-фишер отлично имитирует логин-форму. Пользователь вводит логин и пароль; сайт-фишер в реальном времени передаёт их на настоящий github.com. Настоящий сайт просит TOTP-код. Сайт-фишер просит TOTP-код у пользователя. Пользователь смотрит в Google Authenticator, видит шесть цифр (в которых нет имени домена), вводит их. Сайт-фишер передаёт код на github.com и получает сессию. Атака занимает несколько секунд - явно меньше 30-секундного окна валидности кода.

В мире passkey тот же сценарий ломается на четвёртом шаге. Пользователь нажимает «Войти через passkey» на github.evil.com. Браузер вызывает WebAuthn с rp.id github.evil.com (или evil.com - произвольно, что сайт указал). Аутентификатор смотрит в свою базу: для этого rp.id у меня credential нет. Возможны два варианта: либо аутентификатор честно отвечает «нет credential» и атака просто не проходит, либо (если фишер пытается выдать страницу за github.com) браузер обнаруживает несоответствие origin и rp.id и отказывает. Никакого «пользователь должен внимательно смотреть на адресную строку» - проверка делается криптографией.

То же касается magic link с TLS-сертификатом на похожем домене (g1thub.com) - пользователь кликает, попадает на фишерный сайт, тот переотправляет magic-токен на настоящий сайт. Passkey не имеет такой уязвимости, потому что credential привязан к origin, а origin браузер защищает от подмены сертификатами и SOP (same-origin policy).

Формально это можно переформулировать так: passkey превращает аутентификацию из «передачи знания» (пользователь знает секрет, отдаёт его сайту) в «подпись над контекстом» (устройство подписывает связку challenge + origin). Любая схема первого типа уязвима к real-time relay, потому что секрет не несёт в себе информации о месте передачи. Любая схема второго типа фишинг-устойчива, если пользовательский агент честно подставляет origin и не позволяет сайту его подделать.

Как работает регистрация

Регистрация passkey - это создание пары ключей на устройстве и передача публичного ключа на сервер. Используется API navigator.credentials.create().

Схема регистрации

Клиент                           Сервер
  |                                  |
  |--- GET /register/start --------->|
  |                                  |
  |<-- challenge, rp, user ----------|
  |    (опции регистрации)           |
  |                                  |
  |  navigator.credentials.create()  |
  |  (пользователь проходит          |
  |   биометрию/PIN)                 |
  |                                  |
  |--- POST /register/finish ------->|
  |    publicKey, attestation        |
  |                                  |
  |    Сервер сохраняет публичный    |
  |    ключ, привязывает к userId    |
  |                                  |
  |<-- 200 OK -----------------------|

JavaScript: создание credential

const publicKeyCredentialCreationOptions = {
  // Случайный вызов от сервера - защита от replay-атак
  challenge: Uint8Array.from("random_challenge_from_server", c => c.charCodeAt(0)),

  // Информация о сервисе (Relying Party)
  rp: {
    name: "MyApp",
    // id по умолчанию = текущий домен
    // Можно задать явно: id: "myapp.com"
  },

  // Информация о пользователе
  user: {
    id: Uint8Array.from("user_id", c => c.charCodeAt(0)),
    name: "user@example.com",
    displayName: "John Doe",
  },

  // Поддерживаемые алгоритмы подписи
  // -7 = ES256 (ECDSA с SHA-256) - стандарт де-факто
  // -257 = RS256 (RSA с SHA-256) - для совместимости с Windows Hello
  pubKeyCredParams: [
    { type: "public-key", alg: -7 },
    { type: "public-key", alg: -257 },
  ],

  authenticatorSelection: {
    // "required" - обязательная верификация пользователя (биометрия/PIN)
    userVerification: "required",
    // "required" - создать resident key (discoverable credential)
    // позволяет входить без предварительного ввода email
    residentKey: "required",
  },

  timeout: 60000,

  // "none" - не требовать аттестацию аутентификатора
  // Для обычных приложений достаточно; enterprise может требовать "direct"
  attestation: "none",
};

const credential = await navigator.credentials.create({
  publicKey: publicKeyCredentialCreationOptions,
});

Ключевые параметры

Параметр Назначение Типичное значение
challenge Случайные байты от сервера, защищают от replay-атак 16-32 байт, криптографически случайные
rp.id Домен сервиса, к которому привязан passkey example.com (без протокола и пути)
user.id Уникальный идентификатор пользователя Случайный UUID, не email и не username
pubKeyCredParams Список поддерживаемых алгоритмов подписи ES256 (-7) и RS256 (-257)
userVerification Обязательность верификации пользователя required для passkey
residentKey Хранить credential на аутентификаторе (discoverable) required для passkey без username
attestation Аттестация устройства (проверка производителя) none для большинства приложений

Что возвращает credential

// credential.response содержит:
{
  clientDataJSON,      // JSON с challenge, origin, type - подписывается
  attestationObject,   // Содержит публичный ключ и данные аутентификатора
}

// credential.id - уникальный идентификатор credential
// Сохраняется на сервере, используется при аутентификации

Как работает аутентификация

При входе сервер отправляет новый случайный challenge. Устройство подписывает его приватным ключом. Сервер проверяет подпись публичным ключом, который хранится в базе с момента регистрации.

Схема аутентификации

Клиент                           Сервер
  |                                  |
  |--- GET /auth/start ------------->|
  |    (опционально: email)          |
  |                                  |
  |<-- challenge, allowCredentials --|
  |    (список допустимых credential |
  |     для данного пользователя)    |
  |                                  |
  |  navigator.credentials.get()     |
  |  (пользователь проходит          |
  |   биометрию/PIN)                 |
  |                                  |
  |--- POST /auth/finish ----------->|
  |    assertion (подпись challenge) |
  |                                  |
  |    Сервер: найти публичный ключ  |
  |    по credential.id, проверить   |
  |    подпись, сверить challenge    |
  |                                  |
  |<-- 200 OK (сессия создана) ------|

JavaScript: подтверждение credential

const publicKeyCredentialRequestOptions = {
  // Новый случайный challenge от сервера
  challenge: Uint8Array.from("server_challenge", c => c.charCodeAt(0)),

  timeout: 60000,

  // Домен сервиса - должен совпадать с rp.id при регистрации
  rpId: "yourdomain.com",

  // Список допустимых credential для данного пользователя
  // Если пользователь ввёл email - сервер возвращает его credential IDs
  // Если нет (passwordless) - передаём пустой массив или опускаем поле
  allowCredentials: [
    {
      id: Uint8Array.from("credential_id_from_server", c => c.charCodeAt(0)),
      type: "public-key",
    },
  ],

  // Обязательная верификация пользователя
  userVerification: "required",
};

const assertion = await navigator.credentials.get({
  publicKey: publicKeyCredentialRequestOptions,
});

Что содержит assertion

// assertion.response содержит:
{
  clientDataJSON,       // JSON с challenge, origin, type
  authenticatorData,    // Метаданные: rpIdHash, флаги, счётчик подписей
  signature,            // Подпись authenticatorData + clientDataJSON
  userHandle,           // user.id из момента регистрации (если discoverable)
}

// assertion.id - идентификатор credential
// Сервер использует его для поиска соответствующего публичного ключа
Счётчик подписей (signCount): аутентификатор хранит счётчик, который увеличивается при каждой аутентификации. Сервер может проверять, что счётчик только растёт. Если пришло меньшее значение - возможно клонирование ключа. На практике платформенные аутентификаторы (Apple, Google) могут сбрасывать счётчик при восстановлении из бэкапа, поэтому большинство серверов не блокируют сессию при уменьшении счётчика, но логируют это событие. Подробнее об этой ловушке - в разделе «Антипаттерны».

Серверная часть

Сервер отвечает за три задачи: генерацию и хранение challenge, верификацию ответа аутентификатора, и хранение публичных ключей.

Что хранит сервер

-- Таблица credential'ов (публичных ключей)
CREATE TABLE passkey_credentials (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id),

  credential_id   BYTEA NOT NULL UNIQUE,   -- assertion.id от клиента
  public_key      BYTEA NOT NULL,          -- COSE-encoded публичный ключ
  sign_count      BIGINT NOT NULL DEFAULT 0,

  -- Мета-информация
  aaguid          UUID,                    -- Идентификатор модели аутентификатора
  transports      TEXT[],                  -- ["internal", "hybrid"]
  created_at      TIMESTAMPTZ DEFAULT now(),
  last_used_at    TIMESTAMPTZ
);

-- Таблица challenge'ов (TTL: ~5 минут)
CREATE TABLE webauthn_challenges (
  challenge       BYTEA PRIMARY KEY,
  user_id         UUID REFERENCES users(id),  -- NULL при passwordless
  expires_at      TIMESTAMPTZ NOT NULL
);

Верификация на сервере

Вручную реализовывать верификацию WebAuthn не нужно - это сложно и чревато ошибками. Используйте готовые библиотеки:

Пример верификации (Ruby + webauthn-ruby)

# Регистрация - генерация опций
def registration_options
  options = WebAuthn::Credential.options_for_create(
    user: {
      id: current_user.webauthn_id,
      name: current_user.email,
      display_name: current_user.name
    },
    authenticator_selection: {
      user_verification: "required",
      resident_key: "required"
    },
    attestation: "none"
  )

  session[:webauthn_challenge] = options.challenge
  render json: options
end

# Регистрация - верификация ответа
def registration_finish
  webauthn_credential = WebAuthn::Credential.from_create(params)

  webauthn_credential.verify(session[:webauthn_challenge])

  current_user.passkey_credentials.create!(
    external_id: Base64.strict_encode64(webauthn_credential.raw_id),
    public_key: webauthn_credential.public_key,
    sign_count: webauthn_credential.sign_count
  )
rescue WebAuthn::Error => e
  render json: { error: e.message }, status: :unprocessable_entity
end

# Аутентификация - верификация
def authentication_finish
  stored_credential = PasskeyCredential.find_by!(
    external_id: Base64.strict_encode64(params[:rawId])
  )

  webauthn_credential = WebAuthn::Credential.from_get(params)
  webauthn_credential.verify(
    session[:webauthn_challenge],
    public_key: stored_credential.public_key,
    sign_count: stored_credential.sign_count
  )

  stored_credential.update!(sign_count: webauthn_credential.sign_count)
  sign_in(stored_credential.user)
rescue WebAuthn::Error => e
  render json: { error: e.message }, status: :unauthorized
end

Что проверяет библиотека при верификации

Совместимость

Браузеры и платформы

Платформа Браузер / Среда Поддержка Хранилище ключей
macOS 13+ Safari, Chrome, Firefox, Edge Полная iCloud Keychain
iOS / iPadOS 16+ Safari, Chrome, Firefox (через Safari WebKit) Полная iCloud Keychain
Android 9+ Chrome, Firefox, Edge Полная Google Password Manager
Windows 10/11 Chrome, Firefox, Edge Полная Windows Hello (локально)
Linux Chrome, Firefox Частичная Роуминговые ключи (USB), нет платформенного

Ограничения

Cross-device authentication (Hybrid transport)

WebAuthn поддерживает аутентификацию через другое устройство по QR-коду. Сценарий: пользователь заходит с Windows-ноутбука, сканирует QR телефоном с Android, подтверждает биометрией на телефоне. Связь установлена по Bluetooth (для proximity check) + облачный relay. Никакие секреты не передаются по Bluetooth - только зашифрованный туннель.

Windows (Chrome)                  Android (Chrome)
      |                                  |
      |  Показать QR-код                 |
      |--------------------------------->| (сканировать QR)
      |                                  |
      |<-- Bluetooth proximity check --->|
      |                                  |
      |                                  | (биометрия)
      |                                  |
      |<-- Подписанный assertion --------|
      |    (через зашифрованный          |
      |     облачный канал)              |
      |                                  |
      | Верификация подписи              |
      | Сессия создана                   |

Passkey vs другие методы

Метод Безопасность UX Фишинг-устойчивость Сложность внедрения
Passkey Высокая Высокий Полная Средняя
Пароль Низкая Низкий Нет Простая
Пароль + TOTP (Google Authenticator) Средняя Низкий Частичная Средняя
SMS OTP Низкая Средний Нет Средняя
Magic Link (email) Средняя Средний Частичная Простая
OAuth (Google/GitHub Sign-In) Средняя Высокий Частичная Простая
Hardware key (YubiKey) Максимальная Средний Полная Высокая

Почему Passkey лучше TOTP

TOTP (Time-based One-Time Password, коды из Google Authenticator) - значительно лучше SMS, но уязвим к real-time фишингу. Атакующий создаёт поддельный сайт, пользователь вводит код, атакующий мгновенно использует его на настоящем сайте. Код действителен 30 секунд - достаточно для автоматизированной атаки. Подробный механический разбор - в разделе «Почему фишинг невозможен».

Почему Passkey лучше OAuth

OAuth (Sign in with Google/GitHub) удобен, но создаёт зависимость от провайдера. Если Google заблокирует аккаунт или закроет OAuth API для вашего приложения - пользователи потеряют доступ. Passkey хранится на устройстве пользователя и не зависит от третьих сторон.

Практическая рекомендация: на старте проще всего реализовать OAuth + Magic Link. Passkey стоит добавлять как дополнительный, более удобный метод для существующих пользователей - не заменяя другие методы сразу, а давая выбор. Подробнее о стратегиях перехода - в разделе «Гибридная аутентификация».

Истории внедрения

Абстрактные аргументы в пользу passkey стоят меньше, чем разобранные истории компаний, которые прошли путь внедрения и публично об этом написали. Ниже - три кейса с разным контекстом и разными уроками. Источники приведены в разделе «Источники»; если факт указан без ссылки, это стилизованный пример на основе типичных паттернов.

GitHub: passkey как опциональная замена security key. 12 июля 2023 года GitHub в посте «Introducing passwordless authentication on GitHub.com» объявил general availability passkey для всех пользователей. Контекст важен: GitHub уже долгое время поддерживал WebAuthn через security keys (YubiKey и аналоги), и часть аудитории - в первую очередь Trusted Committers популярных open-source проектов - была обязана пользоваться 2FA. Passkey были добавлены не как замена паролю по умолчанию, а как альтернатива для пользователей, которые хотели входить только биометрией. Параллельно GitHub продолжал требовать 2FA для всех contributors популярных репозиториев (политика, введённая после серии supply-chain атак на npm-пакеты). Урок GitHub - в осторожной поэтапности: сначала годами поддерживается WebAuthn для security keys, затем добавляются синхронизируемые passkey, и только после этого начинается коммуникация «можно входить без пароля». Никакого forced migration; пользователь сам решает, когда переключаться.

Cloudflare: passkey для Cloudflare Access. Cloudflare с 2022 года предлагает passkey как фактор аутентификации в Cloudflare Access - своём Zero Trust решении для доступа к внутренним приложениям. Подход отличается от потребительских сервисов: в корпоративном контексте важна не только устойчивость к фишингу, но и manageability - администратор должен видеть, какие credentials у каких пользователей зарегистрированы, иметь возможность отозвать конкретный passkey, требовать минимальный уровень assurance (например, hardware-backed authenticator, а не platform sync). Cloudflare публиковал детали интеграции в инженерном блоге; общий урок - в корпоративном сегменте платформенный sync через iCloud/Google становится скорее проблемой, чем решением: безопасник предпочитает device-bound credential, который физически невозможно унести с уволившимся сотрудником в его iCloud-аккаунт. Этот сценарий регулируется параметром authenticatorAttachment: "cross-platform" (только USB-ключи) против "platform" (платформенный с возможным sync).

Стилизованный пример: B2B SaaS на 50 000 активных пользователей. Это не задокументированный кейс, а стилизованный пример на основе типичных паттернов, которые приходится наблюдать у B2B-команд уровня Series-B. Команда добавила passkey как дополнительный способ входа в дополнение к existing email/password + TOTP. Promo-баннер в личном кабинете с предложением «привязать устройство для быстрого входа». За первые шесть месяцев passkey включили около 8% пользователей; средний support-load по запросам «не могу войти» снизился на 12% именно в этой когорте. Важный урок - команда сделала следующее намеренно: passkey добавлен в дополнение, не вместо пароля; в момент регистрации passkey пользователю явно сказано «вы можете отвязать его в любой момент, ваш пароль продолжит работать»; recovery-flow остался прежним (email magic link), и не было ни одного локаута. Когда через год команда попыталась сделать «вход только по passkey» для новых аккаунтов, конверсия регистрации упала на 18%, и эксперимент откатили. Стилизация, но шаблон такого отката хорошо знаком.

Все три истории сходятся на одном: passkey - не «новая система входа, сметающая старую», а добавочный фактор, который выигрывает за счёт UX-преимущества там, где он включается, и не должен ломать вход для тех, кто его пока не включил. Никто из перечисленных - даже GitHub - не пошёл по пути «passkey only, password больше нет».

Антипаттерны и их следствия

Большинство неудачных внедрений passkey ломаются не на этапе «не работает API», а на этапе «работает, но в продакшне начинаются жалобы». Ниже - типичные failure modes; каждый из них стоит пройти как чеклист перед публичным запуском.

Отсутствие fallback-метода. Самый частый антипаттерн в рассказах команд, впервые внедряющих passkey. Логика выглядит соблазнительно: «зачем нам пароль, если есть passkey - давайте сделаем passkey-only». Реальность: пользователь меняет телефон, не успел настроить iCloud, потерял YubiKey, переехал с Android на iPhone, потерял доступ к старому Apple ID. Без recovery-канала (email magic link, recovery codes, бэкап-passkey на втором устройстве) такие пользователи становятся локаутами. Поскольку passkey не подразумевает «service desk видит ваш пароль» - сбросить нечего. Правильно: всегда оставлять как минимум один независимый recovery-канал, и проектировать его до запуска passkey, а не «после первого инцидента».

Слепая опора на платформенный sync. Команда исходит из «Apple/Google синхронизируют passkey через своё облако, нам ничего проектировать не надо». Пользователь делает factory reset телефона без настроенного iCloud Keychain - все passkey исчезают. Пользователь меняет основной Apple ID (например, переезжает между странами и создаёт новый) - passkey остаются на старом ID. Пользователь использует enterprise-устройство, где iCloud Keychain отключён политикой - синхронизация не работает в принципе. Платформенный sync - это удобство для большинства, но не гарантия для всех. Сервис, который полагается на него как на «backup», ловит локауты на узкой, но регулярной долей пользователей.

Misunderstanding resident keys vs server-side keys. WebAuthn различает discoverable credentials (resident keys, хранятся на аутентификаторе) и non-resident keys (хранятся серверной частью credential ID, аутентификатор только умеет их подписывать, если ему credentialId передадут). Passkey по соглашению - это discoverable credential (residentKey: required), потому что только тогда пользователь может войти без предварительного ввода email («Войти через passkey» прямо на стартовом экране). Но многие команды по умолчанию ставят residentKey: preferred или забывают этот параметр. Результат - в часть аутентификаторов credential попадает как non-resident, и обещанный «passwordless без логина» не работает: пользователь обязан сначала ввести email, чтобы сервер вернул allowCredentials. UX-обещание сломано на этапе integration, не на этапе фронтенда.

Ignored sign count и replay risk. WebAuthn-аутентификатор отдаёт каждый раз счётчик signCount, и сервер должен сравнить его с предыдущим значением для этого credential. Если новый счётчик меньше или равен сохранённому - возможно клонирование ключа или replay старой подписи. Но команды часто игнорируют signCount по двум причинам: (1) платформенные синхронизируемые passkey могут сбрасывать счётчик при восстановлении из бэкапа, что даёт false positive, и (2) на старте «лень разбираться, всё равно работает». Прагматичный паттерн (тот, что используют большинство production-серверов) - логировать факт уменьшения signCount как security event, но не блокировать сессию автоматически; параллельно настроить алертинг на статистические аномалии. Полный игнор signCount - значит сервер не заметит даже массовое клонирование skimmed credentials.

Treating passkey as «just biometric». Маркетинговый язык Apple и Google подчёркивает Touch ID / Face ID, и в результате product-команда начинает воспринимать passkey как «биометрический логин». Это сужает понимание: биометрия - всего лишь user verification на стороне устройства, а суть passkey - public-key подпись над origin. Из-за этой подмены команды иногда пишут код вроде «если устройство поддерживает Face ID - предлагаем passkey, иначе - нет», теряя десятки процентов пользователей с YubiKey, PIN-only устройствами, кросс-устройственным входом по QR. Правильный детект - PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(), и не раньше, чем приняли решение об authenticatorSelection в политике.

No recovery flow. Связанный с первым, но более узкий: команда добавила recovery-канал (например, email), но не протестировала пользовательский путь до конца. Пользователь нажимает «забыл passkey», получает email, кликает - попадает на страницу, где требуется ввести пароль (которого нет, потому что passwordless), или предлагается войти через passkey, который пользователь как раз потерял. Recovery-flow проектируется отдельно как первый-класс пользовательский путь, не как «ну, отправим magic link, дальше разберутся». Тестировать его надо на реальных сценариях: «новый телефон без аккаунтов», «утерянный YubiKey», «старый Apple ID без доступа».

Day-1 mandatory passkey. Соблазн «новые регистрации - только passkey, мы же modern company» приводит к падению конверсии регистрации на 15-30% (см. стилизованный пример выше). Часть пользователей попросту не понимает, что от них хотят; часть - на устройствах без поддержки; часть - не доверяет «биометрии в браузере». Прогрессивный enhancement (предложить, но не требовать; повторно предложить через несколько входов; сделать обязательным только для определённых высокочувствительных сегментов) даёт ту же безопасность без conversion loss.

Mishandled attestation. Параметр attestation определяет, требует ли сервер криптографического доказательства модели аутентификатора. Для большинства потребительских приложений правильное значение - "none": вы доверяете факту, что пользователь успешно прошёл верификацию, и не хотите видеть AAGUID конкретного устройства. Корпоративные сервисы иногда требуют "direct", чтобы фильтровать по разрешённым моделям (только корпоративные YubiKey, не любые consumer-аутентификаторы). Антипаттерн - запросить "direct" «на всякий случай»: часть платформенных аутентификаторов вернёт self-attestation или вообще откажет, и регистрация молча сломается в части браузеров. Attestation - инструмент с конкретной целью, а не «сделать систему более безопасной».

Эвристика для self-check: возьмите свой production-flow и пройдитесь по списку выше. Если на любом пункте ответ «у нас этого нет, потому что не задумались» - это техдолг с понятной стоимостью первого инцидента (массового локаута, упавшей конверсии, security-нарушения).

Гибридная аутентификация: переходный период

Если у вас новый продукт - вы можете спроектировать вход вокруг passkey с самого начала. Если у вас существующая user base в десятки тысяч аккаунтов с email/password, OAuth, magic link, TOTP - passkey появляется не как замена, а как ещё один фактор в наборе, и задача product-команды - провести transition без потерь конверсии и без локаутов.

Полезная рамка: рассматривать вход как набор из пяти возможных факторов, а не как один «выбранный метод». Эти факторы - email/password, OAuth (Google/Apple/GitHub), magic link, TOTP, passkey. Большинство пользователей будут включать два-три из них; product-задача - не выбрать «правильный» метод, а спроектировать UX так, чтобы пользователь понимал, что у него есть, и не оказывался без вариантов в момент потери одного из них.

Стратегия 1: passkey как primary, OAuth как fallback

Подходит для сервисов, где значительная часть аудитории уже привыкла к Sign in with Google/Apple. Passkey предлагается на главном экране входа («войти быстро, биометрией»); OAuth и пароль доступны через «другие способы». Преимущества: пользователь без passkey проходит знакомым путём; пользователь с passkey получает преимущество в скорости; нет принудительной миграции. Недостаток: вы остаётесь зависимы от OAuth-провайдеров для части аудитории.

Стратегия 2: magic link как переходный мост к passkey

Подходит для сервисов с историей passwordless через email magic link (Slack, Notion, Substack использовали этот паттерн годами). Magic link становится «дефолтным» путём для существующих пользователей, passkey - предложением «давайте сделаем это быстрее: привяжите устройство». Через несколько успешных входов сервис показывает one-tap promo «зарегистрируйте passkey за 30 секунд». Преимущество: пользователь уже привык к passwordless, переход психологически прост. Недостаток: magic link сам по себе уязвим к фишингу (ссылка из письма ведёт на любой сайт), так что переход критичен с security-перспективы.

Стратегия 3: progressive enhancement на следующем входе

Самый осторожный подход. Существующий пользователь продолжает заходить так же, как раньше - email/password или OAuth. После успешного входа сервис показывает one-time баннер «хотите включить passkey? одна биометрия вместо ввода пароля». Если пользователь нажал yes - регистрируется новый passkey, пароль сохраняется как fallback. Если no - больше не показываем минимум 30 дней. Такой подход ловит органическую конверсию, не создаёт давления, не ломает existing flow.

Стратегия 4: device-bound vs synced - сегментация по чувствительности

Для сервисов с разными уровнями чувствительности (бесплатный личный кабинет vs admin-консоль с финансовыми правами) полезно различать типы passkey. Платформенный синхронизируемый passkey (через iCloud/Google) удобен пользователю, но имеет theoretical attack surface на уровне облачного аккаунта платформы. Device-bound passkey (residentKey + authenticatorAttachment: cross-platform с YubiKey, или platform с required HW-binding на тех аутентификаторах, что это поддерживают) сильнее, но менее удобен. Стратегия: для обычного входа достаточно synced; для admin-операций требуется отдельный hardware-bound passkey. Это уже close к WebAuthn step-up authentication, поддерживается через параметр user_verification и через перепроверку при чувствительных действиях.

Стратегия 5: passkey + TOTP как два независимых фактора

В корпоративном/regulated сценарии (финтех, healthcare) одного passkey может быть недостаточно с compliance-перспективы - регулятор требует «two factors». В таких случаях passkey + TOTP даёт одновременно сильную фишинг-устойчивость (passkey не передаётся по фишинговому каналу) и формальное соответствие 2FA-требованиям. UX-цена - один лишний шаг, но в корпоративном сегменте это терпимо.

Общее правило для transition: не убирайте старые методы аутентификации до тех пор, пока не убедитесь, что 95%+ активных пользователей успешно используют новый метод как минимум 30 дней. Раннее отключение пароля или magic link создаёт класс пользователей, которые откроют сервис через полгода и обнаружат, что войти не могут - и поскольку «забыл passkey» не покрывается обычным password-reset flow, support получит поток жалоб.

Прагматичная последовательность для большинства SaaS-команд: (1) текущая система остаётся как есть; (2) passkey добавляется как дополнительный method; (3) после успешного входа - one-tap promo для регистрации passkey; (4) recovery-flow тестируется на 5-10 реальных сценариях; (5) для новых регистраций - passkey предлагается как primary, но не обязателен; (6) через 6-12 месяцев анализируется доля пользователей с активным passkey, и принимается решение о следующем шаге. Никакого «выкатим завтра, паролей больше нет».

Резюме

Индустриальный консенсус: Apple, Google и Microsoft подписали совместное соглашение об ускорении перехода на passkey в 2022 году. Крупные платформы уже перешли или переходят: GitHub, Google, Apple ID, Cloudflare Access, PayPal, Best Buy, eBay и многие другие принимают passkey. Это не экспериментальная технология - это текущий стандарт индустрии.
Не забудьте про fallback: при внедрении passkey всегда оставляйте резервный способ входа. Пользователь может потерять устройство, не иметь доступа к iCloud/Google Account, или использовать устройство без поддержки биометрии. Email magic link или recovery codes - обязательные компоненты production-системы.

Источники

Криптография: первоисточники

FIDO/WebAuthn: стандарты

Реальные внедрения и кейсы

Серверные библиотеки и tooling