JSON Web Token(JWT)是目前Web应用中最主流的身份认证方案之一,但它的安全存储和过期策略如果做不好,就等于把用户的"钥匙"直接暴露在攻击者面前。核心问题就三个:JWT存哪里、怎么过期、被盗了怎么办。答案是:不要用localStorage存JWT,优先使用HttpOnly + Secure + SameSite的Cookie来承载;过期策略必须采用"短生命周期Access Token + 长生命周期Refresh Token"的双令牌机制;同时配合Token黑名单或Token版本号来实现主动失效。下面我把每一个环节拆开讲透。
一、JWT为什么不能随便存——存储位置的安全对比
很多开发者习惯把JWT直接塞进localStorage或者sessionStorage,觉得方便,前端随时能取。但这恰恰是最大的安全隐患。localStorage里的数据可以被任何JavaScript代码读取,一旦页面存在XSS(跨站脚本攻击)漏洞,攻击者只需要一行代码就能把Token偷走。sessionStorage虽然窗口关闭就清空,但同样面临XSS风险。
更安全的做法是把JWT放在HttpOnly Cookie里。HttpOnly标志意味着JavaScript无法通过document.cookie读取这个Cookie,XSS攻击就拿不到Token。同时配合Secure标志确保只在HTTPS连接下传输,SameSite设置为Strict或Lax可以有效防止CSRF(跨站请求伪造)攻击。具体设置方式如下:
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/;
需要注意的是,把JWT放Cookie里也不是万能的。如果你的应用是前后端分离架构,前端和后端不在同一个域名下,Cookie的跨域问题就需要特别处理。这时候可以使用BFF(Backend For Frontend)层做代理,或者在同一个父域名下部署前后端服务。另外,Cookie有大小限制(通常4KB左右),如果JWT的payload太大,就需要考虑压缩或者精简字段。
二、JWT过期策略的核心设计——双令牌机制
JWT本身是无状态的,服务器不需要存储它,验证的时候只要签名对、没过期就放行。但这也意味着一旦Token泄露,在过期之前攻击者都能用。所以过期时间不能设太长,通常Access Token的有效期建议在15分钟到2小时之间。但如果每次用户操作都要重新登录,体验就太差了。
解决方案就是引入Refresh Token。Access Token短期有效,负责日常API请求的身份验证;Refresh Token长期有效(比如7天到30天),专门用来换取新的Access Token。当Access Token过期时,前端用Refresh Token向后端请求一个新的Access Token,用户无感知。Refresh Token必须存储在更安全的地方,比如HttpOnly Cookie,并且后端要对它做严格的校验和存储。
具体的刷新流程代码示例:
// 后端刷新Token接口
app.post('/api/refresh-token', async (req, res) => {
const { refreshToken } = req.body;
// 验证Refresh Token是否存在且未被撤销
const storedToken = await TokenStore.findOne({ token: refreshToken });
if (!storedToken || storedToken.revoked) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// 检查是否过期
if (storedToken.expiresAt < new Date()) {
await TokenStore.deleteOne({ token: refreshToken });
return res.status(401).json({ error: 'Refresh token expired' });
}
// 生成新的Access Token
const newAccessToken = jwt.sign(
{ userId: storedToken.userId, role: storedToken.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
// 可选:轮换Refresh Token(每次刷新都发新的,旧的立即失效)
const newRefreshToken = jwt.sign(
{ userId: storedToken.userId },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
await TokenStore.updateOne(
{ token: refreshToken },
{ $set: { token: newRefreshToken, revoked: true } }
);
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});
这里有一个关键的安全细节:Refresh Token轮换(Rotation)。每次用Refresh Token换取新Access Token时,后端同时发一个新的Refresh Token,并把旧的标记为已撤销。这样如果攻击者偷到了旧的Refresh Token,他用的时候后端会发现这个Token已经被撤销了,攻击就会被阻断。这个机制叫Refresh Token Rotation,是目前业界推荐的最佳实践。
三、JWT被盗后的主动失效——黑名单与版本号机制
JWT最大的痛点就是"一旦签发,无法主动作废"。因为它是无状态的,服务器不存Token,所以你没法像Session一样直接删掉。但实际业务中,用户修改密码、主动登出、管理员封禁账号等场景都需要让已签发的Token立即失效。
常用的解决方案有两种。第一种是Token黑名单。后端维护一个黑名单(可以用Redis这种内存数据库,速度快),把需要作废的Token ID(jti字段)存进去。每次验证Token时,先查黑名单,如果在里面就拒绝。代码示例:
// 中间件验证Token
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 检查黑名单
const isBlacklisted = await redis.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
第二种方案是Token版本号。在JWT的payload里加一个version字段,后端数据库里记录用户当前的Token版本。每次用户修改密码或主动登出,就把版本号加1。验证Token时,对比payload里的version和数据库里的version,不一致就拒绝。这个方案比黑名单更轻量,不需要额外的存储,但需要每次验证时查一次数据库。
四、JWT的payload设计——不要放敏感信息
JWT的payload部分是Base64编码的,不是加密的,任何人都能解码看到里面的内容。所以绝对不要在payload里放密码、身份证号、银行卡号这类敏感数据。payload里只放必要的标识信息,比如userId、role、exp(过期时间)、iat(签发时间)、jti(唯一标识)就够了。
另外,jti(JWT ID)字段非常重要,它是每个Token的唯一标识,配合黑名单机制使用。签发Token时一定要生成一个唯一的jti:
const payload = {
sub: userId,
role: userRole,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 900, // 15分钟
jti: crypto.randomUUID() // 唯一标识
};
五、签名算法的选择——远离HS256的坑
JWT支持多种签名算法,HS256(HMAC + SHA256)和RS256(RSA + SHA256)是最常用的。HS256是对称加密,前后端用同一个密钥;RS256是非对称加密,私钥签名、公钥验证。如果你的应用只有一个服务,用HS256没问题;但如果有多个服务需要验证Token,或者你希望私钥只保存在认证服务器上,就应该用RS256。千万不要用none算法,那等于没有签名,任何人都能伪造Token。
六、前端安全存储的补充措施
即使使用了HttpOnly Cookie,前端也不能完全高枕无忧。还需要做以下几件事:第一,所有API请求必须走HTTPS,防止中间人攻击截获Token;第二,设置合理的CSP(Content Security Policy)头,限制页面只能加载可信来源的脚本,降低XSS风险;第三,对用户输入做严格的过滤和转义,从源头减少注入攻击的可能性;第四,前端框架(如React、Vue)要及时更新版本,避免已知的安全漏洞。
七、总结:一套完整的JWT安全方案
把上面所有要点串起来,一套生产级别的JWT安全方案应该是这样的:Access Token用HttpOnly + Secure + SameSite=Strict的Cookie存储,有效期15分钟;Refresh Token同样用HttpOnly Cookie存储,有效期7天,支持轮换机制;JWT payload只放必要字段,包含jti唯一标识;签名算法用RS256;后端维护Token黑名单或版本号机制实现主动失效;全站HTTPS + CSP + 输入过滤作为基础防线。这套组合拳打下来,JWT的安全性就能达到一个相当高的水平。安全不是单点的事,是一个体系,每个环节都不能掉链子。
