все посты
Лист плотной бумаги на деревянном столе. В центре — зелёный круглый оттиск печати: отпечаток пальца в рамке из греческого меандра. Под ним от руки написан код «A3H 9K8 Z4V». Слева лежит деревянная печать с латунным основанием, которой поставили оттиск.

Passkeys: печать вместо пароля

Пароль — это секрет, который вы показываете каждому, кто спросит.

Вводите его на сайте — сайт его видит. Сайт хранит хэш — хэш утекает вместе с базой. Вы придумали один хороший пароль и используете его в пяти местах — утечка в одном открывает все пять. А фишинговая страница, свёрстанная на час, получает его просто потому, что вы сами его туда напечатали.

Двухфакторная авторизация чинит это наполовину. Код из SMS перехватывают через перевыпуск SIM-карты. Код из приложения фишинговая страница спрашивает сразу после пароля и за те же тридцать секунд пересылает настоящему сайту. Оба фактора — строки, которые человек вводит руками. А куда он их вводит, он не проверяет.

Passkeys устроены иначе. Секрет не покидает ваше устройство. Сайт хранит только то, что бесполезно красть. А браузер не отдаст подпись чужому домену, даже если очень попросить.

Дальше — как это устроено внутри, как этим пользуется человек и как подключить passkeys к своему сайту на JavaScript. Код в статье — рабочий минимум: скопируйте его, запустите на localhost, и через полчаса у вас будет вход без пароля.

Семь слов вокруг одного механизма

Сначала о словах. Новичка здесь сбивает не криптография, а то, что одно и то же называют несколькими разными именами.

Слово Что это
WebAuthn API браузера: navigator.credentials.create() и navigator.credentials.get(). Стандарт W3C.
CTAP Протокол, по которому браузер разговаривает с аутентификатором — по USB, NFC, Bluetooth.
FIDO2 WebAuthn и CTAP вместе. Название от альянса, который это придумал.
Passkey Маркетинговое имя для ключа WebAuthn, который можно найти без логина и который обычно синхронизируется между устройствами.
Аутентификатор То, что хранит ключ и подписывает: Touch ID, Windows Hello, телефон, менеджер паролей, аппаратный ключ.
Relying party (RP) Ваш сайт. Тот, кто проверяет подпись.
RP ID Домен, к которому привязан ключ. Обычно example.com.

Коротко: в документации для разработчиков пишут «WebAuthn», в интерфейсе телефона — «passkey». Это одно и то же.

Печать и оттиск

Представьте печать, которую невозможно подделать по её оттиску.

При регистрации вы ставите на сайте образец оттиска, а печать уносите с собой. Сайт кладёт образец в базу. Когда вы приходите снова, сайт даёт вам бумагу, на которой написано случайное слово — каждый раз новое. Вы ставите на неё печать. Сайт сравнивает оттиск с образцом: совпало — это вы.

Украсть из базы можно только образцы. Поставить ими печать нельзя.

Старая бумага с оттиском тоже не подойдёт: на ней вчерашнее слово, а сегодня сайт написал другое.

В жизни печать по оттиску, конечно, подделать можно. В математике — нельзя, на этом всё и держится. Печать — приватный ключ. Оттиск — публичный. Бумага со словом — challenge. Поставить печать — подписать.

Пароль в этой картинке выглядел бы так: вы отдаёте сайту саму печать, он смотрит на неё и возвращает. Каждый раз. Каждому сайту.

Регистрация: сайт получает оттиск

Весь обмен укладывается в два запроса к серверу и один вызов браузерного API между ними.

POST/passkeys/register/optionschallenge, rp,user, алгоритмыnavigator.credentials.create()Touch ID илиPINподтверждениеновыйпубличный ключи credential IDPOST/passkeys/register/verifyпроверитьchallenge,origin, RP IDpasskeyсохранёнАутентификаторБраузерСерверПользовательПользовательБраузерСерверАутентификатор
Регистрация passkey

Вот что сервер отдаёт браузеру в ответ на первый запрос:

{
  "challenge": "d3zie-L8EA4SdufVnMxT5fD_lwhKnhdV4HCcZko-XMc",
  "rp": { "name": "Мой сайт", "id": "localhost" },
  "user": {
    "id": "hGJlYNa3zAioNdE7KCtsbhzLBXtYa8cqDktdEU78wsk",
    "name": "alex@example.com",
    "displayName": ""
  },
  "pubKeyCredParams": [
    { "alg": -8, "type": "public-key" },
    { "alg": -7, "type": "public-key" },
    { "alg": -257, "type": "public-key" }
  ],
  "timeout": 60000,
  "attestation": "none",
  "excludeCredentials": [],
  "authenticatorSelection": {
    "residentKey": "required",
    "userVerification": "preferred",
    "requireResidentKey": true
  },
  "extensions": { "credProps": true },
  "hints": []
}

Большую часть этого заполняет библиотека. Понимать стоит три поля.

challenge — это слово на бумаге. Тридцать два случайных байта, которые сервер запоминает в сессии. Аутентификатор подпишет ответ, внутри которого лежит этот challenge, и сервер сверит его со своим.

user.id — не email. Это случайный идентификатор, который аутентификатор сохранит рядом с ключом и вернёт при входе. Email в нём держать нельзя: аутентификатор не обещает хранить его в тайне.

pubKeyCredParams — алгоритмы, которые сервер умеет проверять. -8 — Ed25519, -7 — ECDSA на P-256, -257 — RSA. Числа из реестра COSE. Аутентификатор выберет первый, который умеет сам.

Дальше браузер спрашивает человека, аутентификатор создаёт пару ключей и оставляет приватный у себя. На сервер уходит ответ. Самая понятная его часть — clientDataJSON, обычный JSON, закодированный в base64url:

{"type":"webauthn.create","challenge":"d3zie-L8EA4SdufVnMxT5fD_lwhKnhdV4HCcZko-XMc","origin":"http://localhost:3000","crossOrigin":false}

Поле origin записал браузер, а не страница, и подменить его страница не может. Запомните его — дальше оно спасёт вас от фишинга.

Есть ещё attestationObject — бинарный объект, в котором лежат публичный ключ, его идентификатор и хэш домена. Разбирать его руками не нужно. Библиотека проверит.

Вход: сайт проверяет подпись

POST/passkeys/login/optionschallenge и RPIDnavigator.credentials.get()выбратьаккаунт, TouchIDподтверждениеподпись,credential ID,user handlePOST/passkeys/login/verifyнайтипубличный ключпо credential IDпроверитьподпись,challenge, originвы вошлиАутентификаторБраузерСерверПользовательПользовательБраузерСерверАутентификатор
Вход по passkey

Опции входа короче — логина в них нет вообще:

{"rpId":"localhost","challenge":"EkEX66UE_seQWqTJTlzcZ_pPueUGMJyUqJcBp2Nm9jc","timeout":60000,"userVerification":"preferred"}

Сервер не спрашивает, кто вы. Аутентификатор сам знает, какие ключи у него есть для localhost, показывает список, человек выбирает — и в ответе приходит id ключа. По нему сервер находит в базе публичный ключ и проверяет подпись.

Подписывается не один challenge. Аутентификатор склеивает две вещи: свои данные (хэш домена, флаги «человек был рядом» и «человек подтвердил себя», счётчик) и хэш clientDataJSON, где лежат challenge и origin. Поменяйте в этой склейке хоть один байт — и подпись не сойдётся.

А так выглядит clientDataJSON при входе:

