The Jerusalem PostConman posing as reservist with financial trouble who swindled dozens since start of war arrestedInquirerQuiapo Church unveils logo for Nazareno 2027ESPNSources: Eagles to keep retired Johnson on roster amid O-line changesPunchNDLEA rearrests 83-year-old grandmother with drugs in Abia한겨레소녀 28명 살았던 아동양육시설 ‘수풀원’…“폭행 일삼고, 16살에 가사도우미로 넘겨”UOLPolícia apura possível ataque a premiê e ministro na PolôniaSky TG24Sicurezza sul lavoro, Mattarella: "La tutela è nella Costituzione"Collider'Landman' Star's Fan-Favorite Horror Nightmare Officially Becomes a Must-Watch on HuluBillboardNFL London Game Halftime Show 2026: Here’s Where to Watch Perform Ayra Starr Live Online FreeZDF heuteAktuelle Pressemitteilungen des ZDFn-tv"Auf Seite des Mörders": Politikerinnen attestieren Trump "moralischen Bankrott"Observador DesportoPortugal anda a fazer acrobacias diplomáticas com Trump?
The Daily Newsstand · Free, Always
Sunday, October 11, 2026

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

Translate

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), и дальше всё работает как раньше. Не надо переделывать всю авторизацию.

Регистрация Passkey

Регистрация Passkey

Аутентификация

Аутентификация

Общая архитектура (что хранится где)

Общая архитектура (что хранится где)

Стек и зависимости

Минимум зависимостей. Две библиотеки покрывают всё:

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

/passkeys/register/options

JWT

Challenge для регистрации

POST

/passkeys/register/verify

JWT

Сохранить новый ключ

POST

/passkeys/authenticate/options

Нет

Challenge для логина

POST

/passkeys/authenticate/verify

Нет

Проверить ключ → JWT

GET

/passkeys

JWT

Список ключей

GET

/passkeys/count

JWT

Количество ключей

PATCH

/passkeys/{id}

JWT

Переименовать

DELETE

/passkeys/{id}

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:

Субдомены

Если фронт на app.example.com, а API на api.example.com:

Если поставить RP_ID = app.example.com — тоже сработает, но passkey не будет работать с admin.example.com (если вдруг захотите общую авторизацию).

Полная таблица ошибок

Ошибка

Причина

Что делать

SecurityError: RP ID is not a registrable domain suffix

RP_ID не совпадает с доменом фронтенда

Проверить env, убрать протокол из RP_ID

NotAllowedError: The operation was cancelled

Пользователь закрыл диалог Face ID

Не ошибка — isWebAuthnCancellation()

Challenge not found or expired

Challenge протух (>5 мин) или уже использован

Повторить запрос /options

Registration verification failed

Origin не совпадает

Проверить WEBAUTHN_RP_ORIGIN

Credential not found

Ключ удалён или создан для другого домена

Перерегистрировать passkey

Authentication verification failed

sign_count конфликт (клон?)

Удалить ключ, создать заново

В начале я писал, что ваш сервис до сих пор просит Qwerty123!. После трёх дней работы наш — уже нет.

Что получилось по факту:

  • 2 таблицы в PostgreSQL, 4 ключевых эндпоинта, 1 фронт-утилита — это минимальный набор для рабочего Passkey-логина

  • ~3 дня от «давайте попробуем» до production. Большая часть времени — разобраться в концепции. Сам код простой, библиотеки делают за тебя криптографию и работу с бинарными форматами

  • Ноль запросов на сброс пароля от пользователей, которые настроили passkey. Потому что пароля нет — нечего забывать

  • Фишинг невозможен на уровне протокола. Не «надеемся, что пользователь не попадётся», а технически невозможно — origin зашит в криптографию

Это моя первая статья на Хабре. Писал то, что хотел бы прочитать сам, когда начинал разбираться в теме — полный рабочий код, без теоретической воды.

У меня остался открытый вопрос: мы храним challenge в PostgreSQL, потому что не хотели тащить Redis ради 32-байтных записей с TTL 5 минут. При нашей нагрузке это работает. Но если у вас тысячи одновременных логинов — интересно, кто-то пробовал Redis или in-memory store для challenge? Есть ли ощутимая разница?

И шире — если вы уже внедряли Passkeys в своём проекте, расскажите, как решали. Может, есть подход проще или грабли, которые я не описал. Буду рад обсудить в комментариях.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.