Антон Ларичев

Введение
JWT (JSON Web Token) — это компактный стандарт передачи данных между сторонами в виде подписанного JSON-объекта. В Node.js JWT часто используется для реализации аутентификации в REST API и микросервисах: сервер не хранит сессии, а доверяет токену, проверяя его подпись.
Классическая схема включает два типа токенов: access — короткоживущий, используется для доступа к защищённым ресурсам, и refresh — долгоживущий, нужен для обновления access без повторного ввода пароля. Такой подход снижает риски: если access утечёт, окно атаки ограничено его временем жизни.
В статье разберём генерацию, проверку и ротацию токенов, а также типичные ошибки реализации.
Структура JWT
Токен состоит из трёх частей, разделённых точкой: header.payload.signature. Header описывает алгоритм подписи, payload содержит claims (sub, iat, exp и кастомные поля), signature — криптографическая подпись HMAC или RSA.
Важно понимать: payload не шифруется, а только кодируется в base64url. Не кладите туда пароли, ключи или персональные данные. Подпись лишь гарантирует, что токен не был изменён.
Подготовка проекта
Установим зависимости:
npm install express jsonwebtoken bcrypt cookie-parser
npm install --save-dev @types/jsonwebtoken @types/express typescript
В переменные окружения вынесем секреты:
JWT_ACCESS_SECRET=replace_with_long_random_string
JWT_REFRESH_SECRET=another_long_random_string
ACCESS_TTL=15m
REFRESH_TTL=30d
Генерируйте секреты длиной не менее 32 байт, например через openssl rand -hex 32. Никогда не коммитьте их в репозиторий.
Генерация access и refresh токенов
Создадим сервис для работы с токенами:
import jwt from 'jsonwebtoken';
export interface TokenPayload {
userId: string;
email: string;
}
export class TokenService {
// Создаём пару токенов на основе данных пользователя
generateTokens(payload: TokenPayload) {
const accessToken = jwt.sign(payload, process.env.JWT_ACCESS_SECRET!, {
expiresIn: process.env.ACCESS_TTL,
});
const refreshToken = jwt.sign(payload, process.env.JWT_REFRESH_SECRET!, {
expiresIn: process.env.REFRESH_TTL,
});
return { accessToken, refreshToken };
}
// Проверка access-токена: вернёт payload или null
validateAccess(token: string): TokenPayload | null {
try {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as TokenPayload;
} catch {
return null;
}
}
validateRefresh(token: string): TokenPayload | null {
try {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET!) as TokenPayload;
} catch {
return null;
}
}
}
Разные секреты для access и refresh — обязательное условие. Если использовать один ключ, скомпрометированный refresh даст и доступ к ресурсам, и возможность бесконечно его обновлять.
Эндпоинты логина и обновления
При логине проверяем пароль через bcrypt и выдаём пару токенов. Refresh кладём в httpOnly cookie, access возвращаем в теле ответа:
import { Router } from 'express';
import bcrypt from 'bcrypt';
const router = Router();
const tokens = new TokenService();
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await userRepository.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ message: 'Неверные учётные данные' });
}
const pair = tokens.generateTokens({ userId: user.id, email: user.email });
await refreshRepository.save(user.id, pair.refreshToken);
// httpOnly защищает refresh от чтения через JavaScript
res.cookie('refreshToken', pair.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: pair.accessToken });
});
Отдельный эндпоинт /refresh принимает refresh из cookie, проверяет его наличие в БД (ротация) и выдаёт новую пару:
router.post('/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
const payload = tokens.validateRefresh(token);
const stored = await refreshRepository.find(token);
if (!payload || !stored) {
return res.status(401).json({ message: 'Refresh недействителен' });
}
// Удаляем старый токен, чтобы исключить повторное использование
await refreshRepository.delete(token);
const pair = tokens.generateTokens({ userId: payload.userId, email: payload.email });
await refreshRepository.save(payload.userId, pair.refreshToken);
res.cookie('refreshToken', pair.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' });
res.json({ accessToken: pair.accessToken });
});
Middleware для защищённых маршрутов
export function authGuard(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Нет токена' });
}
const payload = tokens.validateAccess(header.slice(7));
if (!payload) {
return res.status(401).json({ message: 'Токен истёк или повреждён' });
}
req.user = payload;
next();
}
Частые ошибки
- Хранение refresh в localStorage. Любой XSS моментально украдёт токен. Используйте httpOnly cookie с флагами Secure и SameSite.
- Одинаковый секрет для access и refresh. Усложняет ротацию и снижает безопасность.
- Отсутствие ротации refresh. Без удаления старого токена украденный refresh работает бессрочно.
- Чувствительные данные в payload. Помните, что payload читается без ключа.
- Слишком длинный TTL access. Делайте его коротким: 5–15 минут.
- Игнорирование алгоритма
none. Всегда указывайте конкретный алгоритм при verify через параметр algorithms.
Заключение
Схема access + refresh даёт баланс между удобством пользователя и безопасностью. Короткий access ограничивает урон от утечки, refresh с ротацией и httpOnly cookie защищает долгоживущую сессию. Главное — хранить секреты в env, использовать разные ключи для двух токенов и обязательно вести таблицу выданных refresh для отзыва. Такой подход легко масштабируется на микросервисы и подходит для большинства production-задач на Node.js.






Комментарии
0