← все статьи
7 мин

Аутентификация в 2025: JWT, sessions, passkeys

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

JWT: access + refresh rotation

JSON Web Token — это подписанный (или зашифрованный) контейнер данных. Сервер не хранит состояние: валидность токена проверяется криптографически, без обращения к базе данных. Это делает JWT привлекательным для горизонтально масштабируемых API — любой инстанс может проверить токен самостоятельно.

Главная ловушка: JWT нельзя отозвать до истечения срока. Если access token скомпрометирован, он действует до exp. Поэтому стандартная схема — короткий access token (15 минут) плюс долгий refresh token (30 дней) в httpOnly cookie.

# Python: PyJWT + refresh rotation
import jwt
import secrets
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass

SECRET = "your-256-bit-secret"
ALGORITHM = "HS256"
ACCESS_EXPIRE_MINUTES = 15
REFRESH_EXPIRE_DAYS = 30


@dataclass
class TokenPair:
    access_token: str
    refresh_token: str


def create_access_token(user_id: int, role: str) -> str:
    payload = {
        "sub": str(user_id),
        "role": role,
        "aud": "qdev-api",        # audience claim — защита от cross-service атак
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_EXPIRE_MINUTES),
        "jti": secrets.token_urlsafe(16),  # уникальный ID токена
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITHM)


def create_refresh_token() -> str:
    # refresh token — случайная непрозрачная строка, не JWT
    # хранится в БД (можно отозвать, ротировать)
    return secrets.token_urlsafe(64)


def issue_token_pair(user_id: int, role: str, db_session) -> TokenPair:
    access = create_access_token(user_id, role)
    refresh = create_refresh_token()

    # Сохраняем refresh token в БД
    db_session.execute(
        "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) "
        "VALUES (:uid, :hash, :exp)",
        {
            "uid": user_id,
            "hash": hash_token(refresh),   # храним хэш, не сам токен
            "exp": datetime.now(timezone.utc) + timedelta(days=REFRESH_EXPIRE_DAYS),
        }
    )
    return TokenPair(access_token=access, refresh_token=refresh)


def rotate_refresh_token(old_refresh: str, db_session) -> TokenPair:
    """Refresh rotation: старый токен инвалидируется, выдаётся новая пара."""
    row = db_session.execute(
        "SELECT user_id, id FROM refresh_tokens "
        "WHERE token_hash = :hash AND expires_at > now() AND revoked = false",
        {"hash": hash_token(old_refresh)}
    ).fetchone()

    if not row:
        raise ValueError("Invalid or expired refresh token")

    # Отзываем старый токен (detect reuse attack)
    db_session.execute(
        "UPDATE refresh_tokens SET revoked = true WHERE id = :id",
        {"id": row.id}
    )

    user = db_session.execute(
        "SELECT id, role FROM users WHERE id = :id", {"id": row.user_id}
    ).fetchone()

    return issue_token_pair(user.id, user.role, db_session)

Token blacklisting. Даже с коротким TTL иногда нужна немедленная инвалидация access token — при выходе, смене пароля, компрометации. Храните отозванные jti в Redis с TTL равным оставшемуся времени жизни токена:

import redis

r = redis.Redis(host="localhost", decode_responses=True)


def revoke_access_token(token: str) -> None:
    payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM], audience="qdev-api")
    jti = payload["jti"]
    exp = payload["exp"]
    ttl = exp - int(datetime.now(timezone.utc).timestamp())
    if ttl > 0:
        r.setex(f"revoked:{jti}", ttl, "1")


def is_token_revoked(jti: str) -> bool:
    return r.exists(f"revoked:{jti}") == 1


# В middleware проверки токена:
def verify_access_token(token: str) -> dict:
    payload = jwt.decode(token, SECRET, algorithms=[ALGORITHM], audience="qdev-api")
    if is_token_revoked(payload["jti"]):
        raise jwt.InvalidTokenError("Token has been revoked")
    return payload

Audience claims — важная деталь, которую пропускают. Если у вас несколько сервисов, токен выпущенный для одного не должен приниматься другим. Указывайте aud при выпуске и проверяйте его при валидации. Без этого токен, скомпрометированный через уязвимость в одном сервисе, даёт доступ ко всем остальным.

Server sessions: Redis-backed и горизонтальный скейлинг

Серверные сессии хранят состояние на бэкенде: клиент получает непрозрачный session ID в cookie, сервер хранит данные сессии (user_id, role, metadata) в хранилище. Главное преимущество: мгновенный revoke — удалил запись из Redis, сессия немедленно недействительна.

