Перейти к основному содержимому

Passkeys и WebAuthn

Разработчику

Passkey (ключ доступа) — способ входа через стандарты WebAuthn (Web Authentication) и FIDO2 (Fast IDentity Online 2). Пользователь подтверждает личность биометрией, PIN или физическим ключом (YubiKey). На сервер уходит криптографическая подпись, пароль по сети не передаётся.

Фишинговый сайт-клон не сможет использовать ключ — браузер привязан к origin (домену и схеме https://). Это принципиальное отличие от классического пароля, который можно ввести на поддельной странице.

Базовая теория auth — 8.07/116, OAuth как дополнение — OIDC и OAuth.


Ключевые термины

ТерминЗначение
Relying Party (RP)Ваш сервис — сайт или API, который принимает вход
AuthenticatorУстройство или ПО, хранящее ключ (Touch ID, Hello, YubiKey)
CredentialПара ключей + идентификатор, зарегистрированная на RP
ChallengeОдноразовая случайная строка от сервера для подписи
AssertionОтвет authenticator при входе (подпись challenge)
rpIdДомен, к которому привязан ключ (например example.com)
Discoverable credentialКлюч с resident storage — вход без ввода логина

Как это работает

Регистрация и вход

ЭтапРегистрацияВход
СерверВыдаёт challenge, сохраняет credential id + public keyВыдаёт challenge для известного credential
Клиентnavigator.credentials.create()navigator.credentials.get()
Хранение секретаТолько на устройстве (Secure Enclave, TPM)
ПроверкаAttestation (опционально), origin, rpIdSignature, sign count, origin

Sign count — счётчик подписей authenticator. Если значение не растёт при повторных входах, возможна копия ключа (replay клонированного authenticator).


Режимы passkeys

1. Platform authenticator

Встроенный в устройство — Face ID, Touch ID, Windows Hello. Удобно для массового B2C.

2. Roaming (security key)

Переносной ключ YubiKey, Feitian. Часто для админов, DevOps и корпоративного доступа.

3. Discoverable credentials (resident keys)

Вход без логина — браузер предлагает "Continue with passkey", как у Apple и Google. Требует хранения credential на устройстве.

4. Synced passkeys

Синхронизация между устройствами через iCloud Keychain или Google Password Manager. Удобство выше, доверие смещается к экосистеме вендора.


Реализация на практике

Используйте проверенные библиотеки

Криптографию WebAuthn на сервере пишут редко — используйте готовые решения:

СтекБиблиотека / сервис
Node.js@simplewebauthn/server, @simplewebauthn/browser
Pythonpy_webauthn, плагины Django/Flask
.NETFido2.AspNet
Javawebauthn4j
Готовый IdPAuth0, Keycloak, Firebase, Microsoft Entra ID

Официальные гайды — passkeys.dev, спецификация — W3C WebAuthn.


Пошагово — регистрация passkey (Node.js)

Шаг 1. Сервер генерирует options

import { generateRegistrationOptions } from '@simplewebauthn/server';

const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'localhost',
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
// Сохранить options.challenge в сессии

Шаг 2. Браузер вызывает create

import { startRegistration } from '@simplewebauthn/browser';

const attResp = await startRegistration({ optionsJSON: options });
await fetch('/webauthn/register', {
method: 'POST',
body: JSON.stringify(attResp),
});

Шаг 3. Сервер проверяет и сохраняет

import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: session.challenge,
expectedOrigin: 'https://localhost:3000',
expectedRPID: 'localhost',
});
// Сохранить verification.registrationInfo.credentialID и credentialPublicKey в БД

Ожидаемый результат: запись в таблице webauthn_credentials с credential_id, public_key, sign_count.


Пошагово — вход по passkey

// Сервер
const options = await generateAuthenticationOptions({
rpID: 'example.com',
allowCredentials: [{ id: credentialId, type: 'public-key' }],
});
// Клиент
const assertion = await startAuthentication({ optionsJSON: options });
// Сервер
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge: session.challenge,
expectedOrigin: 'https://example.com',
expectedRPID: 'example.com',
authenticator: storedCredential,
});
// Обновить sign_count в БД

Требования к серверу (обязательно)

  • Генерировать случайный challenge (32+ байта) и хранить в сессии до проверки (one-time)
  • Проверять origin и rpId — совпадение с вашим доменом
  • Сохранять public key, credential id, sign count
  • Поддерживать fallback — recovery codes, второй фактор, OAuth — потеря устройства не должна блокировать аккаунт навсегда
  • Использовать HTTPS в prod (WebAuthn требует secure context, кроме localhost)

UX — как не потерять пользователей

