做后端这几年,被问得最多的问题之一就是“接口怎么防别人乱调”。早期项目里我习惯用 Session,后来切到前后端分离架构,发现 Session 那套在跨域、多端、分布式环境下越来越别扭。JWT(JSON Web Token)就是在那个阶段进入我技术栈的——它本质上就是一张自带签名、自带过期时间的“通行证”,服务端不需要再记住每一个登录用户,只需要验证令牌本身是否合法。这篇内容围绕“用户认证与授权:使用 JWT 保护你的 API”这个主题,把从登录签发、中间件校验、续签机制到安全加固的完整链路讲清楚,适合正在做接口鉴权,或者想从 Session 切换到 Token 方案的后端开发阅读。文章里的代码以 Node.js + Express 为主,思路同样适用于 Python、Java、Go。
1. 为什么选 JWT:从 Session 到 Token 的演进
1.1 Session 模式的天花板
先聊聊我最早用的 Session 方案。用户登录成功后,服务端把用户信息存在内存或 Redis 里,生成一个 session_id,通过 Set-Cookie 种到浏览器。后续请求带上 Cookie,服务端拿 session_id 去查存储,查到就放行。单机小项目跑起来确实顺手,但有几个问题会随着项目变大逐渐暴露。
第一是存储压力。每个活跃用户都占一份服务端内存或 Redis 空间,用户量上来之后,光维护会话状态就得单独扩容。第二是跨域与多端适配。移动端 App 没有 Cookie 的概念,要手动维护 session_id 的传递,前后端分离部署时 Cookie 的跨域设置又是一堆坑。第三是分布式环境下的会话同步。请求被负载均衡到不同实例时,session 数据要么做黏性会话,要么做集中存储,每次都要额外配置。
我记得有个项目就是在这个阶段踩了坑。前端部署在 CDN,后端在多台云服务器上,用户第一次请求落在 A 机器,登录成功;下一次请求被路由到 B 机器,B 查不到 session,直接把用户踢回登录页。那段时间我每天都在跟“会话丢失”的工单搏斗,最后下定决心把鉴权方案从 Session 换成了 JWT。
1.2 Token 方案解决了什么
Token 方案的核心思路是“无状态”。服务端不再保存会话,而是把用户身份信息放进一个经过签名的 Token 里发给客户端,客户端每次请求时带上它。因为 Token 自带签名,服务端只需要校验签名和过期时间,就能确认“这个 Token 确实是我签发的,内容没有被篡改”。
这样一来,存储压力消失了——服务端不需要为登录用户维护任何状态。分布式环境也变简单了——任何一台实例都能独立校验 Token,不需要共享会话存储。多端适配更不用愁——Token 可以放在 Authorization 头里,App、浏览器、小程序通吃。
我做过一个粗略的对比,同样一万个在线用户,Session 方案在 Redis 里至少存一万个 key,还要考虑过期策略和内存回收;JWT 方案 Redis 里一个 key 都不需要存(除非你要做黑名单),每台服务器各校验各的,谁也不依赖谁。这个差异在微服务场景下更明显:每个服务拿到 Token 自己验签,不需要再回调认证中心,调用链路上的延迟和故障点都少了。
1.3 JWT 与其他 Token 方案的差异
Token 这个概念很宽泛,Opaque Token(不透明令牌)也是 Token。它就是一个随机字符串,存在服务端数据库里,客户端拿它去换用户信息,本质上还是 Session 的变体。JWT 的不同在于它是自描述的——用户 ID、角色、过期时间都写在 Token 里,验签通过就能直接用,不需要再查一次数据库。
自描述的代价是 Token 一旦签发,在过期前无法主动失效(除非额外做黑名单),所以 JWT 更适合短期有效的场景。我个人的选型经验是:单点登录、多端共享、微服务内部鉴权,JWT 是好选择;如果是纯内部系统的长会话管理,Opaque Token 加存续库反而更可控。这个后面讲续签和安全加固时会再展开。
2. JWT 的核心原理:三段式结构与签名机制
2.1 Header、Payload、Signature 到底存了什么
JWT 长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用点号分成三段。第一段是 Header,Base64Url 编码的 JSON,声明签名算法和令牌类型,常见的算法标识是 HS256、RS256、ES256。第二段是 Payload,就是业务声明,比如用户 ID、用户名、角色、签发时间 iat、过期时间 exp,也可以放自定义的 claims。第三段是 Signature,由前两段拼接后,用密钥或私钥签名得到。
要特别注意,Header 和 Payload 只是 Base64Url 编码,不是加密,任何人拿到 Token 都能解出来看。这意味着千万不要把密码、手机号、身份证号这类敏感数据直接放进 Payload。我在代码评审时见过不少把密码明文放 Token 里的操作,每次看到都要苦口婆心解释半天“编码不等于加密”。
记住一句话:JWT 里只能放“不介意被别人看到”的数据,任何敏感信息都必须排除在外。
2.2 HS256 与 RS256 怎么选
算法选择是 JWT 里最大的一个分水岭。HS256 是对称签名,签发和校验用的是同一个 secret,实现简单,性能好,但 secret 一旦泄露,攻击者可以自己签发任意 Token,直接伪造任意用户。RS256 是非对称签名,私钥签发、公钥校验,私钥保存在认证服务,其他服务只需要持有公钥就能验签,安全性更高,适合微服务架构。
小项目或者单体应用,HS256 完全够用,关键是 secret 要够长够随机,放到环境变量里隔离,不要提交到代码仓库。多服务架构里,我强烈建议用 RS256:认证中心持有私钥,其他业务服务拿到公钥后可以独立验签,不需要把签发密钥分发给所有服务——HS256 方案里,任何一个服务泄露 secret,整条信任链就崩了。
实际选型还可以考虑 ES256。它基于椭圆曲线,密钥更短、性能更好,但库的生态相对不如 RSA 成熟,踩坑时资料也少。我自己在要求合规性的项目里还是优先 RS256,图的是稳。
2.3 签名校验的数学逻辑(通俗版)
签名校验可以类比成“防伪封条”。HS256 的封条是“用同一个印章盖上去的,只要用同一枚印章比对就能判断真假”;RS256 的封条是“用私章盖的,但任何人拿对应的公章都能验”。
具体到校验过程:服务端把收到的 Header 和 Payload 拼接,用自己持有的密钥(HS256)或公钥(RS256)重新计算签名,再跟 Token 里的 Signature 做比对。一致就说明 Token 在签发后没有被改动过,不一致直接拒绝。这里有个关键点:校验时要用标准的 JWT 库,不要自己实现签名和比对,因为自己写很容易漏掉关键校验项,比如算法混淆攻击就是在这一步翻车的。这个我放到“常见问题”章节专门讲。
3. 实操:登录接口与 Token 签发全流程
3.1 项目结构与依赖准备
我用 Node.js + Express 来写,这是最典型的 JWT 落地场景。先初始化项目并安装依赖:
npm init -y npm install express jsonwebtoken npm install --save-dev nodemonjsonwebtoken 是 Node 生态里最主流的 JWT 库,API 稳定,文档齐全。如果你用 Python,对应的是 PyJWT;Java 可以用 jjwt;Go 用 github.com/golang-jwt/jwt。库的 API 虽然略有差异,但核心概念完全一致。
下面是一个最小化的目录结构:
├── app.js ├── config.js ├── middleware │ └── auth.js ├── routes │ ├── auth.js │ └── users.js └── utils └── token.js3.2 登录校验与 Token 生成
先看 config,密钥从环境变量读取,绝不硬编码:
// config.js module.exports = { jwtSecret: process.env.JWT_SECRET || 'please-change-me-to-a-long-random-string', jwtExpiresIn: '2h', jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'another-long-random-string', jwtRefreshExpiresIn: '7d' };生产环境务必把 JWT_SECRET 设置成一个至少 32 字节的随机字符串,可以用
openssl rand -base64 48生成,然后通过环境变量注入,不要把默认值带到线上。
再写登录路由。这里假设用户表用简单的数组模拟,实际项目替换成数据库查询即可:
// routes/auth.js const express = require('express'); const jwt = require('jsonwebtoken'); const config = require('../config'); const router = express.Router(); // 模拟用户数据,实际项目请替换为数据库查询 const users = [ { id: 1, username: 'admin', password: '123456', role: 'admin' }, { id: 2, username: 'alice', password: '123456', role: 'user' } ]; router.post('/login', (req, res) => { const { username, password } = req.body; const user = users.find(u => u.username === username && u.password === password); if (!user) { return res.status(401).json({ message: '用户名或密码错误' }); } const payload = { sub: user.id, username: user.username, role: user.role }; const token = jwt.sign(payload, config.jwtSecret, { algorithm: 'HS256', expiresIn: config.jwtExpiresIn }); const refreshToken = jwt.sign({ sub: user.id }, config.jwtRefreshSecret, { algorithm: 'HS256', expiresIn: config.jwtRefreshExpiresIn }); res.json({ token, refreshToken, expiresIn: 7200 }); }); module.exports = router;签发 Token 时我习惯把 sub 设为用户 ID,这是 JWT 规范里定义的主题字段,避免各家自定义字段命名混乱。role 放进 Token 是为了后续做权限判断,不用每次查数据库。过期时间 2 小时是常见配置,但要看业务场景,后面细讲。
补充一个接口规范问题:登录接口要做限流。登录接口天然暴露在公网,是暴力破解的重灾区。建议对单个 IP 和单个账号分别做频率限制,比如同一个 IP 一分钟最多 10 次登录尝试,连续失败 5 次锁定 15 分钟。没有限流的登录接口,无论 Token 方案多安全,都等于把大门敞开。
3.3 Refresh Token 与续签机制
Access Token 的过期时间不能设太长,否则泄露后的风险窗口太大。但过期时间太短,用户就得频繁登录,体验很差。Refresh Token 就是来解决这个矛盾的:Access Token 短效,Refresh Token 长效,Access Token 过期后用 Refresh Token 换一个新的。
续签接口实现如下:
// routes/auth.js router.post('/refresh', (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { return res.status(401).json({ message: '缺少refreshToken' }); } try { const decoded = jwt.verify(refreshToken, config.jwtRefreshSecret); // 这里可以查一下数据库,确认用户仍然有效、refreshToken没有被吊销 const user = users.find(u => u.id === decoded.sub); if (!user) { return res.status(401).json({ message: '用户不存在' }); } const payload = { sub: user.id, username: user.username, role: user.role }; const token = jwt.sign(payload, config.jwtSecret, { algorithm: 'HS256', expiresIn: config.jwtExpiresIn }); res.json({ token }); } catch (err) { return res.status(401).json({ message: 'refreshToken无效或已过期' }); } });关于续签,这里有几个容易踩的坑。第一个是 Refresh Token 要不要轮换——我建议每次刷新都签发一个新的 Refresh Token,旧的立即作废,这样即使 Refresh Token 泄露,泄露的令牌也很快失效。第二个是 Refresh Token 的存储,移动端放安全存储(iOS Keychain、Android Keystore),Web 端放 HttpOnly Cookie,不要把 Refresh Token 塞进 localStorage,XSS 一打就全没了。第三个是刷新频率的控制,可以记录 Refresh Token 的使用次数,异常高频刷新要考虑是不是被盗用了。
还有一个思路是滑动过期(Sliding Expiration),只要用户一直在活跃,就自动续期,类似“活跃用户永远不过期”的效果。实现方式是每次校验时检查剩余过期时间,如果低于某个阈值就签发新 Token。这个方案适合对体验要求高、对安全要求不那么极端的内部系统。
不要忽略“刷新接口要不要也做鉴权”这个问题。很多团队的刷新接口完全裸奔,只要拿到一个 Refresh Token 就能无限续期。建议在刷新时校验设备的指纹信息,比如把设备 ID、UA 等信息跟 Refresh Token 绑定,发现设备不一致直接拒绝,并要求重新登录。
3.4 登出与 Token 失效策略
JWT 的“无状态”属性在登出这里是个老大难。服务端没有会话,怎么让一个还没过期的 Token 立即失效?常用的方案有几种。
黑名单是最直接的方式:登出时把 Token 的 jti(JWT ID,签发时生成的唯一标识)或 sub 加进 Redis,设置 TTL 等于剩余过期时间。中间件校验时先查黑名单,命中就拒绝。这个方案保留了无状态的大部分好处,只是多了一次 Redis 查询。
更简单的方案是缩短 Access Token 的过期时间。把过期时间压到 15 分钟甚至更短,登出后就算 Token 还在客户端手里,很快也会自然过期。牺牲一点体验,换来实现成本的大幅降低。
我实际项目里最常用的是组合拳:Access Token 设 15 分钟短效期,Refresh Token 在服务端数据库留一条记录用来吊销。用户登出时,把 Refresh Token 记录标记为失效;Access Token 不主动拉黑,靠短过期时间兜底。这个方案实现代码不多,安全性和体验平衡得比较好。
这里要提一个容易忽略的细节:登出接口本身需要鉴权。如果登出接口不校验 Token,攻击者可以拿着任意一个 Token 强制“登出”其他用户,制造不断的踢下线效果。登出接口建议使用 POST 方法,并且把 Token 放在 Authorization 头里提交,服务端先验签再执行吊销。
4. 服务端校验:中间件拦截与路由保护
4.1 中间件校验流程
有了 Token,接下来就是每个受保护接口的校验。Express 中间件是最自然的位置,因为它可以在路由处理函数执行前统一拦截。先看核心实现:
// middleware/auth.js const jwt = require('jsonwebtoken'); const config = require('../config'); module.exports = function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ message: '未提供有效的Authorization头' }); } const token = authHeader.split(' ')[1]; try { // 校验签名、过期时间、issuer、audience一次搞定 const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }); req.user = decoded; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ message: 'Token已过期', code: 'TOKEN_EXPIRED' }); } if (err.name === 'JsonWebTokenError') { return res.status(401).json({ message: 'Token无效', code: 'TOKEN_INVALID' }); } return res.status(401).json({ message: '认证失败' }); } };这里有两个细节必须强调。第一是algorithms: ['HS256']这个选项,它限定了只接受 HS256 签名的 Token。如果省略这个参数,jsonwebtoken 库在较老版本里会根据 Header 里的 alg 字段自动选择算法,攻击者可以把 alg 改成 none 或者 RS256 来绕过校验,这就是著名的算法混淆攻击。现在新版本库的默认行为已经变了,但显式声明仍然是最稳妥的做法,也是我在安全评审里必查的一项。
第二是错误类型的区分。TokenExpiredError 和 JsonWebTokenError 要分开处理,前端才能区分“该续签”和“该重新登录”两种情况。很多团队偷懒统一返回 401,结果前端逻辑混乱,用户动不动被踢下线。我见过一个前端同学因为分不清这两种情况,硬是写了个“401 就跳登录页”的逻辑,导致每次 Token 过期用户就会被强制重新登录,体验极差。
4.2 权限控制(角色与 Scope)
认证解决“你是谁”的问题,授权解决“你能干什么”的问题。JWT 里放了 role 字段之后,可以在中间件基础上再做一层角色校验:
// middleware/requireRole.js module.exports = function requireRole(...roles) { return function(req, res, next) { if (!req.user) { return res.status(401).json({ message: '未认证' }); } if (!roles.includes(req.user.role)) { return res.status(403).json({ message: '没有权限访问' }); } next(); }; };使用方式很直观:
// 只有管理员能调用 router.delete('/users/:id', authMiddleware, requireRole('admin'), (req, res) => { // 删除逻辑 }); // 登录用户都能调用 router.get('/profile', authMiddleware, (req, res) => { res.json({ profile: req.user }); });这里要区分 401 和 403 的语义:401 是“你没带 Token 或者 Token 无效”,403 是“你有 Token 但权限不够”。两个状态码用错的话,前端和后端的排查成本都会上升。我见过不少前端把 403 也当成 401 处理,结果用户明明有权限却被迫重新登录,这就是状态码语义没对齐造成的。
对于更细粒度的权限,可以在 Token 里放 scope 或 permissions 数组,比如['user:read', 'user:write'],接口层用 requireScope('user:write') 做检查。这种方式在开放平台 API、OAuth 场景里是标配,JWT 只是载体,权限模型独立设计。这里要提醒一点:scope 设计要遵循最小权限原则,不要图省事给所有客户端都发一个*全量权限,后面要收紧时才发现一堆服务都用到了。
4.3 白名单接口与公开路由
不是所有接口都需要保护。登录接口、注册接口、刷新 Token 接口、密码找回接口,这些天然就是公开的。如果每个路由都手动挂中间件,容易漏配,不如反过来设计:默认全部保护,显式声明公开路由。
// app.js const express = require('express'); const authRoutes = require('./routes/auth'); const userRoutes = require('./routes/users'); const authMiddleware = require('./middleware/auth'); const app = express(); // 先挂载公开路由 app.use('/api/auth', authRoutes); // 再统一挂载全局鉴权中间件 app.use('/api', authMiddleware); // 挂载受保护路由 app.use('/api/users', userRoutes);这样配置的好处是:新加的路由默认就是受保护的,忘记鉴权的情况不会发生;公开接口集中在 /auth 下面,审查起来一目了然。我在带团队时特别强调这个设计习惯,因为“漏加鉴权”是我见过最多的安全漏洞来源之一,比算法选型错误还要普遍。
公开路由要逐个确认,特别是那些挂在受保护路径之外的调试接口、健康检查接口。有人为了排查问题临时加一个 /debug 路由,忘了摘掉,上线后被扫出来就是一次典型的未授权访问事故。
“未授权访问”跟“目录遍历”经常被放在一起讨论,很多人分不清。未授权访问是指某个接口或资源本应该有权限保护,但因为配置遗漏或鉴权缺陷,任何人都能直接访问;目录遍历则是攻击者通过构造路径参数读取服务器上的任意文件,比如../../etc/passwd。前者是鉴权层的缺失,后者是输入校验层的缺陷,修复思路完全不同。做安全排查时,第一件事就是先把这两类问题分清楚。
5. 常见问题与排查技巧实录
5.1 过期时间与时钟偏移
“Token 明明没过期,为什么校验报错?”这个问题我回答过不下十次,九成是时钟偏移。JWT 的 exp 校验是拿服务器当前时间跟 Token 里的 exp 比对的,如果运行服务的机器时钟不准,或者客户端和服务器在不同时区,就会出现误判。
解决办法是在校验库的验证选项里设置 clockTolerance,允许一定秒数的容差。Node 的 jsonwebtoken 和 PyJWT 都支持这个参数,一般设 30 秒到 60 秒就够用,不要设太大,否则过期保护就形同虚设了。另外排查这类问题时先看服务器时间,跑一下date命令,确认 NTP 同步正常。我遇到过一台云服务器系统时间慢了五分钟,导致所有短效期的 Token 都提前报过期,最后发现是 NTP 服务挂了。
5.2 签名校验失败
签名校验失败,最常见的原因是密钥对不上。HS256 场景下,签发服务用的 secret 跟校验服务用的 secret 不一致,最常见于环境变量在不同环境里配置串了;RS256 场景下,校验方用了错误的公钥,或者公钥更新后旧的还没替换。
排查思路很清晰:先看是全部请求失败还是部分请求失败,全部失败基本是密钥配置问题,部分失败可能是某个客户端用了旧 Token,而服务端密钥轮换过了。密钥轮换时要在新旧密钥之间留过渡期,比如在校验逻辑里先试新密钥,失败再试旧密钥,等所有客户端都换上新 Token 后再把旧密钥彻底移除。
还有一种情况是 Token 被中间环节修改了,比如加解密网关对 Header 做了大小写转换,或者代理截断了超长 Header。这类问题要抓包对比原始 Token 和到达服务端的 Token,一眼就能看出来。遇到过最离谱的一次是日志平台把 Token 里的点号给转义了,排查了整整一个下午。
5.3 Token 泄露与重放攻击
Token 放 localStorage、URL 参数、日志里,是泄露的三大重灾区。URL 参数尤其隐蔽,一进 Nginx access log,Token 就留在服务器日志文件里了,日志如果同步到日志平台,等于把钥匙交了出去。所以 Token 必须走 Authorization 头,这是原则问题。
我见过太多团队为了调试方便,把 Token 拼在 URL 后面传给后端,比如GET /api/users?token=xxx。这种习惯一旦形成,Token 就会散落在浏览器历史、代理日志、服务端访问日志里,泄露面极大。正确做法是所有经过认证的请求统一从 Authorization 头取 Token,调试时用 Postman 或 Apifox 的 Authorization 鉴权功能,不要图省事。
重放攻击是指攻击者截获一个有效 Token,在它过期前重复使用。JWT 自身对这个问题的防御很弱,只能靠传输层保护——强制 HTTPS、不把 Token 放日志、不跨域共享。如果业务对防重放要求很高(比如支付接口),可以在 Token 里加一个随机数 nonce,服务端缓存已用 nonce,重复请求直接拒绝;或者用时间戳加时间窗口校验,超过窗口的请求一律丢弃。
5.4 常见问题速查表
| 症状 | 可能原因 | 处理建议 |
|---|---|---|
| 接口全部 401 | 密钥配置错误、算法不匹配 | 检查环境变量、检查中间件 algorithms 配置 |
| Token 未过期却报过期 | 服务器时钟偏移 | 同步 NTP,设置 clockTolerance |
| 移动端登录成功但请求失败 | Token 未正确放入 Header | 检查 Authorization 头格式,必须为 Bearer token |
| 用户被频繁踢下线 | Access Token 过期时间太短 | 配置 Refresh Token 续签机制 |
| 修改密码后旧 Token 仍有效 | JWT 无状态特性导致 | 记录 Token 版本号,密码变更时递增 version |
| 刷新 Token 泄露 | 存储位置不安全 | 改 HttpOnly Cookie 或安全存储,启用轮换 |
| 一个用户多端登录冲突 | 单点登录策略未设计 | 按业务选择允许并发或踢下线,用 Redis 记录会话版本 |
这张表是我这几年做接口鉴权答疑时整理出来的,基本覆盖了日常能遇到的大多数问题。排查的顺序建议是:先复现,再抓包,然后逐层检查 Header、密钥、算法、时间,不要上来就改代码。很多问题看起来是 JWT 本身的问题,查到最后往往是配置或者调用方式的问题。
6. 安全加固的几点实战心得
6.1 加固校验逻辑
除了前面讲的显式声明算法,还有几个校验点不要漏。一个是 iss(签发者)和 aud(受众),多环境部署时,每个环境的 Token 如果混用,用 iss 和 aud 就能挡住。另一个是 nbf(生效时间),防止提前签发的 Token 在设定时间前被使用。还有 jti,给每个 Token 一个唯一 ID,做黑名单和日志追踪都靠它。
Payload 里能不放的信息坚决不放。用户邮箱、手机号这些即使不敏感,也会让 Token 变大,每个请求都要带着跑,占用带宽。我见过最大的一颗 Token 有 8KB,塞了一堆业务字段,请求头都快撑爆了。Token 体积过大还有一个隐患:很多网关和代理对 Header 长度有限制,默认可能只有 8KB 或者更小,Token 一大直接请求就失败了。
还有一个容易被忽略的点:统一异常响应结构。鉴权中间件返回的错误信息要统一格式,比如{ code: 'TOKEN_EXPIRED', message: 'Token已过期' },前端才能做统一的拦截处理。很多项目的鉴权错误格式五花八门,有的是纯字符串,有的是 HTML 页面,前端对接起来苦不堪言。我在项目里会定义一个全局的错误处理中间件,把认证、授权、参数校验的错误全部归一化成统一结构,前端只需要处理这一种格式。
6.2 密钥管理与轮换
HS256 的 secret 和 RS256 的私钥,都是核心资产。secret 要独立于代码仓库,用环境变量或密钥管理服务(比如云上的 KMS、Vault)保存。轮换周期我建议按安全要求定,一般半年到一年一次。轮换时要保证旧 Token 的宽限:签发端换新密钥后,校验端要保留旧密钥验证一段时间,避免线上大量 Token 突然失效。
RS256 场景下,公钥的发布可以做一个公开的 JWKS 端点(/.well-known/jwks.json),业务服务定期拉取。这样公钥轮换时不需要在每个业务服务上手动改配置,JWKS 本身就是为这种场景设计的标准方案。
关于密钥管理,我再多啰嗦一句:不要把密钥写进前端代码,也不要写进客户端安装包。有些团队为了“离线登录”功能把 secret 直接编译进 App 里,这等于把保险柜钥匙贴在保险柜门上。移动端如果有离线需求,应该用非对称方案,客户端只持有公钥,私钥永远留在服务端。
6.3 JWT 漏洞的常见套路与防护
网络安全圈里“JWT 漏洞”是个高频词,常见的攻击套路就那么几类。第一类是把 alg 改成 none,老版本库如果没限制算法,校验直接跳过签名。第二类是算法混淆,把 RS256 的公钥当 HS256 的 secret 来伪造 Token,因为 HS256 验签用的“密钥”可以是任意字符串,而攻击者恰好有公钥。第三类是密钥爆破,secret 太短太简单,直接被字典攻击跑出来。
防护的核心就三条:库版本保持最新;校验时显式声明允许的算法列表;secret 强度达标。这三条做到,绝大多数的 JWT 攻击路径就堵死了。我一直建议团队把“算法白名单”写进代码规范,每次 Code Review 都要确认中间件里有没有漏掉 algorithms 参数。
还有一些细节容易被忽略。比如 Token 的过期时间不要设成“永久有效”,哪怕内部系统也不要。再比如不要信任 Token 里的任何字段来拼接 SQL 或文件路径,JWT 只是身份凭证,不是数据输入的白名单,该做的参数校验一步都不能少。最后,审计日志里记录 Token 的 jti 而不是完整 Token,既能追踪问题,又避免二次泄露。
做鉴权这几年,我的一个体会是:JWT 不是银弹,它只是把“信任”从服务端会话转移到了“可验证的令牌”上。它解决了 Session 在多端和分布式下的痛点,同时也把“Token 泄露”的风险转移给了传输层和客户端存储。所以真正决定一个系统安全下限的,往往不是选了什么方案,而是有没有把过期时间、密钥管理、算法白名单、日志脱敏这些细节做实。现在遇到“要不要上 JWT”的问题,我会先反问一句:你的 Token 过期时间定多久?密钥放在哪?这三个问题答不好,用什么都一样。希望这篇分享能帮你少踩几个我踩过的坑。