# FastAPI + Redis-backed sessions через itsdangerous
import json
import secrets
from fastapi import FastAPI, Request, Response, Depends, HTTPException
from redis.asyncio import Redis

SESSION_COOKIE = "session_id"
SESSION_TTL = 86400 * 30  # 30 дней

redis: Redis = Redis.from_url("redis://localhost:6379", decode_responses=True)


async def create_session(user_id: int, role: str, response: Response) -> str:
    session_id = secrets.token_urlsafe(32)
    session_data = {"user_id": user_id, "role": role}

    await redis.setex(
        f"session:{session_id}",
        SESSION_TTL,
        json.dumps(session_data)
    )

    response.set_cookie(
        key=SESSION_COOKIE,
        value=session_id,
        httponly=True,      # недоступен JS — защита от XSS
        secure=True,        # только HTTPS
        samesite="lax",     # защита от CSRF
        max_age=SESSION_TTL,
    )
    return session_id


async def get_current_user(request: Request) -> dict:
    session_id = request.cookies.get(SESSION_COOKIE)
    if not session_id:
        raise HTTPException(status_code=401, detail="Not authenticated")

    data = await redis.get(f"session:{session_id}")
    if not data:
        raise HTTPException(status_code=401, detail="Session expired")

    # Скользящее окно: продлеваем TTL при каждом запросе
    await redis.expire(f"session:{session_id}", SESSION_TTL)
    return json.loads(data)


async def revoke_session(session_id: str) -> None:
    await redis.delete(f"session:{session_id}")


async def revoke_all_user_sessions(user_id: int) -> None:
    """Отзываем все сессии пользователя (смена пароля, компрометация)."""
    # Если храним индекс user_id → [session_ids], это O(1)
    # Иначе — redis SCAN по паттерну (медленно на prod)
    session_ids = await redis.smembers(f"user_sessions:{user_id}")
    if session_ids:
        await redis.delete(*[f"session:{sid}" for sid in session_ids])
        await redis.delete(f"user_sessions:{user_id}")

Sticky sessions vs. shared session store. Sticky sessions (nginx ip_hash, AWS ELB stickiness) направляют пользователя всегда на один инстанс — сессия хранится in-memory. Это антипаттерн: при падении инстанса все пользователи разлогиниваются, скейлинг ограничен. Redis как shared session store решает обе проблемы: любой инстанс читает любую сессию.

Passkeys и WebAuthn: как работает под капотом

Passkey — это FIDO2/WebAuthn credential: пара ключей (приватный хранится на устройстве в TPM/Secure Enclave, публичный — на сервере). Вход происходит через cryptographic challenge: сервер отправляет случайный challenge, устройство подписывает его приватным ключом, сервер проверяет подпись публичным ключом. Пароль не участвует в протоколе — его нет в принципе.

// TypeScript: registration и authentication с @simplewebauthn/server
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";

const RP_ID = "qdev.run";
const RP_NAME = "QDEVRUN";
const ORIGIN = "https://qdev.run";

// === Registration (создание passkey) ===

async function beginRegistration(userId: string, username: string) {
  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: userId,
    userName: username,
    // Приоритет platform authenticators (Face ID, Touch ID, Windows Hello)
    authenticatorSelection: {
      authenticatorAttachment: "platform",
      userVerification: "required",
      residentKey: "required",  // discoverable credential (passkey)
    },
    attestation: "none",  // для consumer приложений достаточно none
  });

  // Сохраняем challenge в сессии — нужен для верификации
  await saveChallenge(userId, options.challenge);
  return options;
}

async function completeRegistration(userId: string, response: any) {
  const expectedChallenge = await getChallenge(userId);

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    requireUserVerification: true,
  });

  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }

  const { credentialPublicKey, credentialID, counter } =
    verification.registrationInfo;

  // Сохраняем публичный ключ и счётчик (защита от replay attacks)
  await saveCredential(userId, {
    credentialID: Buffer.from(credentialID).toString("base64url"),
    credentialPublicKey: Buffer.from(credentialPublicKey).toString("base64url"),
    counter,
  });
}

// === Authentication (вход по passkey) ===

async function beginAuthentication(userId?: string) {
  // Если userId не указан — discoverable flow (без ввода email)
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: "required",
    allowCredentials: userId
      ? await getCredentialIds(userId)
      : [],  // пустой массив = conditional UI
  });

  await saveChallenge("auth", options.challenge);
  return options;
}