РекомендацияПочему
Предлагать passkey после первого успешного входа по паролю/OAuthПользователь уже доверяет аккаунту
Объяснить, что ключ привязан к этому сайтуПутают с "общим паролем Apple ID"
На desktop без биометрии — QR для телефонаCross-device auth по стандарту
Не удалять пароль сразуПлавный переход, recovery
Показать список устройств с passkey в настройкахУправление и отзыв

Passkeys (WebAuthn) и OAuth "Войти через Google"

КритерийPasskeys (WebAuthn)OAuth "Войти через Google"
Кто хранит identityВаш RPGoogle как IdP (Identity Provider)
Фишинг пароляПароль не используетсяЗависит от Google и redirect URI
Привязка к доменуЖёсткая (WebAuthn origin)Redirect URI, client_id
Типичный кейсОсновной login на вашем сайтеДелегирование учётки
Offline / локальный ключRoaming key возможенНужна сеть к IdP

Часто комбинируют — OAuth для onboarding, passkey как основной login или второй фактор — OIDC и OAuth.


Хранение данных в БД

Минимальная схема (логическая):

ПолеТипНазначение
user_idUUIDСвязь с пользователем
credential_idbytea / base64Идентификатор credential
public_keybyteaПроверка подписи
sign_countintegerAnti-replay
transportstext[]usb, nfc, internal
created_attimestampАудит

Публичный ключ — не секрет. Приватный ключ никогда не покидает authenticator.


Ошибки и риски

ОшибкаПоследствиеКак избежать
Challenge reuseReplay старого assertionOne-time challenge, TTL 5 мин
Слабый rpIdКлюч работает на чужом поддоменеЯвная политика поддоменов
Нет recoveryБлокировка аккаунтаBackup codes, второй фактор
Игнор sign countКлон authenticator незаметенОбновлять и проверять счётчик
Passkeys как единственный канал supportСоциальная инженерия на сбросIdentity proof по процедуре 8.07/133

Passkeys снижают классический фишинг пароля, но не закрывают BEC (Business Email Compromise, компрометация деловой почты) — социальная инженерия.


Troubleshooting для разработчика

СимптомВозможная причинаДействие
NotAllowedErrorПользователь отменил, нет UVПроверить userVerification
SecurityErrorНеверный origin/rpIdСравнить URL и rpID в логах
Работает локально, нет в prodHTTP вместо HTTPSВключить TLS
Не находит credentialДругой authenticatorallowCredentials, discoverable flow
sign_count mismatchКопия ключа или багОтозвать credential, перерегистрация

Тестирование

  • localhost — исключение для secure context в браузерах
  • Инструменты — WebAuthn.io для ручных тестов
  • E2E — Playwright с виртуальным authenticator (Chrome DevTools protocol)
  • Регрессия — сценарии "новое устройство", "потеря ключа", "отзыв credential"

Чек-лист внедрения passkeys

  • HTTPS в prod, корректный rpId
  • Challenge одноразовый, в сессии или Redis с TTL
  • Проверка origin на сервере
  • sign_count обновляется
  • Fallback и recovery документированы
  • UX — предложение после первого входа
  • Страница управления устройствами в личном кабинете
  • Логи аудита регистрации и входа

Безопасность enterprise — passkeys в корпорации

СценарийРекомендация
Все сотрудники на WindowsWindows Hello + Entra ID passkeys
Админы инфраструктурыRoaming YubiKey + запасной ключ в сейфе
B2C массовый сервисPlatform passkeys + fallback SMS/email recovery
Регуляторные требованияДокументировать методы recovery и аудит

Attestation — проверка модели authenticator при регистрации. В enterprise можно разрешать только определённые типы ключей (например, только FIDO2 certified).


WebAuthn в мобильных приложениях

Нативные приложения используют passkeys через:

  • iOS — ASAuthorizationPlatformPublicKeyCredentialProvider
  • Android — Credential Manager API
  • Кросс-платформа — Flutter/React Native плагины поверх тех же API

Веб-вью внутри приложения часто не поддерживает WebAuthn полноценно — предпочтительны нативные SDK или системный браузер (Custom Tabs / ASWebAuthenticationSession).


Тестовые сценарии — таблица QA

#СценарийОжидание
1Регистрация passkey на Chrome/Windows HelloУспех, credential в БД
2Вход с тем же passkeyУспех, sign_count +1
3Вход с другого браузера без synced keyОшибка или fallback
4Поддельный origin в DevToolsSecurityError на сервере
5Повтор assertion со старым challengeОтказ
6Удаление passkey в ЛКСледующий вход только через fallback
7Cross-device QR (desktop)Успех с телефона

Миграция с паролей на passkeys — план команды

ФазаДействие
1Добавить опциональную регистрацию passkey после логина
2Показать баннер "защитите аккаунт ключом"
3Метрика adoption % пользователей с passkey
4Для новых пользователей — passkey-first onboarding
5Сократить политику паролей для оставшихся (длинный пароль + MFA)

