Аутентификация в 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 компрометирует всё остальное. Инвестируйте время в правильный выбор архитектуры сейчас, а не в дорогостоящую миграцию под давлением инцидента.