В системе авторизации на основе JWT разделение на access token и refresh token решает сразу несколько проблем: ограничение времени жизни сессии, снижение ущерба при компрометации токена и возможность безопасного обновления доступа без повторного логина пользователя.
Access token используется для доступа к защищённым ресурсам API. Его
основная характеристика — короткий срок жизни (обычно от 5 до 30 минут).
Он передаётся в каждом запросе, чаще всего в заголовке
Authorization: Bearer.
Refresh token применяется исключительно для получения нового access token. Он живёт значительно дольше (дни или недели) и не используется для доступа к API напрямую.
Ключевая идея: даже если access token будет украден, его короткое время жизни ограничивает ущерб. Refresh token хранится более строго и используется реже.
Библиотека jose предоставляет инструменты для работы с
JOSE-стандартами: JWS (подпись), JWE (шифрование), JWT (токены).
Основные операции:
SignJWT)jwtVerify)В контексте access/refresh схемы jose используется
для:
Access token обычно содержит минимальный набор данных:
userId, role, возможно
sessionId.
Пример создания:
import { SignJWT } from 'jose';
const secret = new TextEncoder().encode(process.env.ACCESS_SECRET);
export async function createAccessToken(user) {
return await new SignJWT({
sub: user.id,
role: user.role
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(secret);
}
Ключевые моменты:
sub — идентификатор пользователяexp (15 минут)Каждый запрос к защищённому ресурсу требует валидации токена:
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.ACCESS_SECRET);
export async function verifyAccessToken(token) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch (e) {
return null;
}
}
При ошибке проверки токен считается недействительным: истёк, изменён или подписан другим ключом.
Refresh token обычно содержит только идентификатор сессии или пользователя. Его задача — не передавать лишние данные.
export async function createRefreshToken(user) {
return await new SignJWT({
sub: user.id,
type: 'refresh'
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(new TextEncoder().encode(process.env.REFRESH_SECRET));
}
Важно разделять секреты:
Это снижает риск полной компрометации системы.
Основной сценарий: access token истёк, но пользователь не должен заново логиниться.
Процесс:
import { jwtVerify } from 'jose';
import { SignJWT } from 'jose';
const refreshSecret = new TextEncoder().encode(process.env.REFRESH_SECRET);
const accessSecret = new TextEncoder().encode(process.env.ACCESS_SECRET);
export async function refreshAccessToken(refreshToken) {
const { payload } = await jwtVerify(refreshToken, refreshSecret);
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
const newAccessToken = await new SignJWT({
sub: payload.sub
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(accessSecret);
return newAccessToken;
}
Более безопасная модель — rotation refresh token. Каждый раз при обновлении access token выдается новый refresh token, старый инвалидируется.
Проблема JWT: он сам по себе неотзываемый. Поэтому используется дополнительное хранилище:
Пример логики:
const store = new Map();
export function saveRefreshToken(userId, token) {
store.set(userId, token);
}
export function isValidRefreshToken(userId, token) {
return store.get(userId) === token;
}
При обновлении:
Access token:
Refresh token:
Пример установки cookie:
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000
});
Это защищает refresh token от XSS.
Access token не должен храниться в localStorage. Лучше держать в памяти приложения.
Если refresh token в cookie:
/auth/refreshРешается:
В продакшене часто используют RSA или ECDSA.
Генерация:
import { generateKeyPair } from 'jose';
const { publicKey, privateKey } = await generateKeyPair('RS256');
Подпись:
await new SignJWT({ sub: user.id })
.setProtectedHeader({ alg: 'RS256' })
.setExpirationTime('15m')
.sign(privateKey);
Проверка:
await jwtVerify(token, publicKey);
Преимущество:
Auth service:
API service:
Redis/DB:
JWT нельзя удалить, поэтому используется стратегия:
export function logout(userId) {
store.delete(userId);
}
Login:
Request API:
Expired access:
Refresh:
Logout: