Passkeys — это не будущее. Это уже работает в каждом банковском приложении на вашем телефоне. Альфа-Банк, Тинькофф, Сбер — давно. GitHub, Apple, Google — тоже. А ваш веб-сервис до сих пор просит придумать пароль с заглавной буквой, цифрой и спецсимволом. И потом ещё «восстановить пароль» раз в месяц.

Меня зовут Ярослав Морозов, я строю Continental — IT-консалтинг и параллельно пишу внутренний продукт. Когда дошли до авторизации, решили не мучить пользователей паролями и внедрили Passkeys. Оказалось проще, чем я думал.
В этой статье — полный production-код. От миграции БД до кнопки «Войти по Face ID». FastAPI на бэке, Next.js на фронте. Можно взять и внедрить.

Зачем вообще Passkeys
Короткий ответ — безопаснее и удобнее. Длинный:
Пароли |
Passkeys |
|
|---|---|---|
Фишинг |
Пользователь вводит пароль на фейковом сайте |
Невозможен — ключ привязан к домену на уровне криптографии |
Утечки БД |
Хеши bcrypt можно подбирать месяцами. Или не можно, но попробуют |
Приватный ключ не покидает устройство. В БД только публичный |
UX |
Придумать, запомнить, ввести. Забыл — восстанавливай |
Палец на сканер или взгляд в камеру. Всё |
Повторное использование |
65% пользователей используют один пароль везде |
Уникальная пара ключей для каждого сайта. Автоматически |
Passkeys поддерживаются всеми основными браузерами:

По данным FIDO Alliance, более 15 миллиардов аккаунтов уже поддерживают passkeys. Google, Apple, Microsoft — все три вендора операционных систем — встроили поддержку на уровне ОС.
Технически Passkeys — это реализация стандарта WebAuthn (W3C) из семейства протоколов FIDO2. Но вам не нужно читать спецификацию на 200 страниц. Библиотеки делают за вас 90% работы.
Как это работает
На пальцах
Passkey — это пара асимметричных ключей:
Приватный ключ живёт в Secure Enclave вашего устройства. Это отдельный чип, к которому даже ОС не имеет прямого доступа. Ключ никогда не покидает устройство.
Публичный ключ отправляется на сервер при регистрации и сохраняется в БД.
При аутентификации сервер отправляет случайный challenge (32 байта). Устройство подписывает его приватным ключом (после подтверждения биометрией). Сервер проверяет подпись публичным ключом.
Всё. Пароль никогда не передаётся по сети, потому что его нет.
Синхронизация между устройствами
Создали passkey на iPhone — он автоматически появится на MacBook через iCloud Keychain. Создали на Android — синхронизируется через Google Password Manager. Это называется Discoverable Credentials — устройство само знает, какие ключи есть для какого сайта.
Связь с JWT
Важный момент: Passkeys не заменяют JWT. Они заменяют ввод логина-пароля. После успешной верификации passkey бэкенд выдаёт обычные JWT-токены (access + refresh), и дальше всё работает как раньше. Не надо переделывать всю авторизацию.