Не отключайте пароли массово до recovery flow и поддержки.


API и мобильный backend — JWT после WebAuthn

После успешной проверки WebAuthn сервер выдаёт сессию или JWT (JSON Web Token):

// После verifyAuthenticationResponse === true
const accessToken = jwt.sign(
{ sub: user.id, amr: ['webauthn'] },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);

Поле amr (Authentication Methods References) документирует, что вход был по passkey — полезно для step-up auth на чувствительных операциях. См. 8.07/116.


Конфигурация rpId и поддоменов

rpIdРаботает на
example.comexample.com, www.example.com, app.example.com
app.example.comТолько app.example.com

Ошибка — rpId: example.com при хостинге на tenant1.example.com и tenant2.example.com без единой политики — ключ может работать шире, чем задумано.


Соответствие стандартам

СтандартСвязь с passkeys
NIST SP 800-63BУровни assurance для аутентификаторов
PSD2 SCAСильная аутентификация для платежей в ЕС
FIDO2Базовый стандарт passkeys

Passkeys соответствуют phishing-resistant аутентификаторам в терминологии NIST AAL2–AAL3 при правильной настройке UV (user verification).


Дополнительные ресурсы и демо

РесурсURL
W3C WebAuthn Level 3https://www.w3.org/TR/webauthn-3/
passkeys.devhttps://passkeys.dev/
WebAuthn.io demohttps://webauthn.io/
SimpleWebAuthn docshttps://simplewebauthn.dev/
FIDO Alliancehttps://fidoalliance.org/

Вопросы для самопроверки

  1. Где хранится приватный ключ passkey?
  2. Что такое challenge и почему он одноразовый?
  3. Чем platform authenticator отличается от roaming?
  4. Зачем проверять sign_count?
  5. Какой fallback нужен при потере устройства?

Кейс — внедрение passkeys в B2C fintech

Контекст. Мобильный банк с 2M пользователей, высокий риск фишинга SMS и паролей.

Подход:

  1. Фаза 1 — optional passkey после успешного входа по паролю + SMS OTP.
  2. Фаза 2 — баннер "защитите вход ключом" с коротким видео.
  3. Фаза 3 — passkey-first для новых регистраций на iOS/Android 16+.
  4. Recovery — видеозвонок в поддержку + паспорт для high-value операций.

Метрики через 6 месяцев:

  • Adoption passkey ~35% MAU
  • Снижение ticket "не могу войти" на 12% за счёт synced keys
  • Zero successful password phishing на аккаунтах только с passkey

Связь с OIDC — социальный login оставили для onboarding, passkey стал primary для returning users.


Кейс — корпоративный доступ админов через YubiKey

Контекст. DevOps-команда с root-доступом к AWS и K8s.

Политика:

  • Roaming FIDO2 key (YubiKey 5) — обязателен для admin panel
  • Запасной ключ в сейфе офиса — второй credential на аккаунт
  • Attestation — только FIDO Certified keys
  • Platform passkey (Hello) — запрещён для admin tier

Полный пример API routes — Express + SimpleWebAuthn

// routes/webauthn.js — упрощённый каркас
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const router = express.Router();
const rpID = process.env.RP_ID || 'localhost';
const origin = process.env.ORIGIN || 'https://localhost:3000';

router.post('/register/options', async (req, res) => {
const user = req.session.user;
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID,
userID: user.id,
userName: user.email,
attestationType: 'none',
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
});
req.session.challenge = options.challenge;
res.json(options);
});

router.post('/register/verify', async (req, res) => {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified) return res.status(400).json({ error: 'Verification failed' });
await saveCredential(req.session.user.id, verification.registrationInfo);
delete req.session.challenge;
res.json({ ok: true });
});

router.post('/login/options', async (req, res) => {
const creds = await getCredentialsForUser(req.body.email);
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: creds.map(c => ({ id: c.credentialId, type: 'public-key' })),
});
req.session.challenge = options.challenge;
res.json(options);
});

router.post('/login/verify', async (req, res) => {
const cred = await getCredential(req.body.id);
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: cred,
});
if (!verification.verified) return res.status(401).json({ error: 'Auth failed' });
await updateSignCount(cred.id, verification.authenticationInfo.newCounter);
req.session.userId = cred.userId;
res.json({ ok: true });
});

export default router;

Сессии — Redis с TTL для challenge. Prod — HTTPS, trust proxy за load balancer.


Discoverable credentials — passwordless UX

Для входа без email используйте resident keys (discoverable credentials):

const options = await generateAuthenticationOptions({
rpID: 'example.com',
userVerification: 'preferred',
// allowCredentials не указываем — браузер предложит все ключи для rpId
});

