Пошук уроків, статей та іншого контенту
Побудуємо схему з короткоживучим access token і refresh token для безпечного оновлення доступу.
Після успішного входу користувача серверу потрібно надати клієнту спосіб підтверджувати автентифікацію під час наступних запитів.
Для цього часто використовують пару токенів:
access token — короткоживучий токен для доступу до API;
refresh token — довгоживучий токен для отримання нового access token.
Такий поділ зменшує наслідки викрадення access token. Якщо access token має термін дії 15 хвилин, він автоматично стане недійсним навіть без додаткової дії користувача.
Refresh token живе довше, тому його потрібно зберігати обережніше та мати можливість відкликати.
Типовий сценарій має такий вигляд:
Користувач надсилає логін і пароль.
Сервер перевіряє облікові дані.
Сервер створює:
access token із коротким терміном дії;
refresh token із довшим терміном дії.
Access token повертається клієнту у відповіді.
Refresh token передається в HttpOnly cookie.
Клієнт додає access token до запитів API.
Якщо access token завершився, клієнт надсилає refresh token на спеціальний endpoint.
Для refresh token важливо мати серверний стан. Сервер повинен розуміти:
кому належить токен;
коли він завершується;
чи був він відкликаний;
чи не був він уже використаний під час ротації.
Access token зазвичай містить ідентифікатор користувача та інші необхідні claims. Для прикладу використаємо JWT.
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);sub — стандартне поле JWT для ідентифікатора суб'єкта, у цьому випадку користувача.
Access token передається в заголовку:
Authorization: Bearer ACCESS_TOKENСервер перевіряє підпис і термін дії токена. Якщо токен недійсний, endpoint повертає 401 Unauthorized.
Access token не потрібно зберігати в базі даних: сервер може перевірити його підпис самостійно. Проте це означає, що окремий access token важко відкликати до завершення його терміну дії. Саме тому його роблять короткоживучим.
У прикладі refresh token буде не JWT, а випадковим непрозорим значенням:
const refreshToken = crypto.randomBytes(32).toString('hex');Сервер не передає цей токен у JSON-відповіді. Він встановлює його в cookie з атрибутами:
HttpOnly — JavaScript у браузері не може прочитати cookie;
Secure — cookie передається лише через HTTPS;
SameSite — зменшує ризик CSRF-атак;
Max-Age — обмежує час життя cookie.
У базі даних краще зберігати не сам refresh token, а його хеш. Якщо база даних буде скомпрометована, зловмисник не зможе одразу використати значення з таблиці.
Під час оновлення доступу сервер:
перевіряє поточний refresh token;
відкликає його;
створює новий refresh token;
встановлює новий refresh token у cookie;
повертає новий access token.
Це називається ротацією refresh token.
Якщо старий refresh token повторно використовується після ротації, це може свідчити про викрадення токена. У реальному застосунку можна відкликати всі refresh-сесії відповідного користувача.
Встановимо залежності:
npm install express jsonwebtoken cookie-parserФайл server.js:
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('node:crypto');
const cookieParser = require('cookie-parser');
const app = express();
const PORT = 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'development-secret-change-me';
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
app.use(express.json());
app.use(cookieParser());
// Для прикладу використовуємо користувача замість бази даних.
const users = [
{
id: 'user-1',
email: 'anna@example.com',
password: 'password123',
role: 'user'
}
];
// У production ці дані потрібно зберігати в базі даних.
const refreshSessions = new Map();
function hashToken(token) {
return crypto
.createHash('sha256')
.update(token)
.digest('hex');
}
function createAccessToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role
},
JWT_SECRET,
{
expiresIn: ACCESS_TOKEN_TTL
}
);
}
function createRefreshToken(userId) {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
refreshSessions.set(tokenHash, {
userId,
expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
revoked: false
});
return token;
}
function setRefreshCookie(response, token) {
response.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth',
maxAge: REFRESH_TOKEN_TTL_MS
});
}
function clearRefreshCookie(response) {
response.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/auth'
});
}
function authenticateAccessToken(request, response, next) {
const authorization = request.headers.authorization;
if (!authorization || !authorization.startsWith('Bearer ')) {
return response.status(401).json({
error: 'Access token is required'
});
}
const accessToken = authorization.slice('Bearer '.length);
try {
const payload = jwt.verify(accessToken, JWT_SECRET);
request.user = {
id: payload.sub,
role: payload.role
};
next();
} catch {
return response.status(401).json({
error: 'Access token is invalid or expired'
});
}
}
app.post('/auth/login', (request, response) => {
const { email, password } = request.body;
const user = users.find(
(candidate) =>
candidate.email === email && candidate.password === password
);
if (!user) {
return response.status(401).json({
error: 'Invalid email or password'
});
}
const accessToken = createAccessToken(user);
const refreshToken = createRefreshToken(user.id);
setRefreshCookie(response, refreshToken);
return response.json({
accessToken,
expiresIn: ACCESS_TOKEN_TTL
});
});
app.post('/auth/refresh', (request, response) => {
const currentRefreshToken = request.cookies.refreshToken;
if (!currentRefreshToken) {
return response.status(401).json({
error: 'Refresh token is required'
});
}
const tokenHash = hashToken(currentRefreshToken);
const session = refreshSessions.get(tokenHash);
if (
!session ||
session.revoked ||
session.expiresAt <= Date.now()
) {
clearRefreshCookie(response);
return response.status(401).json({
error: 'Refresh token is invalid or expired'
});
}
// Старий refresh token більше не можна використовувати.
session.revoked = true;
const user = users.find((candidate) => candidate.id === session.userId);
if (!user) {
clearRefreshCookie(response);
return response.status(401).json({
error: 'User not found'
});
}
const accessToken = createAccessToken(user);
const newRefreshToken = createRefreshToken(user.id);
setRefreshCookie(response, newRefreshToken);
return response.json({
accessToken,
expiresIn: ACCESS_TOKEN_TTL
});
});
app.post('/auth/logout', (request, response) => {
const currentRefreshToken = request.cookies.refreshToken;
if (currentRefreshToken) {
const tokenHash = hashToken(currentRefreshToken);
const session = refreshSessions.get(tokenHash);
if (session) {
session.revoked = true;
}
}
clearRefreshCookie(response);
return response.status(204).send();
});
app.get('/profile', authenticateAccessToken, (request, response) => {
const user = users.find((candidate) => candidate.id === request.user.id);
return response.json({
id: user.id,
email: user.email,
role: user.role
});
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});Запуск:
JWT_SECRET="a-long-random-secret" node server.jsУ Windows змінну середовища можна передати інакше або тимчасово використати значення за замовчуванням із прикладу. У production секрет обов'язково потрібно зберігати в змінній середовища або секретному сховищі.
curl -i -c cookies.txt \
-H "Content-Type: application/json" \
-d '{"email":"anna@example.com","password":"password123"}' \
http://localhost:3000/auth/loginПараметр -c cookies.txt зберігає cookie у файл. У відповіді буде access token:
{
"accessToken": "eyJ...",
"expiresIn": "15m"
}Підставимо отриманий токен у заголовок:
curl \
-H "Authorization: Bearer ACCESS_TOKEN" \
http://localhost:3000/profileЯкщо токен дійсний, сервер поверне профіль користувача.
Refresh token автоматично буде взято з cookies.txt:
curl -i -b cookies.txt -c cookies.txt \
-X POST \
http://localhost:3000/auth/refreshУ відповіді буде новий access token, а cookie буде замінено на новий refresh token.
curl -i -b cookies.txt -c cookies.txt \
-X POST \
http://localhost:3000/auth/logoutСервер відкличе поточну refresh-сесію та видалить cookie.
У браузерному застосунку access token часто зберігають лише в пам'яті JavaScript-застосунку. Це зменшує час його доступності після перезавантаження сторінки.
Refresh token зберігається в HttpOnly cookie. Такий cookie недоступний для document.cookie, тому звичайний JavaScript не може прочитати його значення.
Однак HttpOnly не захищає від усіх атак. Зокрема:
XSS може виконувати дії від імені користувача;
cookie-запити можуть мати ризик CSRF;
слабкі налаштування SameSite або відсутність додаткового CSRF-захисту можуть бути небезпечними.
Для звичайного сценарію cookie з SameSite: 'lax' є кращим початковим налаштуванням, ніж повністю відкритий режим. У production також потрібно використовувати HTTPS, щоб увімкнути Secure.
Access token, створений як JWT, дійсний до завершення свого терміну. Вихід із системи у наведеному прикладі відкликає refresh token, але вже виданий access token може залишатися дійсним до 15 хвилин.
Це нормальна властивість короткоживучого access token. Якщо необхідно негайно блокувати доступ, сервер може додатково використовувати:
коротший термін життя access token;
список відкликаних токенів;
версію сесії або токенів користувача;
перевірку активної сесії на сервері.
Такі механізми збільшують складність і зазвичай потрібні лише для конкретних вимог безпеки.
У прикладі для простоти використано масив користувачів і Map у пам'яті. У справжньому застосунку:
користувачі мають зберігатися в базі даних;
паролі потрібно зберігати лише у вигляді надійних хешів;
refresh-сесії потрібно зберігати в базі даних або іншому спільному сховищі;
refresh token потрібно зберігати в базі лише у вигляді хешу;
потрібно видаляти прострочені сесії;
секрет підпису JWT не можна зберігати в коді;
усі запити з токенами мають виконуватися через HTTPS.
Також один користувач може мати кілька refresh-сесій: наприклад, окрему для ноутбука і телефона. У такому разі кожна сесія повинна мати власний запис, час створення та можливість окремого відкликання.
Якщо access token діє кілька днів, його викрадення створює тривалий ризик.
Краще зробити access token короткоживучим і використовувати refresh token для поновлення доступу.
localStorageJavaScript-код та шкідливий скрипт після XSS можуть прочитати localStorage.
Для refresh token безпечнішим базовим варіантом є HttpOnly cookie з відповідними атрибутами.
Refresh token не потрібно повертати разом з access token у тілі відповіді. Краще встановлювати його як HttpOnly cookie.
Якщо один refresh token можна використовувати необмежену кількість разів, його викрадення дає довготривалий доступ.
Під час кожного оновлення старий токен потрібно відкликати та створювати новий.
У разі витоку бази даних зловмисник одразу отримає робочі токени.
Зберігайте хеш refresh token, а не його початкове значення.
Сервер має перевіряти не лише наявність refresh token у сховищі, а й:
чи не завершився його термін;
чи не відкликаний він;
чи існує відповідний користувач.
Секрети для розробки, тестування та production повинні бути різними. Компрометація тестового середовища не повинна автоматично компрометувати production.
Access token використовується для доступу до API.
Access token має короткий термін життя.
Refresh token використовується лише для отримання нового access token.
Refresh token доцільно зберігати в HttpOnly cookie.
На сервері потрібно зберігати хеш refresh token і його стан.
Під час оновлення доступу потрібно відкликати старий refresh token і виконувати ротацію.
Вихід із системи має відкликати refresh-сесію та очищати cookie.
Короткоживучий access token обмежує наслідки його викрадення, але не замінює HTTPS, захист від XSS і коректні налаштування cookie.