关于 jwt
面向 Node.js 应用的 JWT 认证参考技能,涵盖令牌处理、验证、轮换、撤销及生产环境安全实践。
主要功能
- 01JWT 结构与声明
- 02Node.js 令牌生成与中间件
- 03安全存储与算法建议
- 04刷新令牌轮换与撤销
- 05常见令牌问题排查
适用场景
- 01构建 Bearer 令牌认证
- 02审查 JWT 存储与过期策略
- 03添加刷新令牌轮换和撤销
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ claude-dev-suite/ claude-dev-suite --skill jwt安装后在 Claude Code 中输入/jwt确认已加载。
附带文件quick-ref/implementation.mdquick-ref/security.md
详细介绍
JWT 核心知识
深度知识:使用
mcp__documentation__fetch_docs,并指定技术:jwt,获取完整文档。
令牌结构
header.payload.signature
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "1234", "name": "John", "iat": 1516239022 }
Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
Node.js 实现
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET!;
// Generate token
function generateToken(user: User): string {
return jwt.sign(
{ sub: user.id, email: user.email },
SECRET,
{ expiresIn: '1h' }
);
}
// Verify token
function verifyToken(token: string): JwtPayload {
return jwt.verify(token, SECRET) as JwtPayload;
}
// Refresh token pattern
function generateRefreshToken(user: User): string {
return jwt.sign(
{ sub: user.id, type: 'refresh' },
SECRET,
{ expiresIn: '7d' }
);
}
中间件
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
req.user = verifyToken(token);
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
不要使用此技能的情况
- 基于会话的身份验证 - 使用传统的服务端会话和 Cookie
- OAuth 2.0 流程 - 使用
oauth2技能进行第三方身份验证 - NextAuth.js - 使用
nextauth技能进行 Next.js 身份验证 - 简单的内部 API - API 密钥可能就足够了
最佳实践
| 应做 | 不应做 |
|---|---|
| 使用 HTTPS | 存储在 localStorage 中(使用 httpOnly Cookie) |
| 设置较短的有效期(15 分钟至 1 小时) | 在载荷中放入敏感数据 |
| 验证所有声明 | 使用弱密钥 |
| 使用刷新令牌 | 忽略过期时间 |
反模式
| 反模式 | 不良之处 | 正确做法 |
|---|---|---|
| 将 JWT 存储在 localStorage 中 | 容易受到 XSS 攻击 | 使用 httpOnly Cookie |
| 长期有效的访问令牌 | 泄露后会带来安全风险 | 设置 15 分钟有效期 + 使用刷新令牌 |
| 弱密钥(< 32 字节) | 容易遭到暴力破解 | 使用 256 位随机密钥 |
| 忽略算法验证 | 可能遭受算法混淆攻击 | 明确指定允许的算法 |
| 在载荷中放入密码 | 令牌经过 Base64 编码,并未加密 | 仅包含非敏感声明 |
| 没有令牌撤销机制 | 无法让用户登出 | 实现黑名单或令牌版本控制 |
快速故障排除
| 问题 | 原因 | 解决方案 |
|---|---|---|
| "Invalid signature" | 密钥或算法错误 | 确认 JWT_SECRET 匹配,并检查算法 |
| "Token expired" | exp 声明已过期 | 实现刷新令牌流程 |
| "Missing token" | 未发送 Authorization 标头 | 检查 Authorization: Bearer <token> |
| 无法识别令牌 | 令牌格式错误 | 确认格式为 header.payload.signature |
| 使用 Cookie 时发生 CORS 错误 | SameSite/Secure 标志设置不当 | 设置 sameSite:'strict', secure:true |
| 登出不起作用 | 令牌是无状态的 | 使用 Redis/DB 实现撤销机制 |
标准声明
| 声明 | 用途 |
|---|---|
sub | 主题(用户 ID) |
iat | 签发时间 |
exp | 过期时间 |
iss | 签发者 |
aud | 受众 |
生产环境就绪情况
安全配置
// Use asymmetric keys (RS256) for production
import * as jose from 'jose';
// Generate key pair (run once, store securely)
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
const privateKey = await jose.importPKCS8(
process.env.JWT_PRIVATE_KEY!,
'RS256'
);
const publicKey = await jose.importSPKI(
process.env.JWT_PUBLIC_KEY!,
'RS256'
);
// Sign token
async function generateToken(user: User): Promise<string> {
return new jose.SignJWT({
sub: user.id,
email: user.email,
})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(process.env.JWT_ISSUER!)
.setAudience(process.env.JWT_AUDIENCE!)
.setExpirationTime('15m') // Short-lived access token
.sign(privateKey);
}
// Verify token
async function verifyToken(token: string): Promise<jose.JWTPayload> {
const { payload } = await jose.jwtVerify(token, publicKey, {
issuer: process.env.JWT_ISSUER!,
audience: process.env.JWT_AUDIENCE!,
});
return payload;
}
安全的令牌存储
// Server-side: HttpOnly cookie for access token
res.cookie('access_token', token, {
httpOnly: true, // Prevents XSS access
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
// Refresh token in separate cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Only sent to refresh endpoint
});
令牌轮换与撤销
// Refresh token rotation
async function refreshTokens(refreshToken: string) {
// Verify refresh token
const payload = await verifyRefreshToken(refreshToken);
// Check if refresh token is in blacklist (revoked)
if (await isTokenRevoked(refreshToken)) {
throw new Error('Token revoked');
}
// Revoke old refresh token
await revokeToken(refreshToken);
// Generate new tokens
const user = await db.users.findUnique({ where: { id: payload.sub } });
return {
accessToken: await generateToken(user),
refreshToken: await generateRefreshToken(user),
};
}
// Token revocation with Redis
async function revokeToken(token: string): Promise<void> {
const payload = await jose.decodeJwt(token);
const ttl = payload.exp! - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.set(`revoked:${token}`, '1', 'EX', ttl);
}
}
// Logout: revoke all user tokens
async function logoutAll(userId: string): Promise<void> {
// Increment user's token version, invalidating all existing tokens
await db.users.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } },
});
}
算法安全
// NEVER allow 'none' algorithm
// ALWAYS specify allowed algorithms explicitly
const { payload } = await jose.jwtVerify(token, publicKey, {
algorithms: ['RS256'], // Only allow RS256
issuer: process.env.JWT_ISSUER!,
audience: process.env.JWT_AUDIENCE!,
});
// Validate token type to prevent token confusion
if (payload.type !== 'access') {
throw new Error('Invalid token type');
}
监控指标
| 指标 | 告警阈值 |
|---|---|
| 令牌验证失败次数 | > 100/分钟 |
| 刷新令牌重复使用尝试次数 | > 10/分钟 |
| 过期令牌请求次数 | > 500/分钟 |
| 签名无效错误次数 | > 50/分钟 |
声明验证
async function validateTokenClaims(payload: jose.JWTPayload): Promise<void> {
// Check required claims
if (!payload.sub || !payload.iat || !payload.exp) {
throw new Error('Missing required claims');
}
// Check user still exists and is active
const user = await db.users.findUnique({ where: { id: payload.sub } });
if (!user || !user.isActive) {
throw new Error('User not found or inactive');
}
// Check token version (for logout-all functionality)
if (payload.tokenVersion !== user.tokenVersion) {
throw new Error('Token invalidated');
}
}
检查清单
- 在生产环境中使用 RS256(非对称)
- 设置较短的访问令牌有效期(15 分钟)
- 使用带轮换机制的刷新令牌
- 使用 HttpOnly Cookie(而非 localStorage)
- 设置 Secure + SameSite Cookie 标志
- 配置令牌撤销机制
- 验证签发者和受众
- 明确指定允许的算法
- 包含用于“全部登出”的令牌版本
- 监控验证失败情况
- 对令牌端点实施速率限制
参考文档
评论
支持 Markdown