JWT 安全最佳实践:算法选择、过期、轮换与攻击防御
JSON Web Token 无处不在——但大多数实现存在严重安全漏洞。本指南涵盖算法选择(为何 RS256 优于 HS256)、Token 过期与轮换策略、Refresh Token 模式以及如何抵御算法混淆、"none" 算法绕过和密钥暴力破解等顶级 JWT 攻击。
JWT 是什么?它如何工作?
JWT 是一个 Base64URL 编码的字符串,由三个点分隔的部分组成:头部(算法 + 类型)、载荷(声明)和签名。服务端用密钥或私钥签名 Token;客户端每次请求时发送它,服务端验证签名以确认真实性而无需数据库查找。
算法选择:HS256 vs RS256 vs ES256
最常见的 JWT 安全错误是在分布式系统中使用 HS256。HS256 要求每个验证 Token 的服务共享同一个密钥——若一个服务被攻破,所有 Token 都可被伪造。RS256 和 ES256 使用非对称密钥:只有认证服务器持有私钥,其他服务只需公钥即可验证。
// Anatomy of a JWT — Header.Payload.Signature
// 1. Header (Base64URL decoded)
const header = {
alg: "RS256", // Algorithm: RS256 (asymmetric) preferred over HS256 (symmetric)
typ: "JWT"
};
// 2. Payload (Base64URL decoded) — the claims
const payload = {
iss: "https://auth.example.com", // Issuer
sub: "user_01HXYZ123", // Subject (user ID)
aud: "https://api.example.com", // Audience (target service)
exp: 1735689600, // Expiry: Unix timestamp (15 min from now)
iat: 1735688700, // Issued at
jti: "8f14e0f9-21af-4c1e-8f26-3b4a5c6d7e8f", // Unique token ID (for revocation)
roles: ["user"], // Custom claims
email: "alice@example.com"
};
// 3. Signature (server-side only)
// RS256: sign(base64url(header) + '.' + base64url(payload), PRIVATE_KEY)
// Verification: verify(token, PUBLIC_KEY) — other services only need the public key
// --- Generating a JWT with jsonwebtoken (Node.js) ---
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
import { randomUUID } from 'crypto';
const privateKey = readFileSync('./private.pem'); // RSA private key
const publicKey = readFileSync('./public.pem'); // RSA public key (shared with all services)
function issueAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{
sub: userId,
aud: 'https://api.example.com',
roles,
jti: randomUUID(),
},
privateKey,
{
algorithm: 'RS256',
issuer: 'https://auth.example.com',
expiresIn: '15m', // Short-lived access token
}
);
}
function verifyAccessToken(token: string) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // CRITICAL: whitelist algorithms — never allow 'none'
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
}Token 过期与轮换策略
短期 Access Token(15 分钟)配合长期 Refresh Token(7-30 天)是行业标准。Access Token 过期后,客户端静默地用 Refresh Token 换取新的令牌对。Refresh Token 必须存储在服务端(数据库中)以便可以撤销——不像 Access Token 是无状态的。
// Refresh Token Pattern — Stateless Access + Stateful Refresh
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
// ---- Token issuance ----
async function issueTokenPair(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, jti: randomUUID() },
process.env.JWT_PRIVATE_KEY!,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Refresh token: random bytes stored in DB — NOT a JWT
const refreshToken = randomBytes(64).toString('hex');
const refreshExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Store hashed refresh token in DB (never store plaintext)
await db.refreshTokens.create({
tokenHash: sha256(refreshToken),
userId,
expiresAt: refreshExpiry,
createdAt: new Date(),
});
return { accessToken, refreshToken, refreshExpiry };
}
// ---- Token refresh endpoint ----
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies; // httpOnly cookie
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const stored = await db.refreshTokens.findOne({
tokenHash: sha256(refreshToken),
expiresAt: { >: new Date() }, // Not expired
revokedAt: null, // Not revoked
});
if (!stored) return res.status(401).json({ error: 'Invalid or expired refresh token' });
// Rotate: invalidate old, issue new pair (refresh token rotation)
await db.refreshTokens.update(
{ id: stored.id },
{ revokedAt: new Date() } // Revoke old refresh token
);
const newPair = await issueTokenPair(stored.userId);
// Set new refresh token as httpOnly cookie
res.cookie('refreshToken', newPair.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: newPair.refreshExpiry,
path: '/auth/refresh', // Scope cookie to refresh endpoint only
});
return res.json({ accessToken: newPair.accessToken });
});
// ---- Logout: revoke refresh token ----
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
await db.refreshTokens.update(
{ tokenHash: sha256(refreshToken) },
{ revokedAt: new Date() }
);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.json({ success: true });
});常见 JWT 攻击与防御
最危险的 JWT 漏洞:(1)算法混淆——攻击者将 alg 改为 "none" 以绕过签名验证;(2)RS256→HS256 混淆——攻击者欺骗期望 RS256 的服务器将公钥作为 HS256 密钥;(3)弱密钥——小于 256 位的 HS256 密钥可被暴力破解;(4)缺少 exp 声明验证——库默认可能不验证过期时间。
// JWT Attack Defenses — Code Patterns
// ---- ATTACK 1: Algorithm confusion ("none" bypass) ----
// VULNERABLE: never use verify() without specifying allowed algorithms
const VULNERABLE = jwt.verify(token, secret); // attacker can set alg: "none"
// SAFE: always whitelist algorithms
const SAFE = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Never include 'none', 'HS256' if you're using RS256
});
// ---- ATTACK 2: RS256 > HS256 confusion ----
// Attacker changes alg from RS256 to HS256 and signs with the PUBLIC key as the secret
// Defense: always specify algorithm on verify — never infer from the token header
// VULNERABLE:
const header = JSON.parse(atob(token.split('.')[0]));
jwt.verify(token, publicKey, { algorithms: [header.alg] }); // attacker controls this!
// SAFE:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // hardcoded
// ---- ATTACK 3: Weak HS256 secrets (brute-force) ----
// VULNERABLE: short or predictable secrets
const BAD_SECRET = 'secret';
const BAD_SECRET2 = 'mysupersecretkey';
// SAFE: 256-bit cryptographically random secret
import { randomBytes } from 'crypto';
const GOOD_SECRET = randomBytes(32).toString('hex'); // 256-bit
// Store in environment variable, rotate annually
// ---- ATTACK 4: Missing expiry validation ----
// Some older libraries skip exp check by default
// SAFE: always validate exp (jsonwebtoken does this by default)
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // Allow 30 seconds clock skew max
ignoreExpiration: false, // NEVER set this to true in production
});
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// ---- Token blacklist (for early revocation via jti) ----
import { createClient } from 'redis';
const redis = createClient();
async function revokeToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000); // seconds until natural expiry
if (ttl > 0) {
await redis.set(`jwt:revoked:${jti}`, '1', { EX: ttl });
}
}
async function isTokenRevoked(jti: string): Promise<boolean> {
return (await redis.get(`jwt:revoked:${jti}`)) !== null;
}
// Middleware: check blacklist
app.use(async (req, res, next) => {
const decoded = jwt.verify(req.headers.authorization?.split(' ')[1] ?? '', publicKey, {
algorithms: ['RS256'],
});
if (await isTokenRevoked((decoded as jwt.JwtPayload).jti ?? '')) {
return res.status(401).json({ error: 'Token revoked' });
}
next();
});安全的 Token 存储
Access Token:存储在内存中(JavaScript 变量)——不存在 localStorage(易受 XSS 攻击)。Refresh Token:存储在 httpOnly、Secure、SameSite=Strict cookie 中。这种组合防止 XSS 读取 Refresh Token,SameSite 阻止跨源 cookie 提交。
常见问题
我应该使用 JWT 还是不透明会话 Token?
传统服务端渲染应用使用不透明会话 Token(存储在数据库中的随机 ID)——更简单、可即时撤销、无算法攻击面。微服务或需要跨多个服务进行无状态验证的 API 使用 JWT。
如何在过期前撤销 JWT?
JWT 设计上是无状态的,即时撤销较难。解决方案:(1)短过期时间(15 分钟);(2)Token 黑名单——在 Redis 中存储已撤销的 JTI;(3)用户记录中的 Token 版本——撤销时递增版本字段,在 JWT 中包含版本,拒绝版本过期的 Token。
每个 JWT 应包含哪些声明?
必须:iss(签发者)、sub(主体/用户 ID)、aud(受众/目标服务)、exp(过期时间)、iat(签发时间)、jti(唯一 Token ID 用于撤销)。始终在接收服务上验证 iss 和 aud,防止跨服务的 Token 重用。
JWT 密钥和私钥应该多长?
HS256 密钥:最少 256 位(32 字节)的加密随机数据,使用 crypto.randomBytes(32)。RS256/RS512:最少 2048 位 RSA 密钥(长期密钥推荐 4096 位)。ES256:256 位椭圆曲线密钥(P-256)。永远不要使用密码或人类可读字符串作为密钥。