Access token и refresh token: паттерн реализации

В системе авторизации на основе JWT разделение на access token и refresh token решает сразу несколько проблем: ограничение времени жизни сессии, снижение ущерба при компрометации токена и возможность безопасного обновления доступа без повторного логина пользователя.

Access token используется для доступа к защищённым ресурсам API. Его основная характеристика — короткий срок жизни (обычно от 5 до 30 минут). Он передаётся в каждом запросе, чаще всего в заголовке Authorization: Bearer.

Refresh token применяется исключительно для получения нового access token. Он живёт значительно дольше (дни или недели) и не используется для доступа к API напрямую.

Ключевая идея: даже если access token будет украден, его короткое время жизни ограничивает ущерб. Refresh token хранится более строго и используется реже.

Роль библиотеки Jose в реализации JWT

Библиотека jose предоставляет инструменты для работы с JOSE-стандартами: JWS (подпись), JWE (шифрование), JWT (токены).

Основные операции:

  • создание токенов (SignJWT)
  • проверка токенов (jwtVerify)
  • работа с ключами (HMAC, RSA, ECDSA)
  • поддержка JWK (JSON Web Key)

В контексте access/refresh схемы jose используется для:

  • генерации подписанных JWT
  • проверки валидности токена
  • контроля срока жизни и claims

Генерация access token

Access token обычно содержит минимальный набор данных: userId, role, возможно sessionId.

Пример создания:

import { SignJWT } from 'jose';

const secret = new TextEncoder().encode(process.env.ACCESS_SECRET);

export async function createAccessToken(user) {
  return await new SignJWT({
    sub: user.id,
    role: user.role
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(secret);
}

Ключевые моменты:

  • sub — идентификатор пользователя
  • короткий exp (15 минут)
  • симметричный или асимметричный алгоритм

Проверка access token

Каждый запрос к защищённому ресурсу требует валидации токена:

import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.ACCESS_SECRET);

export async function verifyAccessToken(token) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch (e) {
    return null;
  }
}

При ошибке проверки токен считается недействительным: истёк, изменён или подписан другим ключом.

Генерация refresh token

Refresh token обычно содержит только идентификатор сессии или пользователя. Его задача — не передавать лишние данные.

export async function createRefreshToken(user) {
  return await new SignJWT({
    sub: user.id,
    type: 'refresh'
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('30d')
    .sign(new TextEncoder().encode(process.env.REFRESH_SECRET));
}

Важно разделять секреты:

  • ACCESS_SECRET
  • REFRESH_SECRET

Это снижает риск полной компрометации системы.

Обновление access token через refresh token

Основной сценарий: access token истёк, но пользователь не должен заново логиниться.

Процесс:

  1. Клиент отправляет refresh token
  2. Сервер проверяет его валидность
  3. Генерируется новый access token
  4. (опционально) выдается новый refresh token
import { jwtVerify } from 'jose';
import { SignJWT } from 'jose';

const refreshSecret = new TextEncoder().encode(process.env.REFRESH_SECRET);
const accessSecret = new TextEncoder().encode(process.env.ACCESS_SECRET);

export async function refreshAccessToken(refreshToken) {
  const { payload } = await jwtVerify(refreshToken, refreshSecret);

  if (payload.type !== 'refresh') {
    throw new Error('Invalid token type');
  }

  const newAccessToken = await new SignJWT({
    sub: payload.sub
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')
    .sign(accessSecret);

  return newAccessToken;
}

Ротация refresh token

Более безопасная модель — rotation refresh token. Каждый раз при обновлении access token выдается новый refresh token, старый инвалидируется.

Проблема JWT: он сам по себе неотзываемый. Поэтому используется дополнительное хранилище:

  • Redis
  • база данных
  • whitelist активных refresh token

Пример логики:

const store = new Map();

export function saveRefreshToken(userId, token) {
  store.set(userId, token);
}

export function isValidRefreshToken(userId, token) {
  return store.get(userId) === token;
}

При обновлении:

  • старый refresh token удаляется
  • записывается новый

Хранение токенов на клиенте

Access token:

  • memory (наиболее безопасно)
  • или sessionStorage (хуже)

Refresh token:

  • HttpOnly cookie
  • Secure flag
  • SameSite=strict/lax

Пример установки cookie:

res.cookie('refreshToken', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  path: '/auth/refresh',
  maxAge: 30 * 24 * 60 * 60 * 1000
});

Это защищает refresh token от XSS.

Защита от типичных атак

XSS

Access token не должен храниться в localStorage. Лучше держать в памяти приложения.

CSRF

Если refresh token в cookie:

  • использовать SameSite
  • проверку CSRF токена
  • ограничить путь cookie /auth/refresh

Replay attack

Решается:

  • ротацией refresh token
  • хранением последнего валидного токена

Использование асимметричных ключей в Jose

В продакшене часто используют RSA или ECDSA.

Генерация:

import { generateKeyPair } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('RS256');

Подпись:

await new SignJWT({ sub: user.id })
  .setProtectedHeader({ alg: 'RS256' })
  .setExpirationTime('15m')
  .sign(privateKey);

Проверка:

await jwtVerify(token, publicKey);

Преимущество:

  • приватный ключ только на auth-сервере
  • валидация возможна на других сервисах

Типовая архитектура системы

  • Auth service:

    • выдача access/refresh
    • проверка refresh
  • API service:

    • проверка access token
  • Redis/DB:

    • хранение refresh токенов
    • blacklist при logout

Logout и инвалидирование

JWT нельзя удалить, поэтому используется стратегия:

  • добавление refresh token в blacklist
  • удаление из whitelist
  • короткий TTL access token
export function logout(userId) {
  store.delete(userId);
}

Ошибки проектирования, которые приводят к уязвимостям

  • хранение refresh token в localStorage
  • отсутствие rotation
  • слишком длинный lifetime access token
  • использование одного секретного ключа для всех типов токенов
  • отсутствие server-side проверки refresh token

Практический поток авторизации

  1. Login:

    • создаются access + refresh
    • refresh сохраняется в cookie/DB
  2. Request API:

    • проверяется access token
  3. Expired access:

    • вызывается refresh endpoint
  4. Refresh:

    • проверка refresh token
    • выдача нового access
    • обновление refresh
  5. Logout:

    • удаление refresh из хранилища