{"type":"webauthn.get","challenge":"EkEX66UE_seQWqTJTlzcZ_pPueUGMJyUqJcBp2Nm9jc","origin":"http://localhost:3000","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}

Последнее поле — не опечатка. Chrome иногда добавляет его нарочно: если ваш сервер сравнивает JSON со строкой-шаблоном, пусть он сломается у вас на ноутбуке, а не у пользователей. Поэтому JSON парсят, а не сравнивают.

Фишинговой странице нечего подписать

Теперь то, ради чего passkeys стоит внедрять.

Представьте, что злоумышленник скопировал ваш сайт один в один: та же разметка, тот же скрипт, даже запросы он проксирует на ваш настоящий сервер. Разница одна — адрес. В нашем примере настоящий сайт живёт на localhost, а двойник — на myslte.test. Человек не заметил подмену и нажимает «Войти». Браузер отвечает:

SecurityError: The RP ID "localhost" is invalid for this domain

Браузер отказался ещё до аутентификатора. Страница с myslte.test попросила подпись для localhost, а браузер разрешает просить подпись только для своего домена или его родителя.

Злоумышленник меняет тактику и просит подпись для своего домена:

NotAllowedError: The operation either timed out or was not allowed.

Аутентификатору нечего предложить. Ключ создан для localhost, для myslte.test у него нет ничего. Человеку нечего выбрать, и он ничего не отдаст.

Фишинг не проходит ни так, ни эдак. И не потому, что пользователь стал внимательнее.

Потому что его никто не спрашивал.

Пароль и код можно ввести куда угодно. Passkey существует только для одного домена, а адрес страницы в подпись вписывает браузер. Проверку «тот ли это сайт» делает машина — она не устаёт и не торопится.

Где живёт печать

Где хранится ключ, решает не сайт, а пользователь. И от этого зависит почти всё, что он будет видеть.

Синхронизируемый passkey Привязанный к устройству
Где iCloud Keychain, Google Password Manager, 1Password, Bitwarden YubiKey и другие аппаратные ключи, иногда TPM ноутбука
Новое устройство Ключ уже там Нужно зарегистрировать ещё раз
Потеря устройства Ничего не теряется Ключ пропал вместе с ним
Кто может скопировать Тот, кто войдёт в облачный аккаунт Никто — ключ не извлекается
credentialDeviceType multiDevice singleDevice

Левая колонка обещает «не потеряю», правая — «не скопируют». Я сам пользуюсь обоими: обычные ключи живут в 1Password, а для самого важного есть аппаратный ключ. Ваш сайт должен одинаково хорошо принимать и те, и другие.

Есть и третий вариант, о котором мало кто знает. Сели за чужой компьютер — браузер покажет QR-код. Сканируете его телефоном, телефон по Bluetooth убеждается, что стоит рядом, и подписывает. Ключ с телефона никуда не уходит. Это называется hybrid transport. Bluetooth тут нужен ровно для проверки «рядом»: без неё QR-код с фишинговой страницы можно было бы переслать жертве в мессенджере.

Что видит пользователь

Для пользователя вся эта механика почти незаметна. Так и задумано.

Создать. В настройках аккаунта кнопка «Добавить passkey». Системное окно, Touch ID или PIN телефона — готово. Придумывать ничего не нужно.

Войти. Нажимаете на поле логина — браузер предлагает аккаунт с пометкой «passkey». Выбираете, прикладываете палец. Ни пароля, ни кода из SMS.

Потерять телефон. Если ключ был в iCloud, Google или менеджере паролей — он уже ждёт на новом телефоне. Если это был аппаратный ключ — входите запасным способом и заводите новый. Поэтому ключей у аккаунта лучше держать несколько.

Только в последнем пункте человеку приходится о чём-то думать. Как помочь ему с этим — ниже, в разделе про восстановление.

Подключаем: четыре ручки и два вызова

Писать проверку подписей самому не нужно. Для JavaScript есть SimpleWebAuthn, и почти все берут её: @simplewebauthn/server на сервере и @simplewebauthn/browser в браузере. Примеры ниже — версии 14.0.2 и 14.0.0, Express 5, сессии через express-session.

npm i @simplewebauthn/server @simplewebauthn/browser express express-session

Серверу нужно знать о себе три вещи:

const rpName = 'Мой сайт';
const rpID = 'localhost';
const origin = 'http://localhost:3000';

// Вместо базы — два массива.
const users = [];
const passkeys = [];

В проде здесь будут example.com и https://example.com. WebAuthn работает только по HTTPS. Исключение одно — localhost, так что для разработки сертификат не нужен.

Регистрация на сервере

Всего понадобится четыре ручки: две для регистрации, две для входа. Первая готовит опции и запоминает challenge:

app.post('/passkeys/register/options', async (req, res) => {
  let user = users.find((u) => u.email === req.body.email);
  if (!user) {
    user = { id: users.length + 1, email: req.body.email };
    users.push(user);
  }
  const existing = passkeys.filter((p) => p.userId === user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred',
    },
  });

  req.session.challenge = options.challenge;
  req.session.pendingUserId = user.id;
  res.json(options);
});

residentKey: 'required' делает ключ настоящим passkey. Аутентификатор сохранит его вместе с user.id и сможет найти сам, без подсказки сервера. Без этой строчки ключ сработает, только если сервер заранее знает, кто входит. То есть человеку всё равно придётся вводить логин.

excludeCredentials — ключи, которые у человека уже есть. Если аутентификатор узнает среди них свой, второй он создавать не станет. Без этого человек, трижды нажавший «Добавить passkey», получит три одинаковых ключа и не поймёт, какой удалять.

