Коротко. Ключ доступа (passkey) — это обнаруживаемые учётные данные WebAuthn: пара ключей, которую аутентификатор (телефон, защищённое оборудование ноутбука, менеджер паролей или ключ безопасности) создаёт для одного сайта — доверяющей стороны (relying party, RP). При регистрации сервер получает открытый ключ, при входе — подпись challenge, который выдал сервер. Сервер должен проверять в каждом ответе challenge, источник (origin), RP ID и флаг проверки пользователя. Запрашивайте residentKey: "required" и userVerification: "required", предлагайте алгоритмы -8, -7 и -257, оставьте для аттестации значение "none", если только вы не организация, которая ограничивает допустимые модели аутентификаторов, и рассчитывайте на то, что большинство ключей доступа будет синхронизироваться между устройствами.
Вся модель в одной таблице
| Термин | Что это значит для вашего кода |
|---|---|
| Доверяющая сторона (RP) и RP ID | Ваш сайт. RP ID — это домен, например example.com; учётные данные работают на этом домене и его поддоменах и больше нигде |
| Обнаруживаемые учётные данные (discoverable credential) | Хранятся на аутентификаторе вместе с идентификатором пользователя (user handle), поэтому для входа не нужно имя пользователя. В WebAuthn Level 3 для них указано и другое название — «passkey» |
Идентификатор пользователя (user.id) | Ваш непрозрачный идентификатор аккаунта, не длиннее 64 байт; возвращается при входе. Никогда не используйте в нём адрес электронной почты |
| Присутствие пользователя (UP) и проверка пользователя (UV) | UP: кто-то коснулся аутентификатора или подтвердил действие. UV: аутентификатор проверил PIN-код или биометрию. Это флаги в данных аутентификатора |
| Резервное копирование: допустимо (BE) и выполнено (BS) | Учётные данные, которые можно синхронизировать или которые уже синхронизированы с другими устройствами: синхронизируемый ключ доступа, в спецификации — «учётные данные для нескольких устройств» (multi-device credential) |
| Аттестация и AAGUID | Необязательная цепочка сертификатов, которая сообщает, какая модель аутентификатора создала ключ; AAGUID идентифицирует модель |
Флаги в данных аутентификатора: бит 0 — UP, бит 2 — UV, бит 3 — BE, бит 4 — BS, бит 6 — AT (включены сведения об аттестованных учётных данных) и бит 7 — ED (расширения) (WebAuthn Level 3, § 6.1). WebAuthn Level 3 стал рекомендацией W3C 25 августа 2026 года.
Сервер: обе церемонии
Ниже — полная реализация доверяющей стороны на @simplewebauthn/server 14.0.3. Для краткости данные хранятся в памяти; в рабочей системе три словаря — это таблицы базы данных, а challenge хранится в сессии пользователя на стороне сервера.
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use
export async function registrationOptions(userName, { attestation = "none" } = {}) {
const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
users.set(userName, user);
const options = await generateRegistrationOptions({
rpName: "Example",
rpID,
userName,
userID: user.id,
attestationType: attestation,
authenticatorSelection: { residentKey: "required", userVerification: "required" },
supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
});
pending.set(`reg:${userName}`, options.challenge);
return options;
}
export async function finishRegistration(userName, response) {
const expectedChallenge = pending.get(`reg:${userName}`);
pending.delete(`reg:${userName}`);
const { verified, registrationInfo: info } = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: true,
});
if (!verified) throw new Error("registration not verified");
const { credential } = info;
credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
users.get(userName).credentials.push(credential.id);
return info;
}
export async function authenticationOptions() {
// Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
pending.set(`auth:${options.challenge}`, options.challenge);
return options;
}
export async function finishAuthentication(response, challenge) {
if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
const stored = credentials.get(response.id);
if (!stored) throw new Error("unknown credential");
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
requireUserVerification: true,
});
if (!verified) throw new Error("authentication not verified");
stored.counter = authenticationInfo.newCounter;
return { userName: stored.userName, ...authenticationInfo };
}
Что проверяют вызовы функций verify и почему важен каждый пункт:
- Challenge. Случайный, одноразовый, привязан к этой церемонии. Ответ со старым challenge — это повторное воспроизведение (replay).
- Источник (origin). Браузер записывает источник страницы в
clientDataJSON, а аутентификатор подписывает хеш этих данных. Именно сравнение источника с вашим списком источников делает ключи доступа устойчивыми к фишингу: ответ, созданный на сайте-двойнике, содержит его собственный источник. - Хеш RP ID. Данные аутентификатора начинаются с SHA-256 от RP ID, поэтому учётные данные другого сайта нельзя использовать на вашем.
- Флаги. UP должен быть установлен; UV должен быть установлен, если вы полагаетесь только на ключ доступа, а ради этого ключи доступа и нужны.
- Подпись данных аутентификатора и хеша клиентских данных; её проверяют открытым ключом, сохранённым при регистрации.
- Счётчик. Многие синхронизируемые ключи доступа всегда сообщают 0. WebAuthn считает уменьшение счётчика признаком возможного клонирования, только если в сравнении участвует ненулевое значение (§ 6.1.1); никогда не отклоняйте ноль.
Браузер: два вызова
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.create({ publicKey });
return credential.toJSON(); // send to the server as-is
}
async function signIn(optionsJSON) {
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
const credential = await navigator.credentials.get({ publicKey });
return credential.toJSON();
}
parseCreationOptionsFromJSON, parseRequestOptionsFromJSON и toJSON входят в WebAuthn Level 3 и заменяют код преобразования base64url, который раньше приходилось держать в каждом проекте. Чтобы поле имени пользователя предлагало ключи доступа в автозаполнении браузера, вызовите navigator.credentials.get() с mediation: "conditional" при загрузке страницы, предварительно проверив PublicKeyCredential.isConditionalMediationAvailable(), и пометьте поле ввода атрибутом autocomplete="username webauthn".
Сквозной прогон
На стенде Chromium управлялся через Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble), а виртуальный аутентификатор был добавлен через Chrome DevTools Protocol. Страница на http://localhost:3000 выполняла browser.js; управляющий скрипт передавал в неё параметры из server.mjs и забирал ответы. Каждая строка получена при настоящей регистрации или настоящем входе:
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
Что показывает каждая строка:
- Алгоритмы. В параметрах были предложены
-8,-7и-257; аутентификатор выбрал-8(EdDSA) — первый из тех, что он поддерживает. Порядок в списке задаёт предпочтения. excludeCredentialsработает — с ошибкой InvalidStateError, из которой ваш интерфейс узнаёт: «на этом устройстве уже есть ключ доступа для этого аккаунта».- Вход без имени пользователя. Список
allowCredentialsбыл пуст; аутентификатор предложил свои обнаруживаемые учётные данные, а сервер нашёл аккаунт по идентификатору учётных данных и сохранённому для него открытому ключу. - Повторное воспроизведение отсекается на проверке challenge. Вторая попытка не проходит именно потому, что challenge удаляется при использовании.
- Браузер блокирует не каждый неверный источник. Страница на
http://localhost:4000смогла создать учётные данные для RP IDlocalhost, потому что RP ID охватывает все порты и поддомены этого домена. Ответ отклонила только проверка источника на сервере: Unexpected registration response origin. Задавайте вexpectedOriginточный список. - Синхронизируемые ключи доступа сообщают о себе сами. Когда у аутентификатора была включена допустимость резервного копирования, SimpleWebAuthn сообщил
multiDeviceиbackedUp true. - За требованием UV следит браузер. С аутентификатором, который не мог проверить пользователя, браузер отказал, выбросив общую ошибку NotAllowedError;
requireUserVerificationна стороне сервера — второй рубеж.
Внедрение ключей доступа наряду с паролями
Мало какой сайт может перевести все аккаунты разом. WebAuthn Level 3 добавляет всё нужное для постепенного перехода, и поддержку каждой из этих возможностей можно определить через PublicKeyCredential.getClientCapabilities():
- Предлагайте ключ доступа сразу после входа по паролю. При наличии возможности
conditionalCreateвызовnavigator.credentials.create()сmediation: "conditional"позволяет менеджеру паролей, который только что подставил пароль, создать ключ доступа без лишнего диалога. - Вход из поля имени пользователя. Возможность
conditionalGet— это описанный выше сценарий с автозаполнением: пользователи с ключом доступа выбирают его, остальные, как и раньше, вводят пароль. - Синхронизируйте список на аутентификаторе со своим. Когда пользователь удаляет ключ доступа в настройках аккаунта на вашем сайте, аутентификатор продолжает его показывать.
signalUnknownCredential()сообщает аутентификатору, что идентификатор учётных данных больше не принимается,signalAllAcceptedCredentials()передаёт полный список для вошедшего пользователя, аsignalCurrentUserDetails()обновляет отображаемое имя после того, как пользователь переименовал свой аккаунт (§ 5.1.10). - Несколько доменов, один RP ID. Бренд, у которого вход работает на
example.comиexample.co.uk, может оставить один RP ID и перечислить остальные источники вhttps://example.com/.well-known/webauthnв виде{"origins": [...]}. Браузер запрашивает этот файл, когда RP ID не совпадает с собственным доменом страницы (запросы связанных источников — related origin requests, § 5.11); если запрос не удался, результатом будет ошибкаSecurityError, показанная ниже. - Сохраняйте BE и BS для каждых учётных данных. Пользователь, у которого единственный ключ доступа привязан к одному устройству и не имеет резервной копии, потеряет доступ вместе с этим устройством; предложите ему добавить второй ключ, прежде чем отключать ему вход по паролю.
Аттестация: когда она нужна
Аттестация отвечает на вопрос «какая модель аутентификатора создала этот ключ». Она ничего не говорит ни о пользователе, ни об уровне обновлений устройства, ни о том, получены ли на нём root-права. При attestation: "none" (это значение по умолчанию) браузер заменяет любое заявление об аттестации (attestation statement) пустым ещё до того, как его увидит ваш сервер. Тем не менее стенд получил AAGUID аутентификатора: WebAuthn Level 2 предписывал браузерам в этом случае заменять его 16 нулевыми байтами, а Level 3 этот шаг убрал. Без аттестации AAGUID — лишь утверждение, а не доказательство.
Не требуйте аттестацию на сайте для массовой аудитории. Синхронизируемые ключи доступа задуманы так, чтобы следовать за пользователем на любое устройство, и их поставщики, как правило, их не аттестуют: Google при запуске ключей доступа на Android заявила, что у них «поначалу не будет никакой аттестации» (рассылка fido-dev, 2022). Виртуальный аутентификатор стенда на запрос "direct" вернул заявление формата packed: это показывает, что отправляет аутентификатор с аттестацией, но не то, как поступает конкретный поставщик синхронизации, поэтому проверяйте тех поставщиков, которые на самом деле есть у ваших пользователей. Запрашивайте аттестацию, когда регистрацию обязательно нужно ограничить определённым набором моделей, например ключами безопасности, которые выдаёт ваша компания, и проверяйте цепочку по службе метаданных FIDO (FIDO Metadata Service). Чтобы показывать пользователям понятное название поставщика их ключа доступа («Google Password Manager», «1Password»), есть поддерживаемый сообществом список AAGUID аутентификаторов ключей доступа, который сопоставляет AAGUID с названиями; в его собственном README сказано, что он предназначен только для отображения и не служит основанием для решений, касающихся безопасности.
Алгоритмы COSE
В pubKeyCredParams перечисляются идентификаторы алгоритмов COSE (реестр COSE в IANA). Пример в самой спецификации допускает EdDSA, ES256 и RS256 и отдаёт предпочтение EdDSA; такой список покрывает практически все используемые аутентификаторы:
| Значение | Название | Примечания |
|---|---|---|
| -8 | EdDSA | В WebAuthn только Ed25519 (§ 5.8.5); RFC 9864 пометил его в реестре COSE как устаревший в пользу -19 Ed25519, но аутентификаторы по-прежнему выдают -8 |
| -7 | ES256 | ECDSA P-256; поддерживается шире всех. В COSE объявлен устаревшим в пользу -9 ESP256, ситуация та же |
| -257 | RS256 | RSA PKCS#1 v1.5; Windows Hello в Windows 10 и в части установок Windows 11 создаёт только учётные данные RS256 (Yubico в рассылке W3C) |
| -48, -49, -50 | ML-DSA-44, -65, -87 | Зарегистрированы в RFC 9964 (май 2026 года). Виртуальный аутентификатор Chromium 156 принял -49 (см. ниже); прежде чем полагаться на них, проверьте реальные аутентификаторы и свою серверную библиотеку |
Оставляйте в списке -7 и -257, даже если предпочитаете -8: сама спецификация при пустом pubKeyCredParams по умолчанию использует ES256 и RS256. Храните алгоритм вместе с учётными данными; SimpleWebAuthn держит его внутри сохранённого открытого ключа COSE.
Ошибки DOMException
navigator.credentials.create() и get() сообщают о сбоях через небольшой набор исключений, перечисленных в WebAuthn Level 3, § 5.1.3.1 и § 5.1.4.3. Вот что выбрасывал Chromium 156 при типичных ошибках с виртуальным USB-ключом безопасности, который не умеет хранить обнаруживаемые учётные данные:
only an unknown algorithm (-65000) NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49) created, COSE alg -49
residentKey required, key cannot store it NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes TypeError: User handle exceeds 64 bytes.
aborted with AbortController AbortError: signal is aborted without reason
| Исключение | Значение по спецификации | Что делать |
|---|---|---|
NotAllowedError | Общий случай: пользователь отменил операцию, истекло время ожидания, нет подходящих учётных данных, ни один аутентификатор не выполнил требования | Считайте, что операция «не завершилась»; предложите повторить попытку и другой способ входа. Никогда не показывайте исходный текст ошибки |
InvalidStateError | Аутентификатор распознал одну из записей excludeCredentials | Сообщите пользователю, что на этом устройстве уже есть ключ доступа для вашего сайта |
SecurityError | RP ID — не домен страницы и не его регистрируемый суффикс, либо не прошла проверка связанных источников | Исправьте конфигурацию; см. страницу об ошибке SecurityError |
NotSupportedError | В pubKeyCredParams нет пригодного алгоритма | Предложите -7 и -257 |
ConstraintError | Требуется residentKey или userVerification, но подходящего аутентификатора нет | На практике обрабатывайте так же, как NotAllowedError |
AbortError | Ваш AbortController отменил вызов | Ожидаемо при переключении с автозаполнения на модальный запрос |
TypeError | Некорректные параметры, например идентификатор пользователя длиннее 64 байт | Ошибка в ваших параметрах |
Два результата стенда расходятся со списком в спецификации: Chromium ответил NotAllowedError, а не NotSupportedError, когда единственный алгоритм был неизвестен, и NotAllowedError, а не ConstraintError, когда обнаруживаемые учётные данные требовались от ключа, который не может их хранить. Код, который по NotSupportedError или ConstraintError выбирает объяснение для пользователя, в этих случаях никогда не получит их от Chromium. Оба API доступны только в безопасных контекстах (secure contexts). На странице, которую открыли по обычному HTTP и не на localhost, Chromium 156 сообщил isSecureContext: false, а navigator.credentials, PublicKeyCredential и crypto.subtle оказались undefined, поэтому код падает с TypeError ещё до начала любой церемонии (вариант этой ошибки для Web Crypto — crypto.subtle is undefined). Отдавайте все страницы, где используются ключи доступа, по HTTPS; единственное исключение для разработки — http://localhost.
Контрольный список
- RP ID — регистрируемый домен, который видят пользователи;
expectedOrigin— явный список HTTPS-источников, включая каждый поддомен, на котором выполняется вход. residentKey: "required",userVerification: "required", сервер проверяет UV в каждом ответе.pubKeyCredParamsс-8,-7,-257;excludeCredentialsзаполнен существующими учётными данными пользователя.- Challenge случайный, одноразовый, с коротким сроком жизни и хранится на стороне сервера.
- Для учётных данных хранятся идентификатор, открытый ключ, счётчик, транспорты, флаги BE и BS, AAGUID и имя, которое пользователь может изменить; пользователь может зарегистрировать несколько ключей и удалить их.
- Аттестация
"none", если вы не ограничиваете модели аутентификаторов; если ограничиваете — проверка по FIDO MDS. - Восстановление доступа к аккаунту не слабее самого ключа доступа: второй ключ доступа или проверенный способ восстановления, а не код из SMS, который обходит всё остальное.
Источники
- Web Authentication Level 3, рекомендация W3C, 25 августа 2026 года: § 5.1.3.1, § 5.1.4.3, § 5.8.5, § 6.1, § 6.1.1
- Реестр алгоритмов COSE в IANA; RFC 9864; RFC 9964
- Документация SimpleWebAuthn
- Служба метаданных FIDO (FIDO Metadata Service)
- Список AAGUID аутентификаторов ключей доступа
- Chrome DevTools Protocol: домен WebAuthn