Стек и зависимости
Минимум зависимостей. Две библиотеки покрывают всё:
Backend (Python):
pip install webauthn>=2.0.0
py-webauthn — зрелая библиотека, которую поддерживает автор спецификации WebAuthn. Генерирует challenge, верифицирует ответы, парсит CBOR. Без неё пришлось бы руками работать с бинарными форматами — поверьте, не хотите.
Frontend (TypeScript):
npm install @simplewebauthn/browser @simplewebauthn/types
SimpleWebAuthn — обёртка над navigator.credentials. Главная ценность: конвертирует ArrayBuffer ↔ base64url автоматически. Кто работал с WebCrypto API, знает этот боль — бесконечные btoa(), Uint8Array, TextEncoder. SimpleWebAuthn убирает всё это.
База данных
Две таблицы. Всё.
webauthn_credentials — хранит ключи
CREATE TABLE webauthn_credentials ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, credential_id BYTEA NOT NULL UNIQUE, public_key BYTEA NOT NULL, sign_count INTEGER DEFAULT 0, transports VARCHAR(255), device_name VARCHAR(100), created_at TIMESTAMPTZ DEFAULT now(), last_used_at TIMESTAMPTZ ); CREATE INDEX ix_webauthn_cred_credential_id ON webauthn_credentials(credential_id); CREATE INDEX ix_webauthn_cred_user_id ON webauthn_credentials(user_id);
Что тут что:
credential_id(BYTEA) — уникальный ID ключа, выданный аутентификатором. По нему ищем credential при логине. В API ходит как base64url-строка, в БД лежит как raw bytes.public_key(BYTEA) — COSE-кодированный публичный ключ. Им проверяем подпись при аутентификации.sign_count— счётчик, который аутентификатор инкрементирует при каждом использовании. Если пришёл sign_count меньше сохранённого — кто-то склонировал ключ. Подробнее в секции «Безопасность».transports— JSON-массив:["internal"]для встроенных аутентификаторов (Face ID, Touch ID),["usb"]для YubiKey,["hybrid"]для QR-code flow. Помогает браузеру сразу показать нужный UI.device_name— пользователь может назвать ключ: «iPhone работа», «MacBook дом». Чисто для UX.
webauthn_challenges — одноразовые challenge
CREATE TABLE webauthn_challenges ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, challenge BYTEA NOT NULL, created_at TIMESTAMPTZ DEFAULT now() );
Challenge живёт 5 минут. После использования — удаляется из БД. Одноразовый, как OTP.
user_id nullable — при аутентификации мы ещё не знаем, кто логинится (discoverable credentials), поэтому challenge создаётся без привязки к пользователю.
Почему PostgreSQL, а не Redis? Для challenge-ей хватает обычной таблицы. Их мало (1 challenge на 1 попытку логина), они маленькие (32 байта + метаданные), живут недолго. Если нагрузка вырастет — перенести в Redis за 15 минут, API не изменится.
SQLAlchemy-модели
import uuid from datetime import datetime from sqlalchemy import DateTime, ForeignKey, Integer, LargeBinary, String, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship class WebAuthnCredential(Base): __tablename__ = "webauthn_credentials" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) credential_id: Mapped[bytes] = mapped_column( LargeBinary, unique=True, index=True, nullable=False, ) public_key: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) sign_count: Mapped[int] = mapped_column(Integer, default=0) transports: Mapped[str | None] = mapped_column(String(255), nullable=True) device_name: Mapped[str | None] = mapped_column(String(100), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), ) last_used_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) user: Mapped["User"] = relationship(back_populates="webauthn_credentials") class WebAuthnChallenge(Base): __tablename__ = "webauthn_challenges" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, ) user_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, ) challenge: Mapped[bytes] = mapped_column(LargeBinary, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), )
В модели User добавляем relationship:
class User(Base): # ... существующие поля ... webauthn_credentials: Mapped[list["WebAuthnCredential"]] = relationship( back_populates="user", cascade="all, delete-orphan", # Удалили пользователя — удалились его ключи )
Backend: полный сервис
Конфигурация
Три env-переменные:
# Домен сайта — без протокола, без порта WEBAUTHN_RP_ID=example.com # Имя, которое увидит пользователь в системном диалоге Face ID WEBAUTHN_RP_NAME=My App # Полный URL фронтенда — проверяется при верификации WEBAUTHN_RP_ORIGIN=https://example.com
Для локальной разработки:
WEBAUTHN_RP_ID=localhost WEBAUTHN_RP_NAME=My App Dev WEBAUTHN_RP_ORIGIN=http://localhost:3000
Pydantic Settings:
from pydantic_settings import BaseSettings class Settings(BaseSettings): # ... остальные настройки ... webauthn_rp_id: str = "localhost" webauthn_rp_name: str = "My App" webauthn_rp_origin: str = "http://localhost:3000" settings = Settings()
Обработка ошибок
Прежде чем переходить к сервису — определим кастомные исключения:
from fastapi import HTTPException class AuthException(HTTPException): """Базовый класс для ошибок авторизации.""" def __init__(self, detail: str, status_code: int = 400): super().__init__(status_code=status_code, detail=detail) class WebAuthnError(AuthException): """400 — ошибки верификации, протухший challenge и т.д.""" def __init__(self, detail: str = "WebAuthn error"): super().__init__(detail=detail, status_code=400) class PasskeyNotFoundError(AuthException): """404 — passkey не найден или не принадлежит пользователю.""" def __init__(self): super().__init__(detail="Passkey not found", status_code=404) class UserNotFoundError(AuthException): """404 — пользователь не найден.""" def __init__(self): super().__init__(detail="User not found", status_code=404) class UserNotActiveError(AuthException): """403 — аккаунт деактивирован.""" def __init__(self): super().__init__(detail="User account is deactivated", status_code=403)
WebAuthnService — весь сервис
Вот полный рабочий сервис. Комментарии на каждом шаге — чтобы было понятно, что происходит и зачем:
"""WebAuthn / Passkeys — регистрация и аутентификация через FIDO2.""" import json import uuid from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from webauthn import ( generate_authentication_options, generate_registration_options, verify_authentication_response, verify_registration_response, ) from webauthn.helpers import bytes_to_base64url, options_to_json from webauthn.helpers.structs import ( AuthenticatorSelectionCriteria, AuthenticatorTransport, PublicKeyCredentialDescriptor, ResidentKeyRequirement, UserVerificationRequirement, ) CHALLENGE_TTL_MINUTES = 5 def _parse_transports(raw: str | None) -> list[AuthenticatorTransport]: """JSON-строка транспортов → enum-ы py-webauthn.""" if not raw: return [] try: return [AuthenticatorTransport(t) for t in json.loads(raw)] except (ValueError, KeyError): return [] class WebAuthnService: def __init__(self, db: AsyncSession): self.db = db # ── Challenge management ────────────────────────────── async def _save_challenge( self, challenge: bytes, user_id: uuid.UUID | None = None, ) -> uuid.UUID: """Сохранить challenge в БД, вернуть UUID для передачи на фронт.""" ch = WebAuthnChallenge(user_id=user_id, challenge=challenge) self.db.add(ch) await self.db.flush() return ch.id async def _pop_challenge(self, challenge_id: uuid.UUID) -> bytes: """ Извлечь challenge и сразу удалить. Одноразовый. Проверяет TTL — если прошло больше 5 минут, отклоняем. """ result = await self.db.execute( select(WebAuthnChallenge).where(WebAuthnChallenge.id == challenge_id) ) ch = result.scalar_one_or_none() if not ch: raise WebAuthnError("Challenge not found or expired") cutoff = datetime.now(UTC) - timedelta(minutes=CHALLENGE_TTL_MINUTES) if ch.created_at.replace(tzinfo=UTC) < cutoff: await self.db.delete(ch) await self.db.flush() raise WebAuthnError("Challenge expired") challenge = ch.challenge await self.db.delete(ch) # Pop — достали и сразу удалили await self.db.flush() return challenge async def _get_user_credentials( self, user_id: uuid.UUID, ) -> list[WebAuthnCredential]: result = await self.db.execute( select(WebAuthnCredential).where( WebAuthnCredential.user_id == user_id ) ) return list(result.scalars().all()) # ── Registration ────────────────────────────────────── async def generate_registration_options(self, user: User) -> dict: """ Шаг 1: сгенерировать challenge и опции. exclude_credentials — список уже существующих ключей пользователя, чтобы аутентификатор не создал дубликат. """ existing = await self._get_user_credentials(user.id) exclude_credentials = [ PublicKeyCredentialDescriptor( id=cred.credential_id, transports=_parse_transports(cred.transports), ) for cred in existing ] options = generate_registration_options( rp_id=settings.webauthn_rp_id, rp_name=settings.webauthn_rp_name, user_id=user.id.bytes, user_name=user.email, user_display_name=( f"{user.first_name or ''} {user.last_name or ''}".strip() or user.email ), authenticator_selection=AuthenticatorSelectionCriteria( # PREFERRED — создать discoverable credential, если устройство # поддерживает. Это позволяет логиниться без ввода email. resident_key=ResidentKeyRequirement.PREFERRED, # PREFERRED — попросить биометрию, но не ломать flow на # устройствах без Face ID / Touch ID (будет запрошен PIN). user_verification=UserVerificationRequirement.PREFERRED, ), exclude_credentials=exclude_credentials, ) challenge_id = await self._save_challenge(options.challenge, user.id) await self.db.commit() # options_to_json() конвертирует bytes → base64url, enum-ы → строки options_json = json.loads(options_to_json(options)) # Добавляем challengeId — фронт вернёт его на шаге 2 options_json["challengeId"] = str(challenge_id) return options_json async def verify_registration( self, user: User, challenge_id: uuid.UUID, credential: dict, device_name: str | None = None, ) -> WebAuthnCredential: """ Шаг 2: проверить ответ аутентификатора, сохранить ключ в БД. credential — JSON-объект от startRegistration() (SimpleWebAuthn). py-webauthn проверяет: challenge совпадает, origin совпадает, RP ID совпадает, подпись валидна. """ challenge = await self._pop_challenge(challenge_id) try: verification = verify_registration_response( credential=credential, expected_challenge=challenge, expected_rp_id=settings.webauthn_rp_id, expected_origin=settings.webauthn_rp_origin, ) except Exception as e: raise WebAuthnError(f"Registration verification failed: {e}") transports = credential.get("response", {}).get("transports", []) cred = WebAuthnCredential( user_id=user.id, credential_id=verification.credential_id, public_key=verification.credential_public_key, sign_count=verification.sign_count, transports=json.dumps(transports) if transports else None, device_name=device_name, ) self.db.add(cred) await self.db.commit() await self.db.refresh(cred) return cred # ── Authentication ──────────────────────────────────── async def generate_authentication_options( self, email: str | None = None, ) -> dict: """ Шаг 1: сгенерировать challenge для логина. email опционален: - Если передан — фильтруем ключи конкретного пользователя (allow_credentials). Браузер покажет только его ключи. - Если не передан — allow_credentials пустой. Аутентификатор предложит все discoverable credentials для этого домена. Это тот самый «вход без ввода email». """ allow_credentials = [] if email: result = await self.db.execute( select(User).where(User.email == email.lower()) ) user = result.scalar_one_or_none() if user: creds = await self._get_user_credentials(user.id) allow_credentials = [ PublicKeyCredentialDescriptor( id=c.credential_id, transports=( json.loads(c.transports) if c.transports else [] ), ) for c in creds ] options = generate_authentication_options( rp_id=settings.webauthn_rp_id, allow_credentials=( allow_credentials if allow_credentials else None ), user_verification=UserVerificationRequirement.PREFERRED, ) # При аутентификации user_id = None (ещё не знаем кто логинится) challenge_id = await self._save_challenge(options.challenge) await self.db.commit() options_json = json.loads(options_to_json(options)) options_json["challengeId"] = str(challenge_id) return options_json async def verify_authentication( self, challenge_id: uuid.UUID, credential: dict, ) -> TokenResponse: """ Шаг 2: проверить подпись, выдать JWT. Порядок: 1. Извлекаем challenge из БД (pop — одноразовый) 2. Находим credential по rawId 3. Загружаем пользователя с ролями 4. Проверяем подпись через py-webauthn 5. Обновляем sign_count и last_used_at 6. Выдаём JWT access + refresh """ challenge = await self._pop_challenge(challenge_id) # rawId приходит как base64url-строка raw_id = credential.get("rawId") or credential.get("id") if not raw_id: raise WebAuthnError("Missing credential ID") from webauthn.helpers import base64url_to_bytes try: cred_id_bytes = base64url_to_bytes(raw_id) except Exception: raise WebAuthnError("Invalid credential ID format") # Ищем в БД по credential_id (bytes) result = await self.db.execute( select(WebAuthnCredential).where( WebAuthnCredential.credential_id == cred_id_bytes ) ) stored_cred = result.scalar_one_or_none() if not stored_cred: raise WebAuthnError("Credential not found") # Загружаем пользователя с eager-loading ролей (нужны для JWT payload) result = await self.db.execute( select(User) .options( selectinload(User.service_roles) .selectinload(UserServiceRole.service) ) .where(User.id == stored_cred.user_id) ) user = result.scalar_one_or_none() if not user: raise UserNotFoundError() if not user.is_active: raise UserNotActiveError() # Верификация подписи — py-webauthn проверяет всё: # challenge, origin, RP ID, подпись, sign_count try: verification = verify_authentication_response( credential=credential, expected_challenge=challenge, expected_rp_id=settings.webauthn_rp_id, expected_origin=settings.webauthn_rp_origin, credential_public_key=stored_cred.public_key, credential_current_sign_count=stored_cred.sign_count, ) except Exception as e: raise WebAuthnError(f"Authentication verification failed: {e}") # Обновляем sign_count (для детекции клонов) и last_used_at stored_cred.sign_count = verification.new_sign_count stored_cred.last_used_at = datetime.now(UTC) # Выдаём JWT — точно так же, как при обычном логине по паролю access_token = create_access_token(user.id) refresh_token, jti, expires_at = create_refresh_token(user.id) token_record = RefreshToken( user_id=user.id, jti=jti, expires_at=expires_at, ) self.db.add(token_record) await self.db.commit() return TokenResponse( access_token=access_token, refresh_token=refresh_token, user=UserResponse.from_user(user), ) # ── Passkey management ──────────────────────────────── async def get_user_passkeys( self, user_id: uuid.UUID, ) -> list[WebAuthnCredential]: return await self._get_user_credentials(user_id) async def get_user_passkeys_count(self, user_id: uuid.UUID) -> int: creds = await self._get_user_credentials(user_id) return len(creds) async def rename_passkey( self, user_id: uuid.UUID, credential_id: uuid.UUID, device_name: str, ) -> WebAuthnCredential: result = await self.db.execute( select(WebAuthnCredential).where( WebAuthnCredential.id == credential_id, WebAuthnCredential.user_id == user_id, # Проверка владельца! ) ) cred = result.scalar_one_or_none() if not cred: raise PasskeyNotFoundError() cred.device_name = device_name await self.db.commit() await self.db.refresh(cred) return cred async def delete_passkey( self, user_id: uuid.UUID, credential_id: uuid.UUID, ) -> None: result = await self.db.execute( select(WebAuthnCredential).where( WebAuthnCredential.id == credential_id, WebAuthnCredential.user_id == user_id, ) ) cred = result.scalar_one_or_none() if not cred: raise PasskeyNotFoundError() await self.db.delete(cred) await self.db.commit()
API эндпоинты (FastAPI)
import uuid from typing import Annotated from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, field_validator router = APIRouter(prefix="/api/auth", tags=["Authentication"]) # ── Schemas ────────────────────────────────────────────── class PasskeyResponse(BaseModel): id: uuid.UUID device_name: str | None = None created_at: datetime last_used_at: datetime | None = None class Config: from_attributes = True class PasskeyRenameRequest(BaseModel): device_name: str @field_validator("device_name") @classmethod def validate_name(cls, v: str) -> str: v = v.strip() if not v: raise ValueError("Device name cannot be empty") if len(v) > 100: raise ValueError("Device name must be at most 100 characters") return v class PasskeyCountResponse(BaseModel): count: int # ── Dependency ─────────────────────────────────────────── def get_webauthn_service( db: Annotated[AsyncSession, Depends(get_db)], ) -> WebAuthnService: return WebAuthnService(db) # ── Registration (auth required) ───────────────────────── @router.post("/passkeys/register/options") async def passkey_register_options( user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): return await service.generate_registration_options(user) @router.post("/passkeys/register/verify", response_model=PasskeyResponse) async def passkey_register_verify( body: dict, user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): challenge_id = uuid.UUID(body.pop("challengeId")) device_name = body.pop("deviceName", None) cred = await service.verify_registration( user, challenge_id, body, device_name, ) return PasskeyResponse.model_validate(cred) # ── Authentication (no auth!) ──────────────────────────── @router.post("/passkeys/authenticate/options") async def passkey_auth_options( service: Annotated[WebAuthnService, Depends(get_webauthn_service)], body: dict | None = None, ): email = body.get("email") if body else None return await service.generate_authentication_options(email) @router.post("/passkeys/authenticate/verify", response_model=TokenResponse) async def passkey_auth_verify( body: dict, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): challenge_id = uuid.UUID(body.pop("challengeId")) return await service.verify_authentication(challenge_id, body) # ── Management (auth required) ─────────────────────────── @router.get("/passkeys", response_model=list[PasskeyResponse]) async def get_passkeys( user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): creds = await service.get_user_passkeys(user.id) return [PasskeyResponse.model_validate(c) for c in creds] @router.get("/passkeys/count", response_model=PasskeyCountResponse) async def get_passkeys_count( user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): count = await service.get_user_passkeys_count(user.id) return PasskeyCountResponse(count=count) @router.patch("/passkeys/{credential_id}", response_model=PasskeyResponse) async def rename_passkey( credential_id: uuid.UUID, data: PasskeyRenameRequest, user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): cred = await service.rename_passkey( user.id, credential_id, data.device_name, ) return PasskeyResponse.model_validate(cred) @router.delete("/passkeys/{credential_id}") async def delete_passkey( credential_id: uuid.UUID, user: CurrentActiveUser, service: Annotated[WebAuthnService, Depends(get_webauthn_service)], ): await service.delete_passkey(user.id, credential_id) return {"message": "Passkey deleted"}
Итого 8 эндпоинтов:
Метод |
URL |
Auth |
Зачем |
|---|---|---|---|
POST |
|
JWT |
Challenge для регистрации |
POST |
|
JWT |
Сохранить новый ключ |
POST |
|
Нет |
Challenge для логина |
POST |
|
Нет |
Проверить ключ → JWT |
GET |
|
JWT |
Список ключей |
GET |
|
JWT |
Количество ключей |
PATCH |
|
JWT |
Переименовать |
DELETE |
|
JWT |
Удалить |
Frontend: от утилиты до кнопки
webauthn.ts — ядро
import { browserSupportsWebAuthn, startRegistration, startAuthentication, } from "@simplewebauthn/browser"; import { apiClient } from "./api-client"; export { browserSupportsWebAuthn }; // ── Регистрация ───────────────────────────────────────── export async function registerPasskey( deviceName?: string, ): Promise<boolean> { // 1. Получить challenge от сервера (нужен JWT — пользователь залогинен) const options = await apiClient.post<Record<string, unknown>>( "/api/auth/passkeys/register/options", ); const challengeId = options.challengeId as string; // 2. Показать Face ID / Touch ID / Windows Hello // startRegistration() — обёртка над navigator.credentials.create() const credential = await startRegistration({ optionsJSON: options as any, }); // 3. Отправить результат на бэк для верификации await apiClient.post("/api/auth/passkeys/register/verify", { ...credential, challengeId, deviceName: deviceName || null, }); return true; } // ── Аутентификация ────────────────────────────────────── interface TokenResponse { access_token: string; refresh_token: string; token_type: string; user: unknown; } export async function authenticateWithPasskey( email?: string, ): Promise<TokenResponse> { // 1. Получить challenge (публичный эндпоинт — без JWT) const options = await apiClient.post<Record<string, unknown>>( "/api/auth/passkeys/authenticate/options", email ? { email } : {}, { skipAuth: true }, ); const challengeId = options.challengeId as string; // 2. Face ID / Touch ID // startAuthentication() — обёртка над navigator.credentials.get() const credential = await startAuthentication({ optionsJSON: options as any, }); // 3. Проверить на бэке → получить JWT const tokenResponse = await apiClient.post<TokenResponse>( "/api/auth/passkeys/authenticate/verify", { ...credential, challengeId }, { skipAuth: true }, ); return tokenResponse; } // ── Обработка отмены Face ID ───────────────────────────── export function isWebAuthnCancellation(err: Error): boolean { const msg = err.message || err.name || ""; return ( msg.includes("NotAllowedError") || // User denied biometric msg.includes("AbortError") || // User cancelled dialog msg.includes("cancelled") || // Safari/macOS msg.includes("not allowed") || // Android msg.includes("denied permission") // Firefox ); } // ── Quick-login helpers ────────────────────────────────── // После успешного логина по passkey сохраняем имя и email в localStorage. // При следующем заходе — показываем "Привет, Ярослав" вместо формы пароля. const PASSKEY_USER_KEY = "passkey_user"; export interface PasskeyUser { name: string; email: string; } export function savePasskeyUser(name: string, email: string): void { localStorage.setItem(PASSKEY_USER_KEY, JSON.stringify({ name, email })); } export function getPasskeyUser(): PasskeyUser | null { try { const raw = localStorage.getItem(PASSKEY_USER_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } export function clearPasskeyUser(): void { localStorage.removeItem(PASSKEY_USER_KEY); }
SWR-хуки для управления ключами
import useSWR from "swr"; import { apiClient } from "@/lib/api-client"; export interface Passkey { id: string; device_name: string | null; created_at: string; last_used_at: string | null; } const fetcher = <T>(url: string) => apiClient.get<T>(url); export function usePasskeys() { const { data, error, isLoading, mutate } = useSWR<Passkey[]>( "/api/auth/passkeys", fetcher, ); return { passkeys: data || [], isLoading, error, mutate }; } export function usePasskeysCount() { const { data, error, isLoading, mutate } = useSWR<{ count: number }>( "/api/auth/passkeys/count", fetcher, ); return { count: data?.count ?? 0, isLoading, error, mutate }; } export async function renamePasskey( credentialId: string, deviceName: string, ): Promise<Passkey> { return apiClient.patch<Passkey>( `/api/auth/passkeys/${credentialId}`, { device_name: deviceName }, ); } export async function deletePasskey(credentialId: string): Promise<void> { await apiClient.delete(`/api/auth/passkeys/${credentialId}`); }
Интеграция с Auth Context
В существующий auth-контекст добавляем один метод:
// auth-context.tsx import { authenticateWithPasskey, savePasskeyUser } from "./webauthn"; // Внутри AuthProvider: const loginWithPasskey = useCallback( async (email?: string) => { const response = await authenticateWithPasskey(email); // Сохраняем токены — как при обычном логине setTokens(response.access_token, response.refresh_token); const user = response.user as User; // Запоминаем пользователя для quick-login savePasskeyUser( user.first_name || user.email.split("@")[0], user.email, ); setState({ user, isLoading: false, isAuthenticated: true }); }, [], );
Quick Login — экран как в банковских приложениях
Это тот самый UX, ради которого всё затевалось. Открываешь приложение — и вместо формы пароля видишь «Привет, Ярослав» с кнопкой Face ID. Как в Альфе или Тинькофф.

