JSON Web Tokens: полное руководство
JWT (JSON Web Token) — это компактный, URL-safe способ передачи claims между сторонами. Токены широко используются для аутентификации и обмена данными в микросервисных архитектурах. В отличие от сессионных токенов, JWT содержат всю необходимую информацию внутри себя и могут проверяться без обращения к базе данных.
Структура JWT
JWT состоит из трёх частей, разделённых точками. Каждая часть кодируется в Base64URL.
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Токен можно декодировать на любой стороне — это не шифрование, а подпись. Для конфиденциальных данных используйте JWE (JWT Encryption).
Header
Заголовок содержит алгоритм подписи и тип токена.
{
"alg": "HS256",
"typ": "JWT"
}
Распространённые алгоритмы: HS256 (симметричный), RS256 (асимметричный).
Payload
Полезная нагрузка содержит claims — утверждения о субъекте и дополнительные данные.
{
"sub": "user123",
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
Не храните чувствительные данные в payload — токено можно декодировать без ключа.
Standard Claims
Стандартные claims определены в RFC 7519:
- iss — Issuer (кто издал)
- sub — Subject (субъект)
- aud — Audience (получатель)
- exp — Expiration Time
- nbf — Not Before
- iat — Issued At
- jti — JWT ID
Генерация
Генерация JWT включает создание header, payload и подписи. Библиотеки делают это автоматически.
Node.js
Популярная библиотека jsonwebtoken поддерживает все стандартные алгоритмы.
const jwt = require('jsonwebtoken');
const payload = {
sub: 'user123',
email: 'user@example.com',
role: 'admin'
};
const secret = process.env.JWT_SECRET;
// Создание токена
const token = jwt.sign(payload, secret, {
expiresIn: '1h',
issuer: 'my-app',
audience: 'my-api'
});
// Создание с алгоритмом RS256 (асимметричный)
const privateKey = fs.readFileSync('private.pem');
const tokenRS256 = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
Асимметричные алгоритмы (RS256) предпочтительнее для микросервисов — сервисы могут проверять токены без доступа к приватному ключу.
Payload с несколькими claims
При создании токена можно включать как стандартные claims, так и кастомные данные:
const token = jwt.sign({
// Стандартные
sub: user.id,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
// Кастомные
email: user.email,
role: user.role,
permissions: ['read', 'write', 'delete'],
// Для refresh
type: 'access' // или 'refresh'
}, secret, { expiresIn: '1h' });
Верификация
Проверка токена
const jwt = require('jsonwebtoken');
app.get('/protected', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, secret, {
issuer: 'my-app',
audience: 'my-api',
algorithms: ['HS256']
});
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
});
Асинхронная верификация
const jwt = require('jsonwebtoken');
const verifyAsync = (token, secret) => {
return new Promise((resolve, reject) => {
jwt.verify(token, secret, (err, decoded) => {
if (err) reject(err);
else resolve(decoded);
});
});
};
// Или с async/await
const decoded = await jwt.verifyAsync(token, secret);
RS256 (асимметричное шифрование)
Генерация ключей
# Приватный ключ
openssl genrsa -out private.pem 2048
# Публичный ключ
openssl rsa -in private.pem -pubout -out public.pem
Подпись и верификация
const fs = require('fs');
const jwt = require('jsonwebtoken');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Подпись
const token = jwt.sign({ sub: 'user123' }, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
// Верификация
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256']
});
Refresh Token
const crypto = require('crypto');
// Генерация refresh токена
function generateRefreshToken() {
return crypto.randomBytes(64).toString('hex');
}
// Хранение в БД
async function refreshAccessToken(refreshToken) {
const stored = await db.refreshTokens.find(refreshToken);
if (!stored || stored.expiresAt < Date.now()) {
throw new Error('Invalid refresh token');
}
// Ротация токена
await db.refreshTokens.delete(refreshToken);
const newRefreshToken = generateRefreshToken();
await db.refreshTokens.create({
token: newRefreshToken,
userId: stored.userId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 дней
});
const accessToken = jwt.sign(
{ sub: stored.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
return { accessToken, refreshToken: newRefreshToken };
}
Middleware примеры
Express middleware
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Опциональная авторизация
const optionalAuth = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
// Игнорируем ошибку
}
}
next();
};
Проверка ролей
const requireRole = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
};
// Использование
app.get('/admin', authenticate, requireRole('admin'), adminHandler);
Best Practices
Безопасность
// 1. Используйте HTTPS
// 2. Храните секреты в env
const secret = process.env.JWT_SECRET;
// 3. Короткое время жизни access token
jwt.sign(payload, secret, { expiresIn: '15m' });
// 4. Используйте RS256 для распределённых систем
// 5. Проверяйте issuer и audience
jwt.verify(token, secret, { issuer: 'my-app', audience: 'my-api' });
// 6. Не храните чувствительные данные в payload
// payload виден всем, кто может декодировать токен
jwt.sign({
sub: user.id,
// email: user.email - НЕ рекомендуется!
}, secret);
Хранение на клиенте
// Безопасное хранение
// 1. HttpOnly cookies (рекомендуется)
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
// 2. В памяти (для SPA)
sessionStorage.setItem('token', token);
// НЕ используйте localStorage - XSS уязвимость
Blacklist
const jwt = require('jsonwebtoken');
const redis = require('redis');
const redisClient = redis.createClient();
// Blacklist проверка
const isTokenRevoked = async (token) => {
const result = await redisClient.get(`blacklist:${token}`);
return result !== null;
};
// Middleware с blacklist
const authenticate = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (await isTokenRevoked(token)) {
return res.status(401).json({ error: 'Token revoked' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Logout
app.post('/logout', authenticate, async (req, res) => {
const decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
const exp = decoded.exp;
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redisClient.setEx(
`blacklist:${req.headers.authorization.split(' ')[1]}`,
ttl,
'revoked'
);
}
res.json({ success: true });
});
Заключение
JWT — удобный способ передачи данных, но требует внимания к безопасности: короткое время жизни, безопасное хранение, проверка issuer/audience.