UX — кнопка "Continue with passkey" как у passkeys.dev. Fallback — "Use another method" с email + magic link.


CI — E2E тест WebAuthn в Playwright

# .github/workflows/webauthn-e2e.yml
name: webauthn-e2e
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run build
- run: npx playwright install chromium
- name: Run WebAuthn tests
run: npx playwright test tests/webauthn.spec.ts
env:
CI: true
// tests/webauthn.spec.ts — виртуальный authenticator
import { test, expect } from '@playwright/test';

test('register and login passkey', async ({ page, context }) => {
const client = await context.newCDPSession(page);
await client.send('WebAuthn.enable');
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
await page.goto('https://localhost:3000/login');
await page.getByRole('button', { name: 'Sign in with passkey' }).click();
await expect(page).toHaveURL(/dashboard/);
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
});

Локально — playwright test с HTTPS dev-сервером (mkcert).


Расширенный глоссарий WebAuthn

ТерминОпределение
CTAP2Client to Authenticator Protocol — протокол браузер ↔ ключ
AttestationПодпись производителя authenticator при регистрации
AAGUIDAuthenticator Attestation GUID — модель устройства
User verification (UV)PIN или биометрия перед подписью
User presence (UP)Подтверждение "человек нажал"
Resident keyCredential хранится на устройстве — discoverable login
PRF extensionPseudo-random function — деривация секретов из passkey
Hybrid transportQR + Bluetooth для cross-device auth
Secure EnclaveИзолированный чип Apple для ключей
TPMTrusted Platform Module — аппаратный модуль Windows
AALAuthenticator Assurance Level — уровень NIST 800-63B
AMRAuthentication Methods References — claim в JWT о способе входа

Что делать если — troubleshooting passkeys

Пользователь потерял телефон с единственным passkey

  1. Recovery codes — одноразовые коды при регистрации passkey.
  2. Backup passkey на втором устройстве — предлагайте при onboarding.
  3. Identity verification в support — 8.07/133.
  4. После recovery — отзыв старого credential по credential_id.

Passkey работает в Chrome, но не в Safari

  1. Проверьте rpId — Safari строже к subdomain.
  2. iCloud Keychain sync — пользователь должен быть залогинен в Apple ID.
  3. userVerification: 'required' может блокировать без биометрии — попробуйте preferred.

Enterprise блокирует WebAuthn на прокси

  1. Secure context требует HTTPS end-to-end.
  2. Некоторые прокси режут WebAuthn API — whitelist домена RP.
  3. Fallback — roaming key по USB, минуя corporate proxy для auth domain.

sign_count уменьшился

  1. Возможен clone authenticator или restore из backup.
  2. Отзовите credential, force re-registration.
  3. Алерт в SIEM — аномалия входа.

Cross-device QR не сканируется

  1. Проверьте hybrid transport в authenticatorSelection.
  2. Desktop и phone должны быть в одной экосистеме или поддерживать FIDO cross-device.
  3. Документируйте "use phone to sign in" в help center.

Расширенный FAQ

"Passkeys заменяют MFA?"

Passkey с UV часто считается MFA-equivalent (something you have + something you are). Для high-risk операций добавляйте step-up — повторный WebAuthn или аппаратный ключ.

"Нужна ли attestation в B2C?"

Обычно attestationType: 'none' — проще UX. Enterprise admin — attestation для allowlist моделей ключей.

"Как мигрировать 1M пользователей с паролей?"

Постепенно — optional passkey, метрики adoption, passkey-first для новых. Пароли оставьте до стабильного recovery. См. раздел "Миграция с паролей".

"WebAuthn и LDAP/Active Directory?"

Entra ID и Okta поддержива passkeys как федерацию. On-prem AD — через FIDO2 security keys + Windows Hello for Business.

"Хранить credential_id в plain text?"

credential_id публичен по дизайну. public_key тоже. Шифруйте at-rest по политике, но это не секреты как пароли.

"Passkeys для API-only mobile app?"

Credential Manager API (Android) / ASAuthorization (iOS). Backend тот же — challenge/verify. Не используйте WebView для WebAuthn.


Безопасность session после WebAuthn

// Короткая сессия + rotation
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 15 * 60 * 1000,
},
}));

Sensitive operations — повторный generateAuthenticationOptions с userVerification: 'required'.


Сравнение способов входа — таблица для product

СпособФишингUXRecoveryПодходит для
ПарольВысокий рискЗнакомоEmail resetLegacy
SMS OTPSIM-swapСреднеSupportFallback
OAuth GoogleЗависит от IdPБыстроGoogle accountOnboarding
Passkey platformНизкийОтличноBackup keyB2C primary
YubiKey roamingНизкийНужен ключЗапасной ключAdmin

Часто комбинируют passkey + OAuth + recovery codes — OIDC.


Связанные материалы


Содержание