Вторая ручка проверяет ответ и сохраняет ключ:

app.post('/passkeys/register/verify', async (req, res) => {
  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge: req.session.challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      requireUserVerification: false,
    });
  } catch (error) {
    return res.status(400).json({ error: error.message });
  } finally {
    delete req.session.challenge;
  }
  if (!verification.verified) return res.status(400).json({ error: 'not verified' });

  const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
  passkeys.push({
    id: credential.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: credential.transports,
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    userId: req.session.pendingUserId,
  });
  res.json({ verified: true });
});

Обратите внимание на finally: challenge удаляется из сессии и при успехе, и при ошибке. Одно слово — одна бумага. Второй раз его использовать нельзя.

После регистрации в «базе» лежит примерно такое — массив байтов сокращён:

{
  id: 's78-I0rO1c8bKYqJ-x6n_DGbtGuD_ZXUM8JoxTcugDU',
  publicKey: Uint8Array(42) [ 164, 1, 1, 3, 39, 32, 6, 33, 88, 32, 112, 253, ... ],
  counter: 1,
  transports: [ 'internal' ],
  deviceType: 'singleDevice',
  backedUp: false,
  userId: 1
}

Сорок два байта публичного ключа. Больше ваш сайт о входе пользователя ничего не хранит — и эти байты можно хоть опубликовать.

В настоящей базе данных это одна таблица:

Колонка Зачем
id Идентификатор ключа, по нему ищем при входе. Уникальный индекс.
public_key BLOB, им проверяется подпись.
counter Защита от клонированных ключей — см. ниже.
transports Подсказка браузеру, как искать ключ: internal, usb, hybrid.
device_type, backed_up Синхронизируется ли ключ. Пригодится, чтобы решить, просить ли человека завести второй.
user_id Чей он. У одного пользователя их много.
created_at, last_used_at Чтобы человек в настройках понял, какой ключ какой.

Регистрация в браузере

import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthnAutofill,
} from '@simplewebauthn/browser';

const result = document.querySelector('#result');

async function post(url, body) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body ?? {}),
  });
  return response.json();
}

document.querySelector('#register').addEventListener('click', async () => {
  const email = document.querySelector('[name=email]').value;
  const optionsJSON = await post('/passkeys/register/options', { email });

  let attestation;
  try {
    attestation = await startRegistration({ optionsJSON });
  } catch (error) {
    result.textContent = `${error.name}: ${error.message}`;
    return;
  }

  const verdict = await post('/passkeys/register/verify', attestation);
  result.textContent = verdict.verified ? 'Passkey сохранён' : verdict.error;
});

Зачем здесь библиотека? Браузерный API принимает и возвращает бинарные буферы, а по сети мы гоняем JSON, который их не умеет. startRegistration переводит одно в другое, вызывает navigator.credentials.create() и переводит ответ обратно. Руками это полсотни скучных строк.

Вход на сервере

app.post('/passkeys/login/options', async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
  });
  req.session.challenge = options.challenge;
  res.json(options);
});

app.post('/passkeys/login/verify', async (req, res) => {
  const passkey = passkeys.find((p) => p.id === req.body.id);
  if (!passkey) return res.status(400).json({ error: 'unknown passkey' });

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: req.body,
      expectedChallenge: req.session.challenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      credential: {
        id: passkey.id,
        publicKey: passkey.publicKey,
        counter: passkey.counter,
        transports: passkey.transports,
      },
      requireUserVerification: false,
    });
  } catch (error) {
    return res.status(400).json({ error: error.message });
  } finally {
    delete req.session.challenge;
  }
  if (!verification.verified) return res.status(400).json({ error: 'not verified' });

  passkey.counter = verification.authenticationInfo.newCounter;
  req.session.userId = passkey.userId;
  const user = users.find((u) => u.id === passkey.userId);
  res.json({ verified: true, email: user.email });
});

Список разрешённых ключей (allowCredentials) в опциях не передаём — значит, подойдёт любой ключ этого сайта. Кто пришёл, сервер узнаёт уже из ответа, по id ключа.

Вход в браузере — и почему он начинается сам

async function signIn({ autofill }) {
  const optionsJSON = await post('/passkeys/login/options');

  let assertion;
  try {
    assertion = await startAuthentication({ optionsJSON, useBrowserAutofill: autofill });
  } catch (error) {
    // Фоновый запрос автозаполнения человек не запускал — и об его ошибках не узнаёт.
    if (!autofill) result.textContent = `${error.name}: ${error.message}`;
    return;
  }

  const verdict = await post('/passkeys/login/verify', assertion);
  result.textContent = verdict.verified ? `Вы вошли как ${verdict.email}` : verdict.error;
}

