Реализация токенов доступа и обновления вместе в Node.js
Краткосрочные токены доступа в сочетании с ротируемыми токенами обновления в Node.js: процесс работы JWT, где хранить каждый токен, и как ротация предотвращает использование украденных токенов обновления.
Источники: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both
При создании первого процесса аутентификации в Node.js типичным подходом является использование единственного JSON Web Token (JWT). Пользователь вводит адрес электронной почты и пароль, сервер проверяет их, подписывает токен и возвращает его. С этого момента фронтэнд включает этот токен в заголовок Authorization при каждом запросе.
Такая конфигурация кажется нормальной во время тестирования на localhost. Но как только реальные пользователи начинают пользоваться приложением, возникает структурное противоречие.
Установите срок действия токена на короткий период, например 15 минут, и пользователи будут выходить из системы посреди выполнения задач — возможно, во время ввода комментария или на полпути заполнения формы. Если установить срок действия на длительный период, например 30 дней, люди останутся вошедшими, но это создаст серьезную угрозу безопасности: если токен будет украден, его невозможно аннулировать без отзыва действия всех токенов у всех пользователей во всей системе.
Именно из-за этого противоречия между удобством для пользователей и безопасностью профессиональные системы не полагаются на один токен — они используют два: токен доступа и токен обновления.
Давайте рассмотрим, почему существует модель с двумя токенами, какую роль играет каждый из них и как правильно реализовать её в бэкенде на Node.js.
Прежде чем объяснить, почему необходимы два токена, стоит вспомнить, что делает JWT уникальными: они являются безсостоянийными.
Когда сервер Node.js получает JWT, ему не нужно обращаться к базе данных, чтобы проверить наличие сессии. Он просто сравнивает криптографическую подпись токена с секретным ключом:
Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No -> 401 Unauthorized
Безсостоянийность делает API, основанные на JWT, быстрыми и легкими в масштабировании, поскольку база данных не запрашивается при каждом входящем запросе. Но у этого свойства есть и недостаток: как только токен выдан, у сервера нет простого способа досрочно аннулировать его.
Рассмотрим следующую последовательность событий:
- Ваш единственный JWT настроен на действие в течение 30 дней.
- Злоумышленник получает к нему доступ — возможно, через атаку типа XSS или путем перехвата трафика в незащищенной сети.
Одним из решений является ведение чёрного списка аннулированных токенов в такой системе, как Redis, но проверка этого списка при каждом вызове API фактически превращает вашу «безсостоянийную» систему токенов обратно в состоянийную.
Именно эту проблему предназначен решить подход с двумя токенами.
Что такое токены доступа и токены обновления?
Вместо того чтобы делегировать один токен ответственность как за обычный доступ к API, так и за долгосрочное сохранение авторизации, нагрузка распределяется между двумя токенами:
+------------------+-----------------------------+-----------------------------+
| Feature | Access Token | Refresh Token |
+------------------+-----------------------------+-----------------------------+
| Purpose | Authenticate API requests | Obtain new access tokens |
| Lifetime | Short (5 to 15 minutes) | Long (7 to 30 days) |
| Storage | Memory or short-lived state | HttpOnly, Secure Cookie |
| Database lookup | No (purely stateless) | Yes (checked on refresh) |
| Exposure risk | High (sent on every request)| Low (sent only to /refresh) |
+------------------+-----------------------------+-----------------------------+
1. Токен доступа
Считайте токен доступа временным пропуском. Клиент прикрепляет его к заголовкам обычных запросов API — таких как GET /api/orders или POST /api/profile.
Поскольку он действителен лишь в течение короткого времени (обычно от 10 до 15 минут), возможный ущерб от утечки ограничен. Сервер может проверить его исключительно с помощью проверок подписи, основанных на процессоре, не обращаясь к базе данных, что сохраняет отзывчивость API.
2. Токен обновления
Токен обновления функционирует как ваучер для долгосрочного обновления. Его никогда не отправляют вместе с обычными запросами API — его единственная задача — взаимодействовать с специальным конечным пунктом, например POST /api/auth/refresh.
Как только краткосрочный токен доступа истекает, клиент тихо вызывает этот маршрут обновления. Затем сервер запрашивает базу данных, чтобы убедиться, что учетная запись пользователя по-прежнему активна и имеет разрешения, после чего выдает новый токен доступа.
Поскольку такой вызов обновления происходит примерно раз в 15 минут, а не при каждом взаимодействии пользователя, добавление запроса к базе данных практически не влияет на производительность.
Жизненный цикл запроса
Вот как клиент, сервер и база данных взаимодействуют со временем:
Client Server Database
│ │ │
├─────── 1. Login (credentials) ──>│ │
│ ├─── Verify credentials ─────────>│
│ │<── User record valid ───────────┤
│ ├─── Store Refresh Token Hash ───>│
│<────── 2. Return Tokens ─────────┤ │
│ (Access: 15m, Refresh: 7d) │ │
│ │ │
│─────── 3. GET /api/dashboard ───>│ │
│ [Bearer Access Token] ├─── Verify signature (Stateless) │
│<────── 4. 200 OK + Data ─────────┤ │
│ │ │
│ ... 15 minutes pass ... │ │
│ │ │
│─────── 5. GET /api/dashboard ───>│ │
│ [Expired Access Token] ├─── Token expired! │
│<────── 6. 401 Unauthorized ──────┤ │
│ │ │
│─────── 7. POST /api/auth/refresh>│ │
│ [Cookie: Refresh Token] ├─── Check if token in DB ───────>│
│ │<── Token valid & not revoked ───┤
│ ├─── Generate new Access Token │
│<────── 8. Return new Access Token┤ │
│ │ │
│─────── 9. Retry GET /dashboard ─>│ │
│ [New Access Token] ├─── Verify signature │
│<────── 10. 200 OK + Data ────────┤ │
С точки зрения пользователя весь процесс обновления остается незаметным. Их сессия продолжает работать в фоновом режиме, без раздражающих уведомлений «Сессия истекла», прерывающих активное использование.
Практическое применение с Node.js
Теперь давайте создадим рабочую конфигурацию Express, которая покажет, как выдавать, проверять и обновлять токены.
Начните с установки необходимых пакетов:
npm install express jsonwebtoken cookie-parser dotenv
1. Создание токенов
Создайте вспомогательный модуль, который генерирует оба токена, каждый из которых подписан собственным секретом:
// tokenService.js
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
function generateAccessToken(userId) {
// Short lifespan: 15 minutes
return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
}
function generateRefreshToken(userId) {
// Long lifespan: 7 days
return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}
module.exports = {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
};
Важно: всегда предоставляйте токену доступа и токену обновления отдельные секретные ключи. Если они будут использовать один и тот же секрет, кто-то сможет воспользоваться истекшим токеном обновления и предоставить его вместо токена доступа, тем самым обойдя установленный срок действия.
2. Обработка входа и сохранение токена обновления
Маршрут входа должен хранить токен обновления — или, что лучше, его хеш SHA-256 — в вашей базе данных, связанный с конкретным пользователем, чтобы позже можно было его найти:
// server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
} = require('./tokenService');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Simulated database table for refresh tokens
const refreshTokenDatabase = new Set();
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// In a real app: verify email and hashed password from DB
const mockUserId = 'usr_987654';
const accessToken = generateAccessToken(mockUserId);
const refreshToken = generateRefreshToken(mockUserId);
// Save the refresh token to our database so we can revoke it later
refreshTokenDatabase.add(refreshToken);
// Send refresh token inside an HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Prevents JavaScript from reading the cookie (protects from XSS)
secure: process.env.NODE_ENV === 'production', // Requires HTTPS
sameSite: 'strict', // Protects against CSRF attacks
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Send access token in JSON body for frontend memory storage
res.json({ accessToken });
});
3. Защита бизнес-эндпоинтов
Функция мидлвэра проверяет токен доступа, полученный из заголовка Authorization, прежде чем разрешить прохождение запроса:
function authenticateAccessToken(req, res, next) {
const authHeader = req.headers['authorization'];
// The header format is "Bearer <token>"
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token missing' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) {
// 403 signals to the client that the token is invalid or expired
return res.status(403).json({ error: 'Invalid or expired access token' });
}
req.userId = decoded.sub;
next();
});
}
// Protected route
app.get('/api/orders', authenticateAccessToken, (req, res) => {
res.json({
message: `Orders retrieved for user ${req.userId}`,
data: [{ id: 1, item: 'Mechanical Keyboard' }]
});
});
4. Создание маршрута обновления
Как только клиент получает ответ 403 от запроса вроде /api/orders, он обращается к этому эндпоинту для получения нового токена доступа:
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
// Check if token exists in the database
if (!refreshTokenDatabase.has(refreshToken)) {
return res.status(403).json({ error: 'Refresh token revoked or unknown' });
}
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
// Remove invalid token from database
refreshTokenDatabase.delete(refreshToken);
return res.status(403).json({ error: 'Refresh token expired' });
}
// Token is valid! Issue a new access token
const newAccessToken = generateAccessToken(decoded.sub);
res.json({ accessToken: newAccessToken });
});
});
5. Выход из системы и аннулирование доступа
Выход пользователя из системы — процесс простой: достаточно удалить токен обновления из вашей базы данных и очистить куки, в которых он хранится. С токеном доступа не требуется никаких особых действий — он просто истекает сам по себе через несколько минут.
app.post('/api/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
refreshTokenDatabase.delete(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
Где должны храниться токены на клиенте?
Часто возникает вопрос о том, где фронтенду следует хранить эти токены.
Частой ошибкой является хранение обоих токенов в localStorage.
Проблема заключается в том, что любой скрипт, выполняющийся на странице — будь то скрипт из компрометированной зависимости npm или вставленный встроенный тег — может прочитать содержимое localStorage с помощью простой команды localStorage.getItem('token').
Более безопасный подход выглядит следующим образом:
- Храните токен доступа исключительно в памяти. Храните его в состоянии выполнения вашего приложения — в React Context, хранилище Pinia или даже в обычной переменной. Поскольку он быстро истекает и исчезает сразу после закрытия вкладки, даже успешная инъекция скрипта создает лишь ограниченный период риска.
- Храните токен обновления в куки с атрибутом
HttpOnly. JavaScript вообще не имеет доступа к куки с атрибутомHttpOnly. Браузер автоматически добавляет их каждый раз, когда клиент запрашивает адрес/api/auth/refresh, что означает, что ваши долговременные учетные данные никогда не попадают в код, который может контролироваться злоумышленником.
Усиление безопасности с помощью ротации токенов обновления
Даже с использованием описанной выше конфигурации у простой схемы токенов обновления сохраняется серьезная слабость: если злоумышленник сможет заполучить токен обновления — будь то из хранилища куки или путем перехвата трафика — он сможет генерировать новые токены доступа столько, сколько действителен этот токен обновления, что может составить целых 7 дней.
Промышленные системы устраняют этот недостаток с помощью ротации токенов обновления.
При ротации:
- Каждый раз, когда токен обновления используется для получения нового токена доступа, сервер уничтожает старый токен обновления и выдает совершенно новый.
- Токены доступа и токены обновления заменяются вместе, по одному за другим, при каждом запросе.
Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
Автоматическое выявление нарушений
Ротация токенов обновления позволяет использовать мощный механизм защиты под названием обнаружение повторного использования.
Представьте сценарий, когда злоумышленнику удается скопировать Refresh Token A.
- Настоящий пользователь сначала обменивает
Refresh Token A, и сервер отвечаетRefresh Token B. - Через некоторое время злоумышленник пытается обменять уже устаревший
Refresh Token A. - Сервер проверяет свои записи, видит, что
Refresh Token Aуже был использован, и считает это признаком компрометации. - В ответ он аннулирует всю цепочку токенов, включая
Refresh Token B, что заставляет как злоумышленника, так и законного пользователя вернуться на экран входа.
Вместо того чтобы злоумышленник тихо действовал в фоне, система немедленно реагирует и блокирует доступ для всех до повторной аутентификации.
Ошибки, которых стоит избегать
Обратите внимание на эти четыре распространенные ошибки:
- Включение конфиденциальной информации в токен доступа. Полезная нагрузка JWT кодируется только в формате Base64, а не шифруется, поэтому любой, кто её перехватит, может прочитать содержимое напрямую. Избегайте включения паролей, номеров карт и других личных данных в полезную нагрузку.
- Позволение записям токенов обновления накапливаться бесконечно. Без поля срока действия и процедуры очистки таблица токенов обновления будет расти без ограничений. Добавьте поле
expires_atи запланируйте задачу на периодическое удаление просроченных записей.
403 одновременно. Если ваш фронтенд в ответ на это отправит несколько параллельных запросов /refresh, логика обновления токенов может воспринять это как повторное использование существующего токена и прервать сессию. Чтобы избежать этого, необходимо выстраивать очередь на выходящие запросы после одного актуального запроса на обновление токена.Ситуации, когда один токен может быть достаточен
Такой подход с двумя токенами является стандартным решением для современных веб- и мобильных клиентов, но он не обязателен во всех случаях:
- Внутренняя коммуникация между сервисами: устройства, взаимодействующие друг с другом, обычно используют механизмы аутентификации клиента, краткосрочные токены или взаимный TLS; ни один из этих методов не требует интерактивного цикла обновления токенов.
- Простые приложения, генерируемые на сервере: для традиционного приложения, генерируемого на стороне сервера — например, созданного с использованием классических шаблонов Express — простой куки сессии, хранящийся во внутренней памяти, такой как Redis, обычно проще в управлении и обеспечивает сопоставимую безопасность.
Используйте подход с токенами доступа и обновления именно тогда, когда вы разрабатываете одностраничные приложения (React, Vue или мобильные клиенты), которые взаимодействуют с безсостоянийными бэкендами на основе REST или GraphQL.
Заключение
Использование одного JWT для всего принуждает к компромиссу, который не следует заключать: либо делать сессии короткими и раздражать пользователей частыми входами, либо делать их длинными и подвергать себя серьезной угрозе безопасности.
Разделение процесса аутентификации на два токена решает эту проблему:
- Токены доступа обеспечивают быструю и безсостоянийную обработку каждого запроса, избавляя базу данных от постоянных поисков.
- Токены обновления безопасно обеспечивают долгосрочное сохранение сессий, позволяя мгновенно аннулировать скомпрометированные сессии.
Инвестиции усилий в правильную настройку обоих типов токенов принесут вам плавную работу системы для пользователей и защиту от здесь описанных проблем безопасности.