JWT 认证最佳实践:算法、存储与吊销实战
JWT 被滥用的现状
JWT 几乎成了「做登录」的默认选项,但我在 code review 里见过太多误用:把用户密码塞进 payload、用 alg: none 的库版本、把 token 存 localStorage 还奇怪为什么被 XSS 偷走、token 永远不失效所以没法吊销。
JWT 本身没问题,问题在「以为它是万能的 session 替代品」。这篇文章把生产环境里真正要命的几个点讲清楚。
一、JWT 到底是什么
JWT 由三部分组成,用 . 分隔:header.payload.signature。
- header:算法和类型,如
{"alg":"HS256","typ":"JWT"} - payload:声明(claims),如
{"sub":"123","name":"Ada","exp":1700000000} - signature:对前两部分用密钥签名,防篡改
关键点:payload 只是 Base64Url 编码,任何人都能解码看内容。它不是加密。所以千万别往里放密码、身份证号、token 本身。
# payload 是这样被解码的(人人可读)
echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}
二、最常见的三个误区
误区一:把 JWT 当 session 用
传统 session 把状态存在服务端,吊销一个用户只要在服务端删掉 session。JWT 默认是无状态的——服务端不存它,所以「吊销」变得困难。如果你需要频繁吊销、踢人下线,JWT 并不比 session 简单,反而更麻烦(见第六节)。
误区二:payload 塞敏感数据
因为 payload 可读,放密码、邮箱明文、内部权限位都是泄露风险。只放必要的、非敏感的标识:sub(用户 ID)、role、exp 等。
误区三:token 永不过期
不设 exp,或者设了几年后过期,等于发了一张长期通行证。一旦泄露,攻击者能一直用。
三、算法安全:攻击都从这来
none 算法攻击
早期某些库在 alg: none 时会跳过签名验证。攻击者把 header 改成 {"alg":"none"},把 signature 留空,就能伪造任意 payload。
防御:服务端必须显式指定允许的算法白名单,拒绝 none:
// 用 jsonwebtoken 时显式声明算法
jwt.verify(token, secret, { algorithms: ["HS256"] });
绝不让客户端决定用哪个算法。
RS256 / HS256 混淆攻击
这是经典的「算法混淆」:服务端用 RSA 公钥验证(期望 RS256),但攻击者把 header 改成 HS256,然后用公钥当 HMAC 的密钥去签名。因为公钥往往是公开可获取的,攻击者就能伪造合法 token。
防御:
- 验证时同时锁定算法和密钥类型:RS256 用公钥验证,HS256 用对称密钥。
- 不要把 RSA 公钥当 HMAC 密钥使用。
- 用成熟库(jsonwebtoken、jose)并显式传
algorithms。
// RS256:用公钥验签,且只接受 RS256
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
密钥管理
- 对称算法(HS256)的 secret 要足够长、随机、不进代码仓库(用环境变量/密钥管理)。
- 非对称(RS256/ES256)的私钥只在签发服务端,公钥可分发。
- 支持密钥轮换(
kid头标识当前密钥)。
四、过期与刷新:access + refresh 设计
最佳实践是把 token 分两种:
- access token:短期(如 15 分钟),用于访问接口,泄露窗口小。
- refresh token:长期(如 7 天),只用来换新的 access token,存得要更安全。
// 签发
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
algorithm: "RS256",
expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
algorithm: "RS256",
expiresIn: "7d",
});
刷新接口只接受 refresh token,且每次刷新都发新的 refresh token(轮换),旧的作废。这样即使 refresh token 泄露,用一次就被取代。
五、存储安全:localStorage vs Cookie
这是 XSS 与 CSRF 的权衡题。
localStorage 的风险
localStorage 里的 token 任何 JS 都能读。一旦有 XSS(哪怕是一个有漏洞的第三方脚本),token 就被偷走发到攻击者服务器。而且 localStorage 无法设置 HttpOnly,不可被标记成「只走 HTTP」。
Cookie(HttpOnly + Secure + SameSite)更稳
把 access token 放进 Cookie:
HttpOnly:JS 读不到,挡住 XSS 窃取。Secure:只走 HTTPS。SameSite=Strict/Lax:挡住大多数 CSRF。
代价是要处理 CSRF(见第七节)。对大多数 Web 应用,这是比 localStorage 更安全的默认选择。
// 伪代码:Set-Cookie
res.setHeader("Set-Cookie", [
`access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);
折中方案
- SPA + 后端同域:用 HttpOnly Cookie 存 access,前端通过 fetch 自动带 cookie。
- 移动端 / 第三方调用:用 Authorization 头 + 短期 access token,refresh 走安全存储。
六、吊销与黑名单:jti
access token 短期 + refresh 轮换,已经能控制大部分风险。但「立刻踢人下线」怎么办?
给每个 token 一个唯一 jti(JWT ID),维护一个「吊销列表」(Redis 黑名单,带 TTL 等于 token 剩余寿命):
// 吊销
await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);
// 校验时先查黑名单
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");
这样不需要在服务端存所有 token,只存「被吊销的」,代价极小。注意:黑名单本身只覆盖「需要主动吊销」的场景(封号、改密码),正常过期仍靠 exp。
七、CSRF 防护与 SameSite
用 Cookie 就必须考虑 CSRF:攻击者诱导用户浏览器向你的域名发请求,浏览器自动带上 Cookie,于是以用户身份执行操作。
- SameSite=Lax/Strict:现代浏览器默认就能挡掉大多数跨站请求带 Cookie。优先用
SameSite=Lax。 - CSRF Token:对敏感写操作,要求请求带服务端下发的随机 token(double-submit)。
- 重要写操作要求重新认证:改密码、转账等,除了 Cookie 还要二次验证。
// 敏感操作额外校验
app.post("/api/transfer", requireReauth, handler);
八、实战:Node/Express 中间件
一个最小但安全的校验中间件:
import jwt from "jsonwebtoken";
export function authGuard(publicKey: string) {
return (req, res, next) => {
const token = req.cookies?.access; // HttpOnly Cookie
if (!token) return res.status(401).json({ error: "no token" });
let payload;
try {
// 显式锁定算法,拒绝 none / 混淆
payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
} catch {
return res.status(401).json({ error: "invalid token" });
}
// 查吊销黑名单
if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });
req.user = { id: payload.sub, role: payload.role };
next();
};
}
配套的刷新接口:
app.post("/api/refresh", async (req, res) => {
const rt = req.cookies?.refresh;
if (!rt) return res.status(401).json({ error: "no refresh" });
const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });
// 轮换:吊销旧 refresh,发新的一对
blacklist.add(payload.jti);
const newAccess = issueAccess(payload.sub);
const newRefresh = issueRefresh(payload.sub); // 新 jti
res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
res.json({ ok: true });
});
常见问题
Q1:JWT 和 session 哪个好?
看需求。需要服务端集中控制、频繁踢人、存大量会话状态——session 更合适。需要无状态、跨服务、移动端友好——JWT 更合适。别因为「流行」就无脑选 JWT。
Q2:access token 过期时间设多长?
Web 应用 5~15 分钟较稳;配合 refresh 轮换。太长泄露窗口大,太短刷新频繁影响体验。
Q3:为什么我的 JWT 库报「invalid algorithm」?
基本是服务端期望 RS256 但收到了 HS256,或反过来——算法没锁死。务必 algorithms: [...] 显式声明,且 RS256 用公钥验证。
Q4:前端怎么拿用户信息?
不要解码 JWT 拿敏感数据(payload 可读但不可信,且可能过期)。让用户接口(/api/me)返回权威的用户信息,前端只存「用户 ID」这类非敏感标识。
Q5:JWT 能加密吗?
能,用 JWE(JSON Web Encryption)可以加密 payload。但多数场景只需要签名(防篡改)即可,加密会增加复杂度。敏感数据就别放进去,比加密更省事。
工具推荐
调试 token、看它到底装了什么时,这些 ToolsKu 工具顺手:
- JWT 解码 — 粘贴 token 看 header/payload(不校验签名,纯查看)
- JWT 生成器 — 自己填 header/payload/密钥生成测试 token
- HMAC — 手工验证 HS256 签名是否对得上
JWT 不是 session 的替代品,而是另一种权衡:用「无状态、可分发」换来了「吊销麻烦、payload 可读」。想清楚你换来了什么、又失去了什么,才算真正用好了它。
本站提供浏览器本地工具,免注册即可试用 →