function QuickLoginView({ passkeyUser, onUsePassword, }: { passkeyUser: PasskeyUser; onUsePassword: () => void; }) { const router = useRouter(); const { loginWithPasskey } = useAuth(); const [isLoading, setIsLoading] = useState(false); const handleLogin = useCallback(async () => { setIsLoading(true); try { await loginWithPasskey(); router.push("/dashboard"); } catch (error) { if (error instanceof ApiError) { // Ключ удалён на сервере — сбрасываем quick-login if (error.message?.includes("Credential not found")) { clearPasskeyUser(); onUsePassword(); return; } toast.error(error.message); } else if (error instanceof Error) { // Пользователь закрыл Face ID — молча игнорируем if (!isWebAuthnCancellation(error)) { toast.error(error.message); } } } finally { setIsLoading(false); } }, [loginWithPasskey, router, onUsePassword]); // Автоматически показываем Face ID через 400ms после загрузки useEffect(() => { const timer = setTimeout(() => handleLogin(), 400); return () => clearTimeout(timer); }, []); return ( <div className="flex flex-col items-center py-4"> <div className="mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10"> <PiFingerprint className="h-7 w-7 text-primary" /> </div> <h2 className="text-xl font-medium"> Привет, {passkeyUser.name} </h2> <p className="mt-1 text-sm text-gray-500"> Вход доступен по Face ID </p> <div className="mt-6 w-full space-y-3"> <Button className="w-full" isLoading={isLoading} onClick={handleLogin}> <PiFingerprint className="mr-2 h-5 w-5" /> Войти по Face ID </Button> <button onClick={onUsePassword} className="w-full text-center text-sm text-gray-500 hover:text-gray-700 transition-colors" > Войти по паролю </button> </div> </div> ); }
Логика переключения на LoginForm
function LoginForm() { const [supportsWebAuthn, setSupportsWebAuthn] = useState(false); const [passkeyUser, setPasskeyUser] = useState<PasskeyUser | null>(null); const [forcePasswordMode, setForcePasswordMode] = useState(false); useEffect(() => { const supported = browserSupportsWebAuthn(); setSupportsWebAuthn(supported); if (supported) { setPasskeyUser(getPasskeyUser()); } }, []); // Если есть сохранённый пользователь — показываем Quick Login if (passkeyUser && supportsWebAuthn && !forcePasswordMode) { return ( <QuickLoginView passkeyUser={passkeyUser} onUsePassword={() => setForcePasswordMode(true)} /> ); } // Иначе — обычная форма с кнопкой "Войти по Passkey" return ( <form> {/* email + password fields */} <Button type="submit">Войти</Button> {supportsWebAuthn && ( <> <Divider>или</Divider> <Button variant="secondary" onClick={handlePasskeyLogin}> <PiFingerprint className="mr-2 h-5 w-5" /> Войти по Passkey </Button> </> )} </form> ); }
Post-Login Passkey Prompt
После первого логина по паролю проверяем: есть ли у пользователя passkeys? Если нет и он ещё не отказывался — предлагаем настроить.
const checkPasskeyPrompt = useCallback(async () => { if (!supportsWebAuthn) return; const resp = await fetch("/api/auth/passkeys/count", { headers: { Authorization: `Bearer ${token}` }, }); if (resp.ok) { const { count } = await resp.json(); // У пользователя уже есть passkeys — не трогаем if (count > 0) return; // Ранее отказался — не показываем if (localStorage.getItem("passkey_prompt_dismissed")) return; // Показываем модалку setShowPasskeyPrompt(true); } }, [supportsWebAuthn]);
Модалка простая: «Хотите настроить вход по Face ID?» + две кнопки — «Настроить» и «Не сейчас». При отказе ставим флаг в localStorage.
PasskeysManager — управление ключами в настройках

export function PasskeysManager() { const { user } = useAuth(); const { passkeys, isLoading, mutate } = usePasskeys(); const [isRegistering, setIsRegistering] = useState(false); if (!browserSupportsWebAuthn()) { return <p>Ваш браузер не поддерживает Passkeys</p>; } const handleAdd = async () => { setIsRegistering(true); try { await registerPasskey(); toast.success("Passkey добавлен"); if (user) { savePasskeyUser( user.first_name || user.email.split("@")[0], user.email, ); } mutate(); } catch (err) { if (err instanceof Error && !isWebAuthnCancellation(err)) { toast.error(err.message); } } finally { setIsRegistering(false); } }; const handleRename = async (id: string, name: string) => { await renamePasskey(id, name); toast.success("Переименовано"); mutate(); }; const handleDelete = async (id: string) => { await deletePasskey(id); toast.success("Passkey удалён"); // Последний ключ удалён — отключаем quick-login if (passkeys.length <= 1) clearPasskeyUser(); mutate(); }; return ( <div className="space-y-3"> {isLoading ? ( <p>Загрузка...</p> ) : passkeys.length === 0 ? ( <p className="text-gray-500">Нет зарегистрированных устройств</p> ) : ( passkeys.map((pk) => ( <PasskeyItem key={pk.id} passkey={pk} onRename={handleRename} onDelete={handleDelete} /> )) )} <Button onClick={handleAdd} isLoading={isRegistering} variant="secondary" > Добавить устройство </Button> </div> ); }
Безопасность
Не для галочки — тут есть нюансы, которые важно понимать.
Challenge: одноразовый, 5 минут
Сервер генерирует случайные 32 байта для каждой операции. Challenge сохраняется в БД, а при верификации — извлекается и сразу удаляется (_pop_challenge). Использовать дважды невозможно.
TTL — 5 минут. Если пользователь отвлёкся и не подтвердил Face ID за 5 минут — повторяем. Подбирать challenge бессмысленно: 32 байта = 2^256 вариантов.
Sign Count: детекция клонированных ключей
Аутентификатор при каждом использовании увеличивает внутренний счётчик и отправляет его серверу. Сервер проверяет: пришедший sign_count должен быть строго больше сохранённого.
Если кто-то скопировал ключ (что почти невозможно с Secure Enclave, но теоретически возможно с программными ключами) — и оригинал, и клон будут увеличивать свой счётчик независимо. Рано или поздно сервер увидит, что пришёл sign_count меньше ожидаемого — значит, ключ скомпрометирован.
py-webauthn делает эту проверку автоматически в verify_authentication_response().
Origin Verification: почему фишинг невозможен
Вот почему Passkeys фундаментально безопаснее паролей:
Когда браузер вызывает navigator.credentials.get(), он подписывает в ответе origin текущей страницы. Это делает браузер, не JavaScript — подделать невозможно.
Сервер проверяет: origin из ответа === WEBAUTHN_RP_ORIGIN из конфига.
Если атакующий создал фишинговый сайт https://evil.com — origin будет https://evil.com, а не https://example.com. Верификация упадёт. Пароль можно ввести на фишинговом сайте. Passkey — нет.
Resident Keys: вход без email
ResidentKeyRequirement.PREFERRED говорит аутентификатору: «сохрани credential_id и user_id внутри себя». Это позволяет логиниться без ввода email — устройство само знает, какие ключи есть для этого домена, и предложит выбрать.
На практике: пользователь заходит на сайт → нажимает «Войти по Face ID» → устройство показывает список аккаунтов → пользователь выбирает → Face ID → готово. Email не нужен.
Грабли
Без этого раздела статья была бы нечестной. Вот на чём мы споткнулись.
RP ID vs Origin — в чём разница
RP_ID — это домен. RP_ORIGIN — это полный URL с протоколом. Мы перепутали, поставили RP_ID=https://example.com вместо RP_ID=example.com. Получили:
SecurityError: The RP ID is not a registrable domain suffix of the current origin
Мораль: RP_ID = только домен, без протокола и порта.
NotAllowedError — это не ошибка
Когда пользователь закрывает диалог Face ID — браузер бросает NotAllowedError. Если показать toast.error() — пользователь увидит красное уведомление об ошибке после того, как просто передумал. Неприятно.
Решение — функция isWebAuthnCancellation(), которая проверяет текст ошибки по нескольким паттернам (каждый браузер формулирует по-своему). Если это отмена — молча игнорируем.
Localhost → Production
WebAuthn API не работает на HTTP — только на HTTPS. Единственное исключение: localhost. Поэтому на локалке всё работает, а при деплое — нет.
Чеклист для production:
WEBAUTHN_RP_ID=yourdomain.com(не localhost)WEBAUTHN_RP_ORIGIN=https://yourdomain.com(https!)Валидный SSL-сертификат
Субдомены
Если фронт на app.example.com, а API на api.example.com:
RP_ID =example.com— общий родительский доменRP_ORIGIN =https://app.example.com— откуда вызывается WebAuthn
Если поставить RP_ID = app.example.com — тоже сработает, но passkey не будет работать с admin.example.com (если вдруг захотите общую авторизацию).
Полная таблица ошибок
Ошибка |
Причина |
Что делать |
|---|---|---|
|
|
Проверить env, убрать протокол из RP_ID |
|
Пользователь закрыл диалог Face ID |
Не ошибка — |
|
Challenge протух (>5 мин) или уже использован |
Повторить запрос |
|
Origin не совпадает |
Проверить |
|
Ключ удалён или создан для другого домена |
Перерегистрировать passkey |
|
sign_count конфликт (клон?) |
Удалить ключ, создать заново |
В начале я писал, что ваш сервис до сих пор просит Qwerty123!. После трёх дней работы наш — уже нет.
Что получилось по факту:
2 таблицы в PostgreSQL, 4 ключевых эндпоинта, 1 фронт-утилита — это минимальный набор для рабочего Passkey-логина
~3 дня от «давайте попробуем» до production. Большая часть времени — разобраться в концепции. Сам код простой, библиотеки делают за тебя криптографию и работу с бинарными форматами
Ноль запросов на сброс пароля от пользователей, которые настроили passkey. Потому что пароля нет — нечего забывать
Фишинг невозможен на уровне протокола. Не «надеемся, что пользователь не попадётся», а технически невозможно — origin зашит в криптографию
Это моя первая статья на Хабре. Писал то, что хотел бы прочитать сам, когда начинал разбираться в теме — полный рабочий код, без теоретической воды.
У меня остался открытый вопрос: мы храним challenge в PostgreSQL, потому что не хотели тащить Redis ради 32-байтных записей с TTL 5 минут. При нашей нагрузке это работает. Но если у вас тысячи одновременных логинов — интересно, кто-то пробовал Redis или in-memory store для challenge? Есть ли ощутимая разница?
И шире — если вы уже внедряли Passkeys в своём проекте, расскажите, как решали. Может, есть подход проще или грабли, которые я не описал. Буду рад обсудить в комментариях.
Комментарии (7)

inkelyad
11.10.2026 08:23Вы своим "Сразу видишь кнопку Face ID", кажется, целую кучу пользователей распугиваете.
Кроме того, что биометрия вообще многих несколько пугает (хотя в данном случае она локальная), так, вообще говоря, Passkeys работают и без нее - просто PIN будет спрашиваться.
Ну и вы скрываете название технологии и запутываете тех, кто про нее знает и согласен использовать. Одновременно чуточку препятствуя ее узнаваемости и распространению.
Так что пишите нормально, как на первом экране и предоставляя выбор - 'Войти, используя Passkeys'.
Это, вроде бы, в официальных рекомендациях по внедрению даже есть.

for7raid
11.10.2026 08:23Если пользователь создал аккаунт с помощью этой технологии совсем без почты или другой внешней идентификации, то потеря ключа равно потере аккаунта. Как быть в таком случае?

shaggyone
11.10.2026 08:23Я не понял, почему ос доступа к ключу не имеет и это безопасно, но условный МакБук может получить ключ через синхронизацию и это всё ещё безопасно?
JBFW
То есть те, у кого по какой-то причине нет сканера пальца, камеры или желания ими пользоваться - смело могут пойти нафиг, верно?
А также те, кто по какой-то причине разбил смартфон, компьютер, вместе с сохраненным ключом...
inkelyad
Неверно. Если именно Passkeys используются. Но из показанного интерфейса это ни разу не понятно.
Во первых, Passkeys синхронизируются между устройствами. Во вторых - разных сценариев восстановления доступа никто не отменял.
for7raid
Так если при регистрации никакой другой идентификации не было, то что восстанавливать?
inkelyad
А тут нужно уже после регистрации капать на мозги "добавь способы восстановления/дополнительные способы входа". Логин/пароль может быть одним из них. И пускай где-то записанный в тумбочке лежит.
Если же клиент эти просьбы настойчиво игнорирует - ну так сам того хотел.