Коротка відповідь. Access-токен – короткоживучий пропуск, який клієнт додає до кожного запиту; refresh-токен – довгоживучий, зберігається в httpOnly cookie і використовується лише для отримання нового access-токена. Два токени потрібні, щоб поєднати дві протилежні вимоги: перевіряти запити швидко (без походу в базу) і мати змогу відкликати доступ. Нижче – повний потік, робочий код і помилки, на яких сипляться кандидати.
Навіщо взагалі два токени
Уяви, що є лише один довгоживучий токен. Тоді або він самодостатній (JWT) – і його неможливо відкликати до закінчення строку дії, або сервер перевіряє його в базі на кожен запит – і ти втрачаєш головну перевагу токенів: stateless-перевірку без додаткового I/O.
Розділення обов'язків знімає конфлікт:
- Access-токен живе хвилини (типово 5–15). Його крадіжка неприємна, але вікно атаки коротке. Сервер перевіряє лише підпис – швидко і без бази.
- Refresh-токен живе дні або тижні, але використовується рідко – лише на ендпоінті оновлення. Саме там можна дозволити собі перевірку в базі, ротацію і відкликання.
Повний потік крок за кроком
- Логін. Клієнт надсилає credentials. Сервер перевіряє їх, генерує пару токенів. Access повертається в тілі відповіді, refresh – у
Set-Cookieз прапорцямиhttpOnly,Secure,SameSite. - Звичайний запит. Клієнт тримає access у пам'яті процесу (змінна, стан застосунку) і додає заголовок
Authorization: Bearer <token>. Сервер перевіряє підпис і строк дії – без бази. - Access прострочився. Сервер відповідає 401. Клієнт викликає
POST /auth/refresh; браузер сам додає refresh-cookie. - Оновлення. Сервер знаходить refresh-токен у базі, перевіряє, що він не відкликаний, видає нову пару і інвалідовує старий refresh (ротація). Клієнт повторює початковий запит.
- Логаут. Сервер видаляє refresh-токен із бази й чистить cookie. Access доживає свої хвилини і стає марним.
Код: видача пари токенів
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
const ACCESS_TTL = '10m';
const REFRESH_TTL_DAYS = 14;
type AccessPayload = { userId: string };
function issueAccessToken(userId: string): string {
const payload: AccessPayload = { userId };
return jwt.sign(payload, process.env.ACCESS_SECRET!, {
expiresIn: ACCESS_TTL,
});
}
async function issueRefreshToken(userId: string): Promise<string> {
const token = randomUUID();
const expiresAt = new Date(
Date.now() + REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000
);
// Зберігаємо ХЕШ, а не сам токен: витік бази не дає готових refresh-токенів
await db.refreshToken.create({
data: { tokenHash: sha256(token), userId, expiresAt },
});
return token;
}Зверни увагу: refresh тут – не JWT, а непрозорий (opaque) ідентифікатор. Йому не потрібен підпис: сервер і так звіряє його з базою.
Код: middleware перевірки access
import type { Request, Response, NextFunction } from 'express';
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'no token' });
}
try {
const payload = jwt.verify(
header.slice('Bearer '.length),
process.env.ACCESS_SECRET!
) as AccessPayload;
req.userId = payload.userId;
next();
} catch {
// І прострочений, і підроблений токен – однакова відповідь
return res.status(401).json({ error: 'invalid token' });
}
}Жодного запиту в базу – в цьому й сенс короткоживучого access-токена.
Код: ендпоінт оновлення з ротацією
app.post('/auth/refresh', async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: 'no refresh token' });
const stored = await db.refreshToken.findUnique({
where: { tokenHash: sha256(token) },
});
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ error: 'refresh expired' });
}
// Ротація: старий токен більше не дійсний
await db.refreshToken.delete({ where: { id: stored.id } });
const access = issueAccessToken(stored.userId);
const refresh = await issueRefreshToken(stored.userId);
res
.cookie('refreshToken', refresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth',
maxAge: REFRESH_TTL_DAYS * 24 * 60 * 60 * 1000,
})
.json({ accessToken: access });
});Ротація дає бонус для безпеки: якщо вкрадений refresh-токен уже використано, легітимний користувач при наступному оновленні отримає 401 – і це сигнал інвалідувати всі сесії акаунта.
Типові помилки
- Access-токен у localStorage. Будь-який XSS читає localStorage повністю. Тримай access у пам'яті, refresh – у httpOnly cookie, яку скрипт прочитати не може. Про XSS докладно пояснює MDN.
- Refresh без ротації. Один викрадений токен = тижні доступу. Ротація звужує вікно до одного використання.
- Довгий TTL access-токена. Година чи доба «щоб рідше рефрешити» знецінює всю схему: відкликати такий токен неможливо.
- Зберігання refresh-токенів у чистому вигляді. База витекла – всі сесії скомпрометовані. Зберігай хеш.
- Спільний
pathдля refresh-cookie. Cookie зpath: '/'їде з кожним запитом. Обмеж її/auth– менше поверхні для CSRF.
JWT чи opaque – коротко
Для access зазвичай JWT: перевірка без бази – головна вигода. Для refresh краще opaque-рядок: він і так перевіряється в базі, а JWT-формат лише додає спокусу «перевіряти без бази» там, де це небезпечно. Якщо на співбесіді питають «а можна все зробити сесіями?» – чесна відповідь: так, для одного класичного вебзастосунку серверні сесії простіші; токени виграють, коли клієнтів кілька (SPA, мобільний) або сервісів більше одного.
Питання, які реально ставлять на співбесіді
- Чому не можна просто видати один довгий JWT? – конфлікт «швидка перевірка vs відкликання» (див. початок статті).
- Що станеться, якщо вкрадуть access-токен? – атакуючий має доступ до закінчення TTL; тому TTL короткий, а критичні операції вимагають повторної автентифікації.
- Де зберігати токени в браузері й чому? – access у пам'яті, refresh у httpOnly cookie; localStorage – ні (XSS).
- Як розлогінити користувача з усіх пристроїв? – видалити всі його refresh-токени; access-токени доживуть лічені хвилини.
- Навіщо ротація refresh-токенів? – одноразовість + детекція крадіжки при повторному використанні.
Auth – одна з найчастіших тем на backend-співбесідах із Node.js, і відповідь «ну, JWT, він безпечний» одразу видає завчену поверхневість. Якщо готуєшся системно – подивись план підготовки за 30 днів; а про те, де в цій схемі місце кешу сесійних даних, є окремий розбір Redis.
Практичне завдання
Реалізуй мінімальний auth-модуль на Express або Fastify: /auth/login, /auth/refresh, /auth/logout, один захищений /me. Потім проведи власний «пентест»: спробуй скористатися простроченим access, повторно використаним refresh, cookie без Secure. Кожен сценарій, який несподівано спрацював, – твоя наступна тема для розбору.