Пароли — слабое звено безопасности. Их крадут фишингом, утекают из баз данных, угадывают брутфорсом. Passkey меняет подход принципиально: вместо секрета, который пользователь помнит и вводит, используется асимметричная криптография. Приватный ключ хранится на устройстве и защищён биометрией или PIN, публичный — на сервере. Результат: фишинг невозможен, утечка базы бесполезна, пользователь не запоминает ничего.
Проблемы паролей
Пароли существуют с 1960-х годов и до сих пор остаются основным способом аутентификации — несмотря на то, что за это время стало очевидно: они плохо работают в реальном мире.
Повторное использование
Среднестатистический пользователь имеет десятки, а то и сотни аккаунтов. Запомнить уникальный сложный пароль для каждого невозможно. Результат предсказуем: один пароль используется на многих сервисах. Когда утекает один сервис — злоумышленники проверяют учётные данные на всех остальных (credential stuffing).
Фишинг
Пользователь не может надёжно отличить настоящий сайт от поддельного. Фишинговые страницы, имитирующие популярные сервисы, воруют пароли даже у технически грамотных людей. По данным Google, фишинг — причина большинства захватов аккаунтов.
Утечки баз данных
Даже если сервис хранит пароли правильно — в виде bcrypt или Argon2 хэшей — при утечке базы злоумышленники могут перебрать слабые пароли офлайн на GPU. Хэш "password123" будет взломан за секунды.
Усталость от паролей и слабые пароли
Требования к сложности паролей (заглавная буква, цифра, спецсимвол) не делают их безопаснее — они делают их неудобными. Пользователи приспосабливаются предсказуемо: Password1! формально удовлетворяет всем требованиям, но взламывается мгновенно.
Трение от MFA
Двухфакторная аутентификация снижает риски, но добавляет трение: нужно доставать телефон, переключаться между приложениями, вводить одноразовый код. Часть пользователей отключает MFA из-за неудобства. SMS-коды вдобавок уязвимы к SIM-swapping и перехвату.
Что такое Passkey
Passkey — это учётные данные на основе стандарта FIDO2 / WebAuthn, разработанного альянсом FIDO (Fast IDentity Online) совместно с W3C. Вместо пароля используется пара криптографических ключей.
Асимметричная криптография
При создании passkey устройство генерирует пару ключей:
- Приватный ключ — остаётся на устройстве, никогда не передаётся по сети, защищён аппаратным чипом (Secure Enclave на Apple, TPM на Windows)
- Публичный ключ — передаётся на сервер при регистрации и хранится там
При аутентификации сервер отправляет случайный вызов (challenge). Устройство подписывает его приватным ключом. Сервер проверяет подпись публичным ключом. Приватный ключ при этом никуда не передаётся.
Пользователь Устройство Сервер
| | |
| Вход по биометрии | |
|------------------------->| |
| | (приватный ключ в |
| | Secure Enclave) |
| | |
| | Подписать challenge |
| |<---------------------------|
| | |
| | Подпись(challenge) |
| |--------------------------->|
| | |
| | Проверить подпись |
| | публичным ключом |
| | OK |
|<----------------------------------------------- 200 --|
Привязка к домену (origin binding)
Каждый passkey привязан к конкретному домену (RP ID — Relying Party ID). Passkey, созданный для example.com, физически не может быть использован на evil-example.com. Это делает фишинг невозможным на уровне протокола, а не на уровне "пользователь должен быть внимательным".
Верификация пользователя
Перед использованием приватного ключа устройство требует подтвердить личность пользователя через:
- Биометрию: Touch ID, Face ID, распознавание лица или отпечатка на Android
- PIN-код устройства (как резервный вариант)
Биометрические данные обрабатываются локально на устройстве и никогда не покидают его — ни на сервер, ни к Apple/Google.
Синхронизация между устройствами
Платформенные passkey синхронизируются через облако платформы:
- Apple: iCloud Keychain (сквозное шифрование)
- Google: Google Password Manager
- Windows: Windows Hello (локально, с экспортом через QR)
Как работает регистрация
Регистрация 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
// Сервер использует его для поиска соответствующего публичного ключа
Серверная часть
Сервер отвечает за три задачи: генерацию и хранение 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 (cedarcode)
- Node.js: SimpleWebAuthn
- Python: py_webauthn (Duo Labs)
- Go: go-webauthn/webauthn
- Java: java-webauthn-server (Yubico)
Пример верификации (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
Что проверяет библиотека при верификации
- clientDataJSON.type: должен быть
"webauthn.create"или"webauthn.get" - clientDataJSON.challenge: должен совпадать с challenge из сессии
- clientDataJSON.origin: должен совпадать с ожидаемым доменом — защита от фишинга
- authenticatorData.rpIdHash: SHA-256 от rp.id — должен совпадать
- authenticatorData.flags: бит UP (user presence) и UV (user verification) должны быть установлены
- Подпись: проверяется по публичному ключу из базы данных
Совместимость
Браузеры и платформы
| Платформа | Браузер / Среда | Поддержка | Хранилище ключей |
|---|---|---|---|
| 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), нет платформенного |
Ограничения
- Требуется HTTPS — WebAuthn работает только на HTTPS-сайтах. Исключение:
localhostдля разработки. - Инкогнито-режим — passkey недоступны в инкогнито-вкладках Chrome и приватных окнах Safari (нет доступа к хранилищу). Роуминговые ключи (YubiKey) работают везде.
- Несколько устройств — платформенные passkey синхронизируются только в рамках одной экосистемы. Пользователь с iPhone и Windows-ноутбуком на разных экосистемах может столкнуться с трудностями.
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 защищён от этого на уровне протокола: подпись привязана к origin (домену) и challenge. Подпись для evil.com не принимается на example.com.
Почему Passkey лучше OAuth
OAuth (Sign in with Google/GitHub) удобен, но создаёт зависимость от провайдера. Если Google заблокирует аккаунт или закроет OAuth API для вашего приложения — пользователи потеряют доступ. Passkey хранится на устройстве пользователя и не зависит от третьих сторон.
Резюме
Ключевые принципы Passkey:
- Асимметричная криптография: приватный ключ никогда не покидает устройство
- Привязка к домену на уровне протокола — фишинг невозможен по определению
- Биометрия верифицирует пользователя локально — данные не уходят на сервер
- Утечка базы данных бесполезна — там хранится только публичный ключ
- Синхронизация через iCloud Keychain / Google Password Manager — не нужно переносить ключи вручную
- Cross-device auth через QR + Bluetooth proximity — работает между экосистемами
- Стандарт FIDO2/WebAuthn поддерживается всеми основными браузерами и платформами