Як реалізувати автентифікацію JWT в Express.js?
JWT автентифікація в Express.js використовує підписані JSON-токени для перевірки особи користувача без збереження стану сесії на сервері.
Теорія
TL;DR
- JWT схожий на тамперпрофільний штамп: клієнт носить його з собою, сервер перевіряє підпис і дату закінчення без жодного пошуку в базі
- Основний потік: POST /login з обліковими даними, отримуєш підписаний токен, надсилаєш у
Authorization: Bearer <token>в кожному запиті - JWT для stateless API та горизонтального масштабування. Сесії для серверних застосунків з чутливим станом
- Завжди встановлюй
expiresIn. Завжди передавай{ algorithms: ['HS256'] }уjwt.verify. Секрет тримай у змінних середовища
Швидкий приклад
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
// Видати токен при вході
app.post('/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
const valid = await bcrypt.compare(req.body.password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, email: user.email }, SECRET, { expiresIn: '15m' });
res.json({ token });
});
// Перевірка в middleware
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}jwt.sign кодує payload, підписує секретом і вбудовує термін дії. jwt.verify перераховує підпис і кидає помилку, якщо щось не збігається. Жодних запитів до бази на жодному кроці.
Як JWT працює зсередини
Токен складається з трьох частин, розділених крапками: заголовок (алгоритм), payload (твої дані: id користувача, роль, мітки часу) і підпис. Бібліотека jsonwebtoken використовує crypto.createHmac з Node для створення підпису HS256. Верифікація перераховує підпис і порівнює. Якщо payload змінили після підписання, підписи не збіжаться і verify кине помилку.
Повний цикл запиту:
- Клієнт надсилає POST /auth/login з email і паролем
- Сервер знаходить користувача в базі,
bcrypt.compareперевіряє хеш пароля - Сервер викликає
jwt.sign({id, email, role}, SECRET, {expiresIn: '15m'})і повертає токен - Клієнт зберігає токен (httpOnly cookie безпечніша за localStorage)
- Кожен наступний запит містить
Authorization: Bearer <token> - Middleware
authenticateвикликаєjwt.verify, записує результат уreq.user, викликаєnext()
Сервер ніколи не зберігає токен. Це і є вся ідея.
JWT проти сесій
Сесії зберігають стан користувача в памʼяті або Redis, тому при кожному запиті потрібен пошук. JWT кодує стан всередині самого токена і підписує його. Серверу потрібен тільки секрет для верифікації. Немає спільного сховища, немає проблем зі sticky sessions при масштабуванні.
Але токени не можна відкликати до закінчення їхнього терміну дії. Якщо користувач виходить з системи, токен технічно залишається дійсним до expiry. Сесії вирішують це миттєво. Тому: JWT для stateless API, мобільних бекендів, мікросервісів. Сесії для серверних застосунків з чутливим, змінним станом.
Коли використовувати
- REST або GraphQL API для мобільних застосунків або SPA
- Горизонтально масштабовані сервіси, де спільне сховище сесій додає складності
- Мікросервіси, які передають перевірені дані про користувача між сервісами (тут краще підходить RS256)
- Короткочасні access-токени (15 хв) у парі з refresh-токенами для довших сесій
Не варто використовувати JWT, якщо потрібно миттєво відкликати токени без Redis-чорного списку, або якщо твій застосунок серверний з важкою логікою сесій.
Типові помилки
Відсутність expiration. jwt.sign(payload, secret) без expiresIn створює токен, який ніколи не закінчується. Завжди додавай { expiresIn: '15m' } для access-токенів.
Пропуск algorithms у verify. Поведінка за замовчуванням надто дозволяюча і відкриває атаку alg: none, де зловмисник видаляє підпис і встановлює алгоритм в none. Погано налаштований сервер приймає такий токен як дійсний. Виправлення:
// Неправильно: вразливість алгоритмічної плутанини
jwt.verify(token, secret);
// Правильно
jwt.verify(token, secret, { algorithms: ['HS256'] });Зберігання токена в localStorage. XSS може його вкрасти. Використовуй httpOnly cookie:
res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'strict' });Слабкий секрет. 'shh' або 'secret' можна підібрати офлайн, якщо токен витік. Генеруй нормальний секрет:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Відсутність допуску на зсув годинника в розподілених системах. Розбіжність серверних годинників відхиляє дійсні токени. Додавай { clockTolerance: 30 } до параметрів verify.
Де використовується на практиці
- MERN стеки: стратегія
passport-jwtобгортає крок верифікації в конвенцію Passport - Next.js:
next-authкерує JWT-сесіями через OAuth-провайдери - NestJS: модуль
@nestjs/jwtз декораторамиAuthGuard - Supabase і Auth0: обидва видають JWT, які верифікуються в Express за допомогою їхніх публічних ключів (RS256)
- Strapi: вбудований JWT-плагін для headless CMS API
На практиці я бачив, як команди роками пропускають опцію algorithms, поки це не виявляє перевірка безпеки. Це один рядок, і він важливий.
Питання на співбесіді
Q: Поясни повний цикл від входу до захищеного запиту.
A: Клієнт POST /login з обліковими даними. Сервер bcrypt.compare хеш пароля. При успіху jwt.sign({id: user.id, email}) повертає Bearer-токен. Клієнт додає його до кожного заголовку Authorization. Middleware jwt.verify декодує, записує в req.user, викликає next().
Q: Як відкликати JWT-токен у stateless системі?
A: Напряму не можна. Короткий expiry (15 хв) обмежує вікно ризику. Для жорсткого відкликання: зберігай jti UUID в кожному токені і перевіряй Redis-чорний список при кожній верифікації. При logout записуй jti в Redis з TTL рівним часу, що залишився до expiry.
Q: Різниця між HS256 і RS256?
A: HS256 симетричний: один секрет для підпису і верифікації. RS256 асиметричний: приватний ключ підписує, публічний ключ верифікує. RS256 краще для мікросервісів, бо інші сервіси перевіряють токени без знання приватного ключа.
Q: Що таке атака alg: none?
A: Зловмисник видаляє підпис і встановлює алгоритм токена в none. Погано налаштований сервер приймає токен без перевірки підпису. Виправлення: завжди передавай { algorithms: ['HS256'] } у jwt.verify.
Q: Як реалізувати ротацію (rotation) refresh-токенів безпечно?
A: Видавай новий refresh-токен при кожному використанні і додавай старий у чорний список Redis за його jti UUID. Якщо заблокований токен зʼявляється знову, це ознака replay-атаки: негайно інвалідуй всі сесії для цього користувача.
Приклади
Базовий вхід і захищений маршрут
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const app = express();
app.use(express.json());
const SECRET = process.env.JWT_SECRET;
const users = [{ id: 1, email: 'alice@example.com', passwordHash: '$2a$10$...' }];
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Токен живе 15 хвилин
const token = jwt.sign({ id: user.id, email }, SECRET, { expiresIn: '15m' });
res.json({ token });
});
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token' });
try {
// Явно вказуємо алгоритм, щоб закрити вразливість alg:none
req.user = jwt.verify(header.split(' ')[1], SECRET, { algorithms: ['HS256'] });
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
app.get('/profile', authenticate, (req, res) => {
// req.user = { id: 1, email: 'alice@example.com', iat: 1700000000, exp: 1700000900 }
res.json({ user: req.user });
});Пароль перевіряється bcrypt, токен підписується з expiry 15 хвилин, authenticate використовує опцію algorithms. Будь-який захищений маршрут просто додає authenticate як middleware.
Авторизація на основі ролей
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Роль додається в payload токена при вході
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role }, // 'admin' | 'user'
SECRET,
{ expiresIn: '15m' }
);
// Тільки адміни досягають цього обробника
app.delete('/users/:id', authenticate, authorize('admin'), async (req, res) => {
await User.deleteById(req.params.id);
res.status(204).send();
});authorize запускається після authenticate, тому req.user вже заповнений. Роль береться з payload токена без додаткового запиту до бази. Це швидко. Але якщо роль користувача змінилась у базі, старий токен ще деякий час нестиме стару роль. Короткий expiry важливий і тут.
Refresh-токени з httpOnly cookie
const REFRESH_SECRET = process.env.REFRESH_SECRET;
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: ['HS256'] });
// Новий короткочасний access-токен
const accessToken = jwt.sign(
{ id: decoded.id, email: decoded.email },
SECRET,
{ expiresIn: '15m' }
);
// Ротація: новий refresh-токен, старий стає недійсним
const newRefresh = jwt.sign(
{ id: decoded.id, email: decoded.email },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refreshToken', newRefresh, {
httpOnly: true, // JavaScript не може його прочитати
secure: true, // тільки HTTPS
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 днів у мілісекундах
});
res.json({ accessToken });
} catch {
res.status(403).json({ error: 'Invalid refresh token' });
}
});Access-токен живе 15 хвилин і повертається в тілі відповіді. Refresh-токен живе 7 днів у httpOnly cookie. XSS не може прочитати cookie. CSRF не може використати access-токен, бо він не в cookie. Ротація при кожному оновленні обмежує ризик, якщо refresh-токен витік.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.