Перейти к содержимому
ecdsa.com
← Все статьи

Ключи доступа для разработчиков: WebAuthn без догадок

Ключ доступа — это учётные данные WebAuthn, с которыми пользователь входит на сайт, не вводя ни имя пользователя, ни пароль. API браузера невелик, а вот решений вокруг него немало. Ниже — обе церемонии от начала до конца, обязательные проверки на сервере и точные ошибки, которые выдаёт Chromium; всё это получено при прогоне с виртуальным аутентификатором.

Коротко. Ключ доступа (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 хранится в сессии пользователя на стороне сервера.

server.mjs (@simplewebauthn/server 14.0.3)
// 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.js
// 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, Node.js в образе Playwright)
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 ID localhost, потому что 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; такой список покрывает практически все используемые аутентификаторы:

ЗначениеНазваниеПримечания
-8EdDSAВ WebAuthn только Ed25519 (§ 5.8.5); RFC 9864 пометил его в реестре COSE как устаревший в пользу -19 Ed25519, но аутентификаторы по-прежнему выдают -8
-7ES256ECDSA P-256; поддерживается шире всех. В COSE объявлен устаревшим в пользу -9 ESP256, ситуация та же
-257RS256RSA PKCS#1 v1.5; Windows Hello в Windows 10 и в части установок Windows 11 создаёт только учётные данные RS256 (Yubico в рассылке W3C)
-48, -49, -50ML-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-ключом безопасности, который не умеет хранить обнаруживаемые учётные данные:

Вывод: errors.mjs (Chromium 156.0.8078.4)
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Сообщите пользователю, что на этом устройстве уже есть ключ доступа для вашего сайта
SecurityErrorRP 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.

Контрольный список

  1. RP ID — регистрируемый домен, который видят пользователи; expectedOrigin — явный список HTTPS-источников, включая каждый поддомен, на котором выполняется вход.
  2. residentKey: "required", userVerification: "required", сервер проверяет UV в каждом ответе.
  3. pubKeyCredParams с -8, -7, -257; excludeCredentials заполнен существующими учётными данными пользователя.
  4. Challenge случайный, одноразовый, с коротким сроком жизни и хранится на стороне сервера.
  5. Для учётных данных хранятся идентификатор, открытый ключ, счётчик, транспорты, флаги BE и BS, AAGUID и имя, которое пользователь может изменить; пользователь может зарегистрировать несколько ключей и удалить их.
  6. Аттестация "none", если вы не ограничиваете модели аутентификаторов; если ограничиваете — проверка по FIDO MDS.
  7. Восстановление доступа к аккаунту не слабее самого ключа доступа: второй ключ доступа или проверенный способ восстановления, а не код из SMS, который обходит всё остальное.

Источники