Passkeys в продакшне: как мы выкинули пароли и внедрили Face ID за 3 дня. FastAPI + Next.js, полный код

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.0py-webauthn — зрелая библиотека, которую поддерживает автор спецификации WebAuthn. Генерирует challenge, верифицирует ответы, парсит CBOR. Без неё пришлось бы руками работать с бинарными форматами — поверьте, не хотите.
Frontend (TypeScript):
npm install @simplewebauthn/browser @simplewebauthn/typesSimpleWebAuthn — обёртка над 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:3000Pydantic 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 в своём проекте, расскажите, как решали. Может, есть подход проще или грабли, которые я не описал. Буду рад обсудить в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.