async function completeAuthentication(response: any) {
  const credential = await getCredentialById(response.id);
  if (!credential) throw new Error("Unknown credential");

  const expectedChallenge = await getChallenge("auth");

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    authenticator: {
      credentialPublicKey: Buffer.from(credential.credentialPublicKey, "base64url"),
      credentialID: Buffer.from(credential.credentialID, "base64url"),
      counter: credential.counter,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) throw new Error("Authentication failed");

  // Обновляем счётчик (защита от cloned authenticator)
  await updateCredentialCounter(
    credential.credentialID,
    verification.authenticationInfo.newCounter
  );

  return credential.userId;
}

Browser support (на момент начала 2025): Chrome 108+, Safari 16+, Firefox 122+, Edge 108+. iOS 16+ и Android 9+ поддерживают passkeys через платформенные authenticators. Для старых браузеров и устройств — fallback на пароль или magic link.

UX-нюансы: passkeys синхронизируются через iCloud Keychain (Apple) и Google Password Manager, что решает проблему привязки к одному устройству. Cross-device authentication (QR-code + Bluetooth proximity) позволяет использовать телефон как authenticator для входа на компьютере.

OAuth 2.1: что изменилось

OAuth 2.1 — это не новый протокол, а консолидация лучших практик, накопленных с 2012 года. Ключевые изменения по сравнению с OAuth 2.0:

  • PKCE обязателен для всех клиентов, включая confidential. В 2.0 PKCE требовался только для public clients (SPA, мобильные приложения). Теперь — везде.
  • Implicit flow удалён. Передача access token в URL fragment была векторной для атак через referrer headers и browser history.
  • Password grant удалён. Resource Owner Password Credentials — антипаттерн, который обходил смысл OAuth. Заменяется Device Authorization Grant или Redirect-based flow.
  • Redirect URI должен совпадать точно. Wildcard-матчинг запрещён явно.
  • Refresh token rotation рекомендован для всех типов клиентов.
# Python: PKCE flow (обязателен в OAuth 2.1)
import hashlib
import base64
import secrets


def generate_pkce_pair() -> tuple[str, str]:
    """Генерация code_verifier и code_challenge для PKCE."""
    # code_verifier: 43-128 символов, [A-Z a-z 0-9 - . _ ~]
    code_verifier = secrets.token_urlsafe(64)

    # code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
    digest = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

    return code_verifier, code_challenge


def build_authorization_url(
    client_id: str,
    redirect_uri: str,
    scope: str,
    state: str,
    code_challenge: str,
) -> str:
    params = {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": scope,
        "state": state,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",  # только S256 в OAuth 2.1
    }
    query = "&".join(f"{k}={v}" for k, v in params.items())
    return f"https://auth.example.com/authorize?{query}"


async def exchange_code_for_tokens(
    code: str,
    code_verifier: str,
    client_id: str,
    redirect_uri: str,
) -> dict:
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.post(
            "https://auth.example.com/token",
            data={
                "grant_type": "authorization_code",
                "code": code,
                "client_id": client_id,
                "redirect_uri": redirect_uri,
                "code_verifier": code_verifier,  # сервер проверяет против challenge
            },
        )
        response.raise_for_status()
        return response.json()

MFA: TOTP, push-уведомления, hardware keys

Multi-factor authentication добавляет второй фактор: что-то, что вы знаете (пароль) + что-то, что у вас есть (телефон, ключ). Три основных подхода отличаются по UX и уровню защиты.

TOTP (Time-based One-Time Password) — алгоритм RFC 6238, реализован в Google Authenticator, Authy, 1Password. Генерирует 6-значный код на основе shared secret и текущего времени (окно ±30 секунд).

# Python: TOTP с pyotp
import pyotp
import qrcode
from io import BytesIO
import base64


def generate_totp_secret() -> str:
    return pyotp.random_base32()


def get_totp_provisioning_uri(secret: str, email: str) -> str:
    totp = pyotp.TOTP(secret)
    return totp.provisioning_uri(name=email, issuer_name="QDEVRUN")


def generate_qr_code(uri: str) -> str:
    """Возвращает QR-код как base64 PNG для отображения в браузере."""
    img = qrcode.make(uri)
    buffer = BytesIO()
    img.save(buffer, format="PNG")
    return base64.b64encode(buffer.getvalue()).decode()