document.querySelector('#signin').addEventListener('click', () => signIn({ autofill: false }));

if (await browserSupportsWebAuthnAutofill()) signIn({ autofill: true });

Здесь два способа войти, и второй важнее первого.

Кнопка «Войти по passkey» открывает системное окно. Это работает, но человек должен помнить, что у него есть passkey, и найти эту кнопку.

Автозаполнение человек не пропустит. Запрос с useBrowserAutofill: true стартует при загрузке страницы и тихо ждёт. Когда человек ставит курсор в поле логина, браузер показывает его passkey в выпадающем списке, рядом с сохранёнными паролями. Выбрал — вошёл. Нужно только правильно заполнить autocomplete у поля:

<input name="email" type="email" autocomplete="username webauthn" placeholder="email">

Подсказку включает слово webauthn в конце. В спецификации это называется conditional mediation, в статьях чаще — conditional UI.

Ошибки фонового запроса человеку не показывают. Отсюда условие в catch. Библиотека отменяет запрос автозаполнения всякий раз, когда начинается другой — например, регистрация по кнопке. Человек этот запрос не запускал, и сообщение о его отмене его только испугает. Показывайте ошибки только того, что человек начал сам.

Пароль уходит — или остаётся первым шагом

Всё, что выше, — вход, где passkey заменяет и пароль, и второй фактор. Это лучший вариант, но не единственный.

Passkey как единственный способ. Человек регистрируется по email и сразу создаёт ключ. Входит только ключом. Пароля в системе нет вообще, и красть в базе нечего. Хорошо подходит новым проектам.

Passkey как второй шаг. Пароли уже есть, пользователи к ним привыкли, отказываться от них сразу страшно. Тогда ключ заменяет код из SMS: человек вводит пароль, сервер его проверяет и просит ключ — уже конкретного пользователя.

app.post('/login/second-factor/options', async (req, res) => {
  const userId = req.session.halfSignedInUserId;
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: passkeys
      .filter((p) => p.userId === userId)
      .map((p) => ({ id: p.id, transports: p.transports })),
    userVerification: 'discouraged',
  });
  req.session.challenge = options.challenge;
  res.json(options);
});

Отличий два. allowCredentials перечисляет ключи этого человека — сервер уже знает, кто входит. А userVerification: 'discouraged' говорит, что палец или PIN не нужны, хватит касания: личность подтвердил пароль, от ключа нужно только «устройство при мне».

Проверка ответа та же, что при обычном входе. Добавьте только одно условие: найденный ключ должен принадлежать именно этому пользователю.

Честно о цене: пароль остаётся в базе и может утечь. Зато фишинг перестаёт работать — узнав пароль, злоумышленник упрётся во второй шаг, который не перешлёшь. Для живого продукта это обычно правильное начало, а отказаться от пароля можно и потом.

userVerification — касание или палец

Эта настройка путает почти всех, потому что речь в ней о двух похожих вещах.

User presence — «кто-то нажал на ключ». Касание YubiKey, кнопка «Продолжить».

User verification — «это именно владелец»: отпечаток, лицо, PIN устройства.

Значений три. 'required' — без проверки личности ключ не сработает. 'preferred' — проверь, если умеешь. 'discouraged' — не беспокой человека.

Для входа без пароля проверка личности нужна: иначе ключ — просто вещь, которую можно украсть вместе с ноутбуком. Но Touch ID, Windows Hello и телефоны делают её всегда. Поэтому большинству сайтов хватит 'preferred' в опциях и requireUserVerification: false на сервере. Нужна гарантия — ставьте 'required' и true.

Attestation почти никому не нужна

В опциях регистрации стояло "attestation": "none". То есть сервер не спрашивает, какое устройство создало ключ.

Спросить можно. Тогда аутентификатор приложит сертификат производителя, и сервер узнает модель устройства и сможет проверить, что это действительно, скажем, YubiKey. Модель определяется по AAGUID, а сертификаты сверяются с реестром FIDO Metadata Service.

