Пароль плох по устройству: его можно украсть из БД, выманить фишингом, переиспользовать. Passkeys убирают сам секрет — вместо него асимметричная пара ключей, где приватный ключ остаётся у аутентификатора пользователя (или, у синхронизируемых passkeys, в его зашифрованном хранилище), а сервер хранит лишь публичный. Из базы сервера украсть нечего, выманить на фишинговой странице нечего — привязка к origin закрывает этот путь. Но passkey защищает именно церемонию входа: украденную после входа сессию или слабый путь восстановления доступа он не спасает. На мобильных клиентах биометрию мы уже разбиралиСкоро; эта статья — про WebAuthn на вебе и про то, что нужно на бэкенде, чтобы passkeys принять.
Третья, завершающая статья из трёх углублений серии «Авторизация: токены, сессии, refresh» (ядро серии закончилось на пятой статье — эта и предыдущие две про OAuth2/OIDC и MFA/TOTP разбирают отдельные большие темы поверх него). Прошлая статья закончилась на том, что TOTP всё ещё фишится: поддельная страница получает код и за 30–90 секунд пересылает его на настоящий сайт. WebAuthn закрывает именно этот случай — не «более длинным секретом», а тем, что секрета, который можно ввести не туда, больше не существует.
В статье
- Зачем убирать пароль
- Асимметрия вместо секрета
- Два потока: registration и authentication
- Почему это не фишится: origin/rpId binding
- Signature counter: сигнал возможного клонирования
- Один контракт, два верификатора: Go против Java
- Resident keys и синхронизируемые passkeys
- Роль бэкенда и честные компромиссы демо
- UX и фолбэк: passkey не отменяет остальное
- Источники
Зачем убирать пароль
Всё, что мы разбирали раньше в серии — модели сессий, методы входа, второй фактор — работает поверх одного и того же допущения: где-то есть секрет, который пользователь предъявляет серверу, а сервер сверяет с тем, что хранит у себя (пароль-хэш) или с тем, что проверяемо восстанавливает (TOTP-код по общему секрету). У любого такого секрета — общая уязвимость: если его можно ввести на легитимном сайте, его можно ввести и на поддельном. Пользователь не различает origin визуально, различает только браузер.
Passkeys устраняют не конкретную атаку, а саму категорию. Вместо секрета, которым пользователь владеет и может ошибочно передать не туда, WebAuthn использует пару асимметричных ключей, которую генерирует не пользователь и не сервер, а аутентификатор — чип secure enclave в телефоне или ноутбуке, TPM в Windows-машине, отдельный аппаратный ключ. Приватная половина пары не передаётся серверу ни при регистрации, ни при последующих входах — она участвует только в вычислении подписи внутри аутентификатора. На сервер уезжает исключительно публичный ключ. (У device-bound ключа приватная половина вообще не покидает устройство; у синхронизируемого passkey её зашифрованная копия реплицируется между устройствами пользователя — об этой разнице ниже.) Из этого следуют оба главных свойства: утечка базы данных сервера бесполезна атакующему (публичный ключ и так публичен, подделать подпись по нему нельзя), а фишинговая копия сайта не может получить рабочий ответ от аутентификатора, потому что тот подписывает не то, что видит пользователь, а то, что реально запросил браузер — включая настоящий origin. Дальше в статье — как это устроено в протоколе и как выглядит на двух независимых бэкендах стенда: Go (go-webauthn v0.17.4) и Java (Spring Boot + webauthn4j 0.31.8), которые верифицируют один и тот же HTTP-контракт (security/auth/webauthn, /register/begin|finish, /login/begin|finish).
Асимметрия вместо секрета
Ключевая пара в WebAuthn — не долгоживущий сертификат, а пара, сгенерированная на конкретную комбинацию (аутентификатор, RP): для каждого сайта, к которому пользователь подключает passkey, аутентификатор создаёт отдельную пару ключей. Приватный ключ живёт внутри защищённого аппаратного модуля (secure enclave на Apple-устройствах, TPM на Windows, Strongbox/TEE на Android) и (для device-bound ключа) не сериализуется в обычную память процесса — операционная система и браузер могут попросить модуль подписать данные, но не могут вытащить из него сам ключ. Публичный ключ вместе с идентификатором credential’а (credential ID) уходит на сервер при регистрации и с этого момента используется только для проверки подписей — сервер физически не может ничего подписать этим материалом сам, только проверить чужую подпись.
Различают два класса аутентификаторов. Платформенные (platform authenticators) встроены в устройство — Touch ID/Face ID на macOS/iOS, Windows Hello, биометрия Android — и обычно недоступны за пределами этого устройства. Роуминговые (roaming authenticators) — отдельные USB/NFC/BLE-ключи (YubiKey и аналоги), которые можно переносить между устройствами и подключать к любому из них по CTAP2 (Client to Authenticator Protocol). В стенде за роль аутентификатора при автоматических прогонах отвечает виртуальный CTAP2-аутентификатор Chrome DevTools Protocol (WebAuthn.addVirtualAuthenticator, protocol: 'ctap2', transport: 'internal') — он честно генерирует пару ключей ES256, честно подписывает assertion и ведёт себя для бэкенда неотличимо от настоящего платформенного аутентификатора; в юнит-тестах Go-бэкенда та же роль — у virtualAuthenticator в backend-go/webauthn_test.go, который сам собирает CBOR-attestation и подписывает ASN.1 DER-подписью ECDSA P-256.
Два потока: registration и authentication
WebAuthn делит жизненный цикл passkey на две отдельные церемонии с общей формой (challenge от сервера → операция у аутентификатора → подписанный ответ на проверку), но разным смыслом.
Registration (attestation) — подключение нового ключа к аккаунту. Сервер генерирует случайный challenge и параметры создания (creation options: кто RP, кто пользователь, какие алгоритмы подписи допустимы), браузер вызывает navigator.credentials.create({ publicKey }), аутентификатор создаёт новую пару ключей и возвращает attestationObject (публичный ключ + метаданные + опциональное доказательство подлинности самого аутентификатора) вместе с clientDataJSON (challenge, type, origin — то, что реально видел браузер). В Go-бэкенде это registerBegin/registerFinish.
Ограничение демо, которое важнее остальных: регистрация ключа в стенде не проверяет, что запрос пришёл от владельца аккаунта. Зная чужой логин, можно привязать к нему свой аутентификатор. В проде добавлять ключ к существующему аккаунту можно только из его свежей авторизованной сессии — подробнее в разделе о компромиссах демо.
func (s *server) registerBegin(w http.ResponseWriter, r *http.Request) {
// ...
user, err := s.loadUser(ctx, req.Username)
if user == nil {
user, err = newUser(req.Username) // WebAuthnID — случайные 32 байта, user handle
}
creation, session, err := s.wa.BeginRegistration(user)
if err := s.saveSession(ctx, "register", req.Username, session); err != nil { /* ... */ }
writeJSON(w, http.StatusOK, creation)
}
func (s *server) registerFinish(w http.ResponseWriter, r *http.Request) {
// ...
session, err := s.loadAndDeleteSession(ctx, "register", username) // challenge одноразовый
credential, err := s.wa.FinishRegistration(user, *session, r)
if err != nil {
writeError(w, http.StatusBadRequest, "registration verification failed")
return
}
user.Credentials = append(user.Credentials, *credential)
// ...
}
Состояние церемонии (challenge и всё, что с ним связано) хранится в Redis под webauthn:session:register:{username} с TTL sessionTTL = 5 * time.Minute — с запасом на время, которое пользователь реально тратит на подтверждение у Touch ID или физический ключ, — и удаляется сразу при чтении в loadAndDeleteSession, независимо от исхода верификации. При последовательных запросах это делает challenge одноразовым: второй finish с тем же challenge состояния уже не найдёт. Но гарантии под конкуренцией в стенде нет: loadAndDeleteSession выполняет отдельные GET и DEL (ошибка DEL к тому же игнорируется), и два параллельных finish могут успеть прочитать одно состояние до удаления. В проде чтение и удаление должны быть одной атомарной операцией — GETDEL в Redis 6.2+ или Lua-скрипт, — а сама церемония привязана к конкретной сессии браузера (идентификатор в cookie), а не только к username, который знает кто угодно.
Authentication (assertion) — предъявление уже зарегистрированного ключа при входе. Сервер снова генерирует challenge (loginBegin → s.wa.BeginLogin(user)), аутентификатор находит подходящий приватный ключ по credential ID/rpId, подписывает связку authenticatorData || sha256(clientDataJSON) и возвращает signature вместе с тем же clientDataJSON. loginFinish передаёт всё это в FinishLogin, которая проверяет origin и challenge (в clientDataJSON), rpIdHash и флаги (в authenticatorData) и саму подпись по сохранённому публичному ключу:
// FinishLogin проверяет origin/rpId (сверка CollectedClientData) и подпись
// assertion (ECDSA по сохранённому публичному ключу credential'а) —
// это делает сама библиотека go-webauthn, здесь только диспетчеризация.
credential, err := s.wa.FinishLogin(user, *session, r)
if err != nil {
writeError(w, http.StatusUnauthorized, "assertion verification failed")
return
}
Смысловая разница между потоками: registration создаёт новую доверенную пару ключей и требует явного подтверждения владения устройством (обычно с биометрией), authentication лишь доказывает, что запрашивающий владеет уже созданной приватной половиной — и именно authentication выполняется на каждый вход, тогда как registration — разово на каждое устройство/ключ.
Почему это не фишится: origin/rpId binding
Разница с TOTP, о которой шла речь в прошлой статье: TOTP-код — это шесть цифр, которые пользователь читает и перепечатывает куда скажут — вручную, глазами, без всякой привязки к тому, куда он на самом деле их вводит. Поддельная страница логина ничем не отличается от настоящей визуально, и код уходит туда, куда пользователь его напечатал.
В WebAuthn проверка «с кем я на самом деле разговариваю» выполняется не пользователем, а браузером — на уровне, который пользователь не может обойти, потому что он не в UI, а в протоколе. clientDataJSON, который подписывает аутентификатор, формирует браузер, а не JavaScript страницы: туда попадает origin — тот, который реально загружен в адресной строке, а не тот, который страница может заявить о себе через fetch. Если фишинговая копия развёрнута на khor0st.tech, а настоящий RP — khorost.tech, то clientDataJSON.origin от браузера жертвы будет содержать khor0st.tech независимо от того, насколько убедительно страница выглядит. Плюс к этому в сам authenticatorData вшит rpIdHash — SHA-256 от rpId, за которым закреплён credential; аутентификатор попросту не предложит браузеру ключ, привязанный к другому rpId, — привязка проверяется дважды, на уровне выбора credential’а и на уровне подписанных данных.
Сервер обязан сверить оба поля с тем, что сам ожидает (в стенде — WEBAUTHN_RP_ID/WEBAUTHN_RP_ORIGINS для Go, WEBAUTHN_RP_ID/WEBAUTHN_RP_ORIGIN для Java), и по спецификации эта сверка CollectedClientData — один из первых шагов верификации, задолго до проверки самой криптографической подписи: несовпадение origin отбрасывает assertion сразу, не тратя цикл на ECDSA. backend-go/webauthn_test.go фиксирует это явным тестом:
func TestLoginRejectsWrongOrigin(t *testing.T) {
// ... регистрация прошла с origin http://localhost:8087
auth.counter = 1
assertionOpts := beginLogin(t, ts.URL, username)
// origin в clientDataJSON не совпадает с WEBAUTHN_RP_ORIGINS — библиотека
// обязана отклонить ассерцию на шаге сверки CollectedClientData.
loginBody := auth.buildAssertion(t, testRPID, "http://evil.example", assertionOpts.PublicKey.Challenge)
finishLogin(t, ts.URL, username, loginBody, http.StatusUnauthorized)
}
Итог для атакующего: даже завладев полным контролем над поддельным доменом и заманив жертву кликнуть «подтвердить» у настоящего аутентификатора, он не получает ничего пригодного для входа на настоящий сайт — подпись, которую выдаст аутентификатор, будет криптографически привязана к чужому origin и настоящий сервер её отклонит.
Signature counter: сигнал возможного клонирования
Origin-binding закрывает фишинг, но не единственный сценарий компрометации — есть ещё клонирование самого аутентификатора: если приватный ключ каким-то образом скопирован (взлом прошивки, физический доступ, дефект реализации), два экземпляра одного и того же credential’а работают параллельно, каждый со своим представлением о состоянии. WebAuthn даёт для этого встроенный, хотя и не абсолютный, механизм — signature counter: каждый ответ аутентификатора несёт монотонно растущее число, и сервер обязан хранить последнее увиденное значение.
Логика проверки: если новый counter не больше сохранённого — это сигнал, что кто-то предъявляет ответ от версии аутентификатора, которая не видела последних успешных входов, Это может быть второй, клонированный экземпляр — но может быть и сбой или ошибка реализации самого аутентификатора. Спецификация W3C поэтому называет откат counter признаком возможного клонирования, а не доказательством, и оставляет реакцию на усмотрение сервера (relying party): отклонить вход, пометить credential, потребовать дополнительную проверку. go-webauthn сама не отклоняет ceremony при откате — она лишь выставляет флаг CloneWarning на возвращённом credential’е (Authenticator.UpdateCounter), решение о реакции — на вызывающем коде:
// go-webauthn НЕ отклоняет ceremony сама при откате signature counter —
// она только выставляет Authenticator.CloneWarning на возвращённом
// credential (см. Authenticator.UpdateCounter).
if credential.Authenticator.CloneWarning {
writeError(w, http.StatusUnauthorized, "possible credential cloning detected (signature counter did not increase)")
return
}
webauthn4j, наоборот, по умолчанию сам бросает исключение при откате counter (DefaultCoreMaliciousCounterValueHandler) — чтобы Java-бэкенд стенда принимал то же решение явным кодом (а не скрытым поведением библиотеки), встроенный обработчик заменён на no-op, и explicit-reject реализован вручную в finishLogin, с важной деталью — снимок старого counter’а нужно сделать до вызова verify(), потому что верификатор сам обновляет credentialRecord изнутри:
// Снимок counter'а ДО verify(): CoreAuthenticationDataVerifier внутри verify()
// сама вызывает credentialRecord.setCounter(presentedSignCount), когда
// presentedSignCount > storedSignCount — если прочитать getCounter() уже
// после verify(), там будет НОВОЕ значение, и сравнение
// "newCounter <= storedCounter" всегда окажется истинным (ложный reject).
long storedCounter = credentialRecord.getCounter();
authenticationData = webAuthnManager.verify(authenticationRequest, authenticationParameters);
long newCounter = authenticationData.getAuthenticatorData().getSignCount();
if ((newCounter > 0 || storedCounter > 0) && newCounter <= storedCounter) {
throw new UnauthorizedException(
"possible credential cloning detected (signature counter did not increase)");
}
credentialRecord.setCounter(newCounter);
Отказ во входе — политика, выбранная для демо: она самая строгая и легко проверяется тестом; в проде вместо неё может быть мягкая реакция с уведомлением пользователя. Оба бэкенда тестами подтверждают именно этот сценарий: TestLoginRejectsCounterRollback в Go-стенде честно входит с counter=5, а затем повторно предъявляет ответ с тем же counter — второй раз сервер отвечает 401.
Отдельно стоит проговорить условие (newCounter > 0 || storedCounter > 0) в обеих реализациях — это не косметика, а осознанный компромисс самого протокола. Часть аутентификаторов вообще не реализует signature counter и всегда репортует signCount = 0 — среди них Windows Hello, многие современные platform-passkeys и виртуальный CTAP2-аутентификатор, использованный в e2e-тестах стенда. Без этой оговорки условие «0 не больше 0» отклоняло бы каждый вход такого аутентификатора, включая самый первый после регистрации — clone-detection в принципе неприменим к аутентификатору, который не поддерживает counter, и оба бэкенда сознательно пропускают проверку в этом случае, а не притворяются, что она что-то доказывает. README стенда называет это прямо: против клонирования конкретно таких аутентификаторов counter ничего не гарантирует — это ограничение протокола, а не недоработка демо.
Один контракт, два верификатора: Go против Java
Стенд статьи устроен необычно для серии — вместо одной реализации это две независимые, написанные на разных языках и разными библиотеками поверх одного и того же HTTP-контракта: POST /register/begin|finish, POST /login/begin|finish. Go-бэкенд (backend-go/, порт 8087) — go-webauthn v0.17.4 поверх Redis. Java-бэкенд (backend-java/, порт 8088) — Spring Boot 3.5.16 с webauthn4j 0.31.8 поверх in-memory ConcurrentHashMap-хранилища (WebauthnStore). Смысл контраста не в «какой лучше», а в том, что он честно показывает, какая часть протокола — обязательный минимум спецификации (проверка origin/rpId, проверка подписи, работа с counter — оба бэкенда это делают, потому что иначе не будет соответствия WebAuthn) и какая — свободный выбор реализации (хранилище состояния, набор допустимых алгоритмов подписи, строгость проверки attestation).
Различия видны прямо в коде создания опций регистрации: Go вызывает s.wa.BeginRegistration(user) без явных переопределений (используя дефолты go-webauthn, без выбора конкретного набора pubKeyCredParams), тогда как Java явно фиксирует единственный алгоритм и attestation: "none" прямо в хендлере:
var pubKeyCredParams = List.of(new Dtos.CreationOptionsResponse.PubKeyCredParam("public-key", -7)); // ES256
var publicKey = new Dtos.CreationOptionsResponse.PublicKeyCreation(
b64(challenge.getValue()), rp, user, pubKeyCredParams, TIMEOUT_MILLIS, "none");
Оба бэкенда отдают JSON одинаковой формы — challenge/user.id/rp/pubKeyCredParams/excludeCredentials/allowCredentials в base64url без паддинга, — и это позволяет одному и тому же frontend’у (web/index.html + web/app.js) работать с обоими без единой развилки в коде, кроме выбора BASE-URL по query-параметру ?backend=go|java:
const BACKENDS = { go: 'http://localhost:8087', java: 'http://localhost:8088' };
const backendName = params.get('backend') in BACKENDS ? params.get('backend') : null;
const BASE = backendName ? BACKENDS[backendName] : location.origin;
app.js вызывает navigator.credentials.create()/navigator.credentials.get() напрямую и переводит ArrayBuffer-поля из/в base64url вручную (base64urlToBuffer/bufferToBase64url) — это низкоуровневый Web Authentication API без обёрточных библиотек, ровно то, что видит браузер и ожидает бэкенд.
Проверка, что контракты действительно совместимы, — не заявление в комментарии, а работающий Playwright-сценарий: e2e/webauthn.spec.ts поднимает виртуальный CTAP2-аутентификатор через CDP (WebAuthn.addVirtualAuthenticator, hasResidentKey: true, automaticPresenceSimulation: true) и гоняет полный цикл регистрация→вход через реальный браузерный fetch() против обоих бэкендов одним и тем же тестом:
for (const [backend, base] of [['go', BASE_GO], ['java', BASE_JAVA]] as const) {
test(`register+login via virtual authenticator (${backend})`, async ({ page }) => {
// ... WebAuthn.addVirtualAuthenticator ...
await page.goto(`${base}/?backend=${backend}`);
await page.fill('#username', `demo-${backend}`);
await page.click('#register');
await expect(page.locator('body')).toHaveAttribute('data-authok', 'registered', { timeout: 8000 });
await page.click('#login');
await expect(page.locator('body')).toHaveAttribute('data-authok', 'logged-in', { timeout: 8000 });
});
}
На живом прогоне против поднятых контейнеров это два прошедших теста — register+login via virtual authenticator (go) и (java) — не юнит-проверка подписи в изоляции, а полный HTTP-цикл браузер↔сервер с настоящим navigator.credentials.
Resident keys и синхронизируемые passkeys
Классический вариант, который показывает стенд — loginBegin принимает username и по нему ищет зарегистрированные credentials пользователя, чтобы передать их аутентификатору как allowCredentials. Это рабочая, но не самая «беспарольная» форма UX: пользователю всё ещё нужно ввести логин, прежде чем аутентификатор включится в дело. Здесь стоит сказать честно: в текущем виде демо webauthn/ именно так и устроено — username вводится в форму до /login/begin в обоих бэкендах, это не демонстрация полностью usernameless-входа.
Полная беспарольность в WebAuthn достигается через resident (discoverable) credentials — аутентификатор хранит не только приватный ключ, но и связанный с ним user handle прямо у себя, и на login сервер может вообще не спрашивать логин: браузер запрашивает у аутентификатора «есть ли у тебя вообще ключ для этого rpId» без указания конкретного пользователя, аутентификатор сам показывает пикер из своих сохранённых credential’ов. Именно эта возможность аутентификатора и включена у виртуального устройства в e2e-тестах (hasResidentKey: true) — устройство физически способно на discoverable-сценарий, — но конкретный demo-flow этой возможностью не пользуется, оставаясь на связке «username → allowCredentials».
Отдельная, более прикладная развилка — где физически живёт приватный ключ после регистрации. Device-bound passkey (или классический аппаратный ключ) не покидает то единственное устройство, где был создан: потеря устройства безвозвратно теряет и credential, что заставляет держать несколько ключей или полагаться на recovery-путь (тот же, что уже разобран для TOTP — recovery-коды). Синхронизируемые passkeys — то, что сейчас продвигают Apple (iCloud Keychain), Google (Google Password Manager) и менеджеры паролей — реплицируют зашифрованную копию приватного ключа между устройствами пользователя через облако платформы, снимая проблему «один физический ключ — одна точка отказа» ценой того, что приватный ключ формально уже не «никогда не покидал одно устройство» в буквальном смысле, а живёт в зашифрованном виде вне его, под защитой отдельного механизма (сквозное шифрование учётной записи платформы). Это тот же компромисс восстановление/строгость гарантии, что мы уже видели на мобильных клиентах — там же разбирали, как биометрия на телефоне защищает локальный ключ, а не заменяет егоСкоро: WebAuthn на вебе следует тому же принципу для десктопа и браузера, только с добавлением origin-binding, которого у чисто локальной биометрии нет.
Роль бэкенда и честные компромиссы демо
Сервер в WebAuthn не хранит ничего секретного и не выполняет никакой криптографии для выпуска ключей. Его ответственность при проверке ответа — это не «проверить подпись», а целый минимум из спецификации W3C (разделы о верификации регистрации и assertion):
- сгенерировать непредсказуемый challenge на каждую церемонию, привязать его к сессии браузера и погасить атомарно — чтобы его нельзя было предъявить дважды;
- в
clientDataJSONсверитьtype(webauthn.createпри регистрации,webauthn.getпри входе — иначе ответ одной церемонии подсовывают в другую),challengeиorigin; - в
authenticatorDataсверитьrpIdHashс SHA-256 от собственного rpId и проверить флаги: UP (пользователь присутствовал) обязательно, UV (пользователь верифицирован биометрией или PIN) — если политика сервиса его требует; - при входе убедиться, что credential принадлежит тому аккаунту, под который входят: сверить
credential IDсо списком аккаунта, а при discoverable-входе —userHandleиз ответа с user handle аккаунта; - проверить подпись над
authenticatorData || sha256(clientDataJSON)по ранее сохранённому публичному ключу; - принять решение по signature counter и сохранить его новое значение.
Большую часть этих проверок в стенде выполняют go-webauthn и webauthn4j; на совести приложения остаются привязка церемонии к сессии, атомарность и решение по counter. Состояние Go-бэкенда видно по устройству ключей Redis:
| Ключ | Тип | Назначение |
|---|---|---|
webauthn:user:{username} |
STRING (JSON) | учётная запись: WebAuthnID (user handle), список Credentials (публичные ключи + counter) |
webauthn:cred:{credID} |
STRING | обратный указатель credID → username |
webauthn:session:{kind}:{username} |
STRING, TTL 5 мин | состояние ceremony (register/login) между begin и finish, одноразовое |
Java-бэкенд решает ту же задачу структурно иначе — WebauthnStore держит четыре ConcurrentHashMap в памяти процесса вместо Redis: рестарт контейнера стирает все зарегистрированные credentials целиком. Для контраста двух реализаций это осознанно вынесено за скобки — важна крипто-верификация, а не персистентность, — но переносить как есть в прод нельзя: без внешнего хранилища сервис не может быть отказоустойчивым и теряет состояние на каждый деплой.
Список остальных честных упрощений демо, которые стоит проговорить вслух, а не молчать о них:
- Регистрация ключа не требует, чтобы владелец аккаунта был авторизован — главное упрощение стенда.
registerBeginпо переданномуusernameзагружает существующего пользователя, аregisterFinishдобавляет новый credential в его список без проверки сессии владельца. Значит, зная чужой логин, можно привязать к чужому аккаунту свой аутентификатор и затем войти под этим именем. В демо это приемлемо только как «регистрация нового пользователя»; в продеregister/beginдля существующего аккаунта допустим лишь из уже авторизованной сессии этого аккаунта, причём свежей (повторный вход или подтверждение имеющимся фактором перед добавлением ключа), а для неавторизованного запроса — только создание нового пользователя. Успешная attestation доказывает владение аутентификатором, но не принадлежность аккаунта. excludeCredentialsне заполняется ни одним из бэкендов. При регистрации оба сервиса не передают аутентификатору список уже существующих credential’ов пользователя, поэтому повторная регистрация того же ключа для того же пользователя не блокируется на уровне опций создания — в результате вCredentialsпоявляется второй, отдельный credential вместо переиспользования первого. В продеexcludeCredentials— это UX-защита (не дать пользователю по ошибке завести дубликат), и её стоит заполнять явно.- Java — non-strict-менеджер.
WebAuthnManager.createNonStrictWebAuthnManager()ослабляет часть встроенных проверокwebauthn4jпо сравнению со строгим режимом — уровень строгости сопоставим с тем, что даёт Go-бэкенд без дополнительных настроек, но не эквивалентен production-конфигурацииwebauthn4j. - Java —
pubKeyCredParams=nullпри верификации.new RegistrationParameters(serverProperty, null, false, true)означает, что на этапе проверки регистрации сервер не ограничивает набор допустимых алгоритмов подписи — полагается на то, что реальный аутентификатор сам предложит алгоритм, который сервер способен проверить. В проде — явный whitelist алгоритмов.
Последние три пункта — из раздела «Честные компромиссы demo» README стенда; первый и неатомарность одноразового challenge следуют из кода server.go. Все они присутствуют в коде ровно в таком виде.
UX и фолбэк: passkey не отменяет остальное
Стенд статьи демонстрирует изолированную WebAuthn-церемонию — регистрацию и вход одним ключом, без встраивания в существующую сессионную модель или в другие методы входа серии. Реальное внедрение passkeys почти никогда не заменяет всё разом: переходный период, в котором часть пользователей ещё не завела passkey (старое устройство без secure enclave, браузер без поддержки WebAuthn, ключ забыт дома), требует держать рабочим и старый путь — пароль или OTP/OAuth из четвёртой статьи, возможно, усиленный TOTP как вторым фактором там, где passkey пока недоступен. Успешная WebAuthn-церемония на практике должна заканчиваться тем же самым, чем заканчивается любой другой метод входа в серии — выдачей сессии по той же модели, что разобрана в первой статье: opaque-сессия в Redis или JWT-пара, в зависимости от архитектуры сервиса. Сама по себе успешная loginFinish в стенде этот шаг не делает — она лишь подтверждает владение ключом; связка с выдачей полноценной сессии — то, что нужно добавить поверх, соединяя эту статью с самым началом серии.
Комментарии