def verify_totp(secret: str, code: str) -> bool:
    totp = pyotp.TOTP(secret)
    # valid_window=1 допускает код из предыдущего/следующего 30-секундного окна
    # Защищает от проблем с рассинхронизацией часов
    return totp.verify(code, valid_window=1)


def setup_totp_for_user(user_id: int, email: str, db_session) -> dict:
    secret = generate_totp_secret()
    uri = get_totp_provisioning_uri(secret, email)
    qr = generate_qr_code(uri)

    # Сохраняем secret в БД (НЕ активируем до первой верификации)
    db_session.execute(
        "UPDATE users SET totp_secret_pending = :secret WHERE id = :id",
        {"secret": secret, "id": user_id}
    )

    return {"qr_code": f"data:image/png;base64,{qr}", "secret": secret}


def activate_totp(user_id: int, code: str, db_session) -> bool:
    """Активируем TOTP только после успешной первой верификации."""
    row = db_session.execute(
        "SELECT totp_secret_pending FROM users WHERE id = :id",
        {"id": user_id}
    ).fetchone()

    if not row or not verify_totp(row.totp_secret_pending, code):
        return False

    db_session.execute(
        "UPDATE users SET totp_secret = totp_secret_pending, "
        "totp_secret_pending = NULL, mfa_enabled = true WHERE id = :id",
        {"id": user_id}
    )
    return True

Recovery flows — обязательный элемент, который часто забывают. Пользователь потерял телефон с TOTP — что дальше? Стандартная схема: одноразовые recovery codes (8–10 кодов по 10–16 символов), генерируются при активации MFA, показываются один раз, хранятся в хэшированном виде.

def generate_recovery_codes(count: int = 10) -> list[str]:
    """Генерация одноразовых кодов восстановления."""
    return [secrets.token_hex(8).upper() for _ in range(count)]
    # Результат: ['A3F2B1C4D5E6F7A8', ...] — удобно форматировать как XXXX-XXXX-XXXX


def save_recovery_codes(user_id: int, codes: list[str], db_session) -> None:
    """Сохраняем хэши кодов, не сами коды."""
    import hashlib
    for code in codes:
        code_hash = hashlib.sha256(code.encode()).hexdigest()
        db_session.execute(
            "INSERT INTO recovery_codes (user_id, code_hash, used) "
            "VALUES (:uid, :hash, false)",
            {"uid": user_id, "hash": code_hash}
        )


def use_recovery_code(user_id: int, code: str, db_session) -> bool:
    import hashlib
    code_hash = hashlib.sha256(code.upper().encode()).hexdigest()
    row = db_session.execute(
        "SELECT id FROM recovery_codes WHERE user_id = :uid "
        "AND code_hash = :hash AND used = false",
        {"uid": user_id, "hash": code_hash}
    ).fetchone()

    if not row:
        return False

    db_session.execute(
        "UPDATE recovery_codes SET used = true WHERE id = :id",
        {"id": row.id}
    )
    return True

Hardware keys (FIDO2 Security Keys) — YubiKey, Google Titan Key. Физическое устройство, подтверждение присутствием (нажатие кнопки). Самый высокий уровень защиты: фишинг невозможен (ключ привязан к origin). Используется в корпоративных средах, для privileged access, у разработчиков с доступом к production.

Таблица решений: что выбрать

Нет универсального ответа — выбор зависит от типа продукта, требований к масштабированию и порога сложности имплементации.

Сценарий Рекомендация Причина
Монолит с SSR (server-side rendering) Redis sessions Простота, мгновенный revoke, нет проблем с CORS
SPA + REST API JWT (access 15 мин) + refresh rotation в httpOnly cookie Stateless API инстансы, cookie защищает от XSS
Микросервисы JWT с audience claims, верификация без централизованного сервиса Каждый сервис независим, нет single point of failure
Consumer продукт (B2C) Passkeys + TOTP как fallback Лучший UX, защита от фишинга, нет паролей
B2B / enterprise OAuth 2.1 / SAML для SSO + TOTP или hardware key для MFA Требования compliance, централизованное управление
Privileged access (prod, admin) Hardware FIDO2 key обязателен Максимальная защита, phishing-resistant

Общий принцип: не изобретайте собственный auth, если есть готовое решение подходящего класса. Auth0, Clerk, Keycloak — это годы battle-tested кода. Собственная реализация оправдана только при жёстких требованиях к data residency или нетипичных сценариях.


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

← Предыдущая CI/CD как код... Следующая →Тестирование backend: стратегия, а не 100% coverage