Нужно это там, где правила касаются железа. Компания раздала сотрудникам YubiKey и не хочет, чтобы кто-то входил ключом из iCloud. Банку регулятор предписывает сертифицированные устройства. Всем остальным attestation принесёт только две вещи: отказы пользователям с «неправильным» менеджером паролей и список производителей, который придётся поддерживать. Оставьте none.

Где это ломается

Сменили домен — потеряли все ключи. Ключ привязан к RP ID. Переедете с example.com на example.io — и ни один ключ не сработает. Миграция базы тут не поможет: подпись делается на устройстве пользователя. Поэтому RP ID выбирают один раз и с запасом. Ключ для example.com работает и на app.example.com, и на login.example.com. Наоборот — нет.

Восстановление доступа через почту. Человек потерял все устройства, ключей нет. Что делать? Проще всего прислать ссылку на email: по ней можно войти и завести новый ключ. Но помните, что теперь аккаунт защищён ровно настолько, насколько защищена почта. Риск можно уменьшить: после входа по ссылке разрешайте только создать новый ключ и сразу пишите об этом на ту же почту. Убрать его совсем нельзя.

Один ключ — это ноль запасных. Покажите человеку список его ключей с датами и названиями и предложите добавить второй. Особенно если у ключа backedUp: false: такой ключ пропадёт вместе с устройством.

У синхронизируемых ключей счётчик всегда ноль. counter придуман против клонов: при каждом входе аутентификатор его увеличивает, и если сервер видит число меньше сохранённого — ключ скопировали. Аппаратный ключ так и считает: 1, 2, 3. А ключ из iCloud живёт на нескольких устройствах сразу, общего счётчика у них нет, и они присылают 0. Библиотека об этом знает и не ругается. Просто не рассчитывайте на эту защиту: для таких ключей её роль играет облачный аккаунт.

Один challenge на сессию. В примере выше место под challenge в сессии одно, а ждать его могут сразу двое: автозаполнение и кнопка. Выиграет тот, кто запросил последним, второй получит 400. Пока ошибки фонового запроса не показываются человеку, это не страшно.

Нет HTTPS — нет passkeys. Вне localhost без HTTPS браузер просто не даст вам navigator.credentials.

Тестировать можно без пальца. В Chrome DevTools есть панель WebAuthn: включаете там виртуальный аутентификатор, и браузер подписывает без Touch ID. В автотестах то же самое делается через Playwright:

const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
  options: {
    protocol: 'ctap2',
    transport: 'internal',
    hasResidentKey: true,
    hasUserVerification: true,
    isUserVerified: true,
    automaticPresenceSimulation: true,
  },
});

Так в CI проверяются и регистрация, и вход. Одно отличие от живого браузера: виртуальный аутентификатор отвечает на запрос автозаполнения сразу, не дожидаясь клика по полю. Живой браузер ждёт, пока человек выберет аккаунт.

Тогда почему пароли ещё здесь

Возражение справедливое: если всё так хорошо, почему на каждом втором сайте по-прежнему форма с паролем?

Из-за первой настройки. Пароль объяснять не нужно: поле со звёздочками знает любой. С passkey человеку надо понять, что такое «ключ», где он хранится и что будет, если потерять телефон. А разработчику — продраться через семь названий одного механизма, две процедуры и настройки с одинаково звучащими именами.

Как раз эту часть мы только что прошли. А когда всё настроено, пароль не выигрывает ни в одном сценарии. Passkey быстрее. Его нельзя придумать слабым, использовать на двух сайтах, продиктовать мошеннику по телефону или ввести на поддельной странице.

Куда всё движется

Крупные платформы свой выбор уже сделали. Новые аккаунты Microsoft по умолчанию создаются без пароля. Google и Apple предлагают passkey раньше пароля. Менеджеры паролей договорились о формате, по которому ключи можно переносить друг к другу, — раньше passkey из iCloud оставался там навсегда.

И сам стандарт подтягивает неудобные места. Сайт теперь может сообщить менеджеру паролей, что ключ на сервере удалён, и тот перестанет его предлагать, — в SimpleWebAuthn для этого есть sendSignal(). Один ключ может работать на нескольких доменах одной компании. А браузер может сам предложить создать passkey сразу после входа по паролю, без похода в настройки.

Всё идёт к тому, что пользователю вообще не придётся разбираться, как это устроено. Разбираться будем мы.

Пароли ещё поживут — как запасной вход и как привычка. Но главным способом входа им уже не быть.

Печать останется у вас. Сайт получит только оттиск.

Комментарии 0

Комментариев пока нет.