去年接手一个前后端分离的SPA项目,登录态从Session改造为JWT,踩了一路坑,也把很多网上说得含糊的概念彻底捋清楚了。这篇就把我对JWT的理解、实际落地过程、还有那些容易被忽略的安全问题一次性串起来讲,适合正在做登录鉴权、准备从Session迁移过来、或者被JWT续签和安全问题卡住的朋友。
先说一个核心判断:JWT本身不复杂,复杂的是它的使用边界。很多人把JWT当成加密工具,其实它是签名工具;很多人以为用了JWT就天然安全,其实安全漏洞几乎都出在实现方式上。这篇文章会从原理出发,落到真实代码和坑点,尽量让新手能看懂,让有经验的人也能有收获。
1. 从SPA项目登录态说起:为什么最终选了JWT
1.1 原来用Session,为什么不行了
传统单体Web应用里,登录状态一般靠Session维护:用户登录成功后,服务器在内存或Redis里存一份sessionId对应的用户数据,同时把sessionId写进Cookie。浏览器每次请求自动带上Cookie,服务器比对一下就知道你是谁。这套方案逻辑简单,在小项目里很顺手。
但前后端分离的SPA项目出现后,问题就来了。前端是独立部署的静态资源,API是独立部署的后端服务,两者不但在不同端口,甚至经常在不同域名。此时要处理CORS、跨域Cookie的SameSite属性、CSRF防护,维护成本直线上升。更麻烦的是,如果后端要横向扩容,部署了多个实例,Session默认存在单机内存里就废了——第一台机器存的登录态,第二台机器不认。要么引入Redis做Session共享,要么就得让负载均衡做粘滞会话,两种方案都是额外的基础设施和运维成本。
我当时的场景是:后端有几个服务需要统一做登录鉴权,前端是Vue的SPA,部署在CDN上。用Session的话,跨域Cookie方案要配置很多,而且每个服务都要对Session存储做依赖。评估了一圈,决定换成JWT,把登录态直接交给客户端保存。
1.2 JWT解决了什么,付出了什么代价
JWT(JSON Web Token)在这一场景下的核心优势是无状态。服务器不保存任何会话数据,登录成功后签发一个自包含的Token给前端,前端后续请求把Token放在HTTP Header里带过来,服务器只要验签通过,就信任Token里携带的用户信息。因为Token本身带着用户ID、角色、过期时间这些信息,所以后端不需要查库,天然适合分布式的多个服务共享一套鉴权逻辑。
代价也很明确,主要有三个。第一,无法主动失效。Session可以随时在服务端删除,JWT一旦签发,在过期之前无论如何都无法让它作废,除非额外引入黑名单机制,那就又变成有状态了。第二,体积相对膨胀。Cookie几个字节能解决的事,JWT一个Token动辄几百字节,放在每个请求的Header里会轻微增加带宽消耗。第三,敏感信息不能直接塞进Payload。Payload只是Base64Url编码,不是加密,任何拿到Token的人都能直接解码看到内容。
这三个代价不是用来劝退的,而是提醒你:JWT适合什么场景,不适合什么场景。适合的是无状态、分布式、Token只包含非敏感信息、允许短时有效的场景;不适合的是一次签发想永久有效的场景,或者需要精细控制单点下线的场景。
2. JWT三段式结构拆解:Header、Payload、Signature到底在签什么
2.1 拆开一个真实的Token
一个JWT长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDEsInVzZXJuYW1lIjoicGFuZWwiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTk5OTQ4MDB9.Kc5kDIQj2zYrnf6Yq8GdYNaFz2LRLOR9pZ7b6Mxsbf0中间用两个点分成三段。用最简单的办法解码看内容——在命令行里执行:
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | base64 -d会得到:
{"alg":"HS256","typ":"JWT"}这就是Header,声明了签名算法和Token类型。
第二段Payload也一样解码:
echo "eyJ1aWQiOjEwMDEsInVzZXJuYW1lIjoicGFuZWwiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MTk5OTQ4MDB9" | base64 -d得到:
{"uid":1001,"username":"panel","role":"admin","exp":1719994800}这就是载荷部分,承载了业务需要的用户信息,以及JWT标准里规定的一些注册声明。第三段是签名,它长得像乱码,却是整个Token安全性的根基。
2.2 签名到底保护了什么
很多人第一次看到Base64解码出来的明文,会惊讶:这不是裸奔吗?信息全都能看到。没错,能看到。但JWT本来就不是加密,它是签名。
签名的作用是保证完整性:Token在传输过程中,任何一段内容被改动过,签名校验就会失败。以HS256算法为例,签名计算规则是:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )就是把前两段拼接起来,用双方共享的密钥做一次HMAC运算,把运算结果作为第三段。服务器收到Token后,用同样的规则重新计算签名,再和Token携带的签名做比对。一致则说明内容没被动过;不一致,直接拒绝。
你可能会问:那客户端能不能自己伪造一个Payload、然后用自己猜的密钥签名?理论上如果密钥足够复杂,猜不中就签不出来。密钥一共两种,要么是服务器和签发方共用一个对称密钥(HS256),要么是一对公私钥(RS256/ES256)。用非对称算法时,服务器只保存公钥验签,签发用私钥在单独的认证服务里完成,这样更安全——即使某个后端服务的公钥泄露,也无法伪造Token。
2.3 那些JWT规范里的注册声明
除了业务自定义字段,JWT规范定义了一些可选的注册声明,实际使用中建议尽量带上:
exp:过期时间,Unix时间戳形式,验签时必须校验这个字段,网上很多教程的坑就是只验签没过期时间。iat:签发时间。nbf:生效时间,早于这个时间点的Token应该被拒绝。aud:受众,说明Token给谁用,防止一个Token到处通用。iss:签发者,标识Token是哪家签发的。jti:唯一ID,主要用于防重放或者黑名单记录。
我在代码里最少会带exp、iat、iss、aud四个,后面在中间件里校验iss和aud。很多项目里漏掉这两个校验,如果Token被一个服务签发给另一个服务用,一旦签发密钥泄露,攻击范围会被放大很多。
3. 登录到鉴权的完整实现链路:签发、校验、续签
3.1 登录接口:验证码通过后才签发Token
这是热词里提到的场景:SPA项目结合验证码实现JWT登录。流程是这样的:
- 前端加载登录页,请求后端获取一个图形验证码,后端生成图片并把验证码答案存到Redis,给前端返回一个
captchaId。 - 用户输入账号密码和验证码,提交登录。
- 后端先校验验证码:用
captchaId去Redis查答案,比对不通过直接返回"验证码错误",通过则删除该验证码(验证码必须一次性)。 - 验证码通过后,继续校验账号密码(密码存储必须是加盐哈希,比如bcrypt,禁止明文)。
- 校验全部通过后,查询用户角色、状态等信息,生成JWT返回。
对应Node.js实现大致是这样:
const jwt = require('jsonwebtoken'); const crypto = require('crypto'); // 登录接口 async function login(req, res) { const { username, password, captchaId, captchaCode } = req.body; // 1. 校验验证码,一次性使用 const savedCode = await redis.get(`captcha:${captchaId}`); if (!savedCode || savedCode.toLowerCase() !== captchaCode.toLowerCase()) { return res.status(400).json({ code: 400, message: '验证码错误' }); } await redis.del(`captcha:${captchaId}`); // 2. 校验用户名密码 const user = await db.user.findByUsername(username); if (!user || !(await bcrypt.compare(password, user.passwordHash))) { return res.status(401).json({ code: 401, message: '用户名或密码错误' }); } // 3. 更新登录信息,签发Token await db.user.updateLoginInfo(user.id, { lastLoginAt: new Date(), lastLoginIp: req.ip, }); const token = jwt.sign( { uid: user.id, username: user.username, role: user.role, }, process.env.JWT_SECRET, { expiresIn: '2h', issuer: 'my-app', audience: 'my-app-web', } ); res.json({ code: 0, data: { token, user: { uid: user.id, username: user.username } } }); }这里有一个细节:签发前更新用户登录信息,对应了"更新用户登录信息并生成返回JWT令牌"这个场景。数据库操作和JWT签发在同一次请求里完成,所以即使登录信息更新失败了也能及时发现,不会出现Token已经发出去但登录记录没写上的不一致状态。
3.2 鉴权中间件:一次验签要做的事比你想的多
每个需要登录才能访问的接口,都会经过同一个鉴权中间件。我的实现里做了这几件事:
- 从请求头取
Authorization: Bearer <token>,没有就直接401。 - 拆出Token后调用
jwt.verify验签。 - 验签时要同时校验
issuer、audience,并且库本身会校验exp。 - 验签通过后,把Token里的用户信息挂到
req.user,后续业务直接用。
function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '未登录或令牌缺失' }); } const token = authHeader.slice(7); try { const payload = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'my-app', audience: 'my-app-web', }); req.user = payload; next(); } catch (err) { // 区分过期和无效,方便前端做对应处理 if (err.name === 'TokenExpiredError') { return res.status(401).json({ code: 401, subCode: 'TOKEN_EXPIRED', message: '登录已过期' }); } return res.status(401).json({ code: 401, subCode: 'TOKEN_INVALID', message: '令牌无效' }); } }如果你不想依赖第三方库,也可以用Node内置的crypto模块自己写验签,完整实现HS256验签不超过40行:
const crypto = require('crypto'); function base64UrlDecode(str) { return Buffer.from(str, 'base64url').toString('utf8'); } function verifyHs256(token, secret) { const [headerB64, payloadB64, signatureB64] = token.split('.'); const data = `${headerB64}.${payloadB64}`; const expected = crypto.createHmac('sha256', secret).update(data).digest('base64url'); // 防止时序攻击 const actual = signatureB64; const a = Buffer.from(expected); const b = Buffer.from(actual); if (a.length !== b.length) return null; if (!crypto.timingSafeEqual(a, b)) return null; return JSON.parse(base64UrlDecode(payloadB64)); }自己实现一遍不是为了替代成熟库,而是为了彻底理解签名机制。等你亲手拼过一遍HMAC,再看网上的JWT漏洞分析就轻松多了。
3.3 前端如何安全地保存和使用Token
前端拿到Token后,存哪里是个经典话题。localStorage和sessionStorage的优点是不会被请求自动携带,但XSS攻击一旦得手就能直接读走Token;Cookie虽然可以加HttpOnly防止JS读取,但容易踩CSRF的坑,而且跨域时要处理SameSite和CORS。
我的选择是:纯前端应用存localStorage,然后在请求拦截器里手动加到Authorization头。风险点在于必须做好XSS防护——不信任任何用户输入拼接DOM、对富文本做白名单过滤、依赖第三方库时留意供应链安全。如果项目对安全要求极高,可以把Token放进HttpOnly Cookie,同时用自定义Header配合CSRF Token防护,这是更重但更稳的方案。
一个实用的小建议:后端在登录接口返回Token的同时,可以顺带返回一个expiresAt时间戳,前端在这个时间前几分钟主动引导用户"续期"或重新登录,体验会比等请求401再跳登录页好很多。
4. 我踩过的JWT安全坑:算法混淆、KID注入和弱密钥
4.1 算法混淆攻击:把RS256降级成HS256
这是JWT实现里非常经典的一类漏洞。原理是这样的:如果服务器签发Token用RS256(非对称),验签时公钥是公开的;攻击者把Token的Header从RS256改成HS256,然后用公开的公钥内容当作HS256的对称密钥来签名。如果服务器验签时没有固定算法,而是读取Header里的alg字段,就会傻乎乎地用HS256和公钥去验签——结果攻击者自己签名的Token就通过了。
这类漏洞的历史教训太多,修复方式也很简单:验签时不要相信Header里声明的算法。在jsonwebtoken库中,验签时显式传入algorithms参数白名单:
jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ['RS256'], issuer: 'my-app', audience: 'my-app-web', });这样即使攻击者把Header改成alg: none或者alg: HS256,验签也会失败。我建议所有人的JWT验签代码里都必须有这个白名单,无论用什么语言什么库。
4.2 KID注入:一个参数从验签失败变成命令执行
kid是JWT Header里的一个可选字段,全称Key ID,用来告诉服务器用哪把密钥验签。不少实现会这样写:
key = open(f"/keys/{kid}", "rb").read() decoded = jwt.decode(token, key, algorithms=["HS256"])如果kid完全来自用户输入且没有过滤,攻击者把kid设置成../../../../etc/passwd,服务器就会尝试读取任意文件内容当作密钥。更进一步的利用手法是把kid指向一个攻击者控制的URL,或者利用某些库对文件路径的特殊处理实现代码执行。在关系型数据库中,还有把KID拼接进SQL查询导致SQL注入的经典案例。
正确的做法是:kid只允许在白名单内取值,比如一个有限集合{key1, key2},查不到直接拒绝。永远不要用用户输入拼文件路径或数据库查询。
4.3 弱密钥爆破:你的secret可能秒破
HS256的安全性完全依赖密钥。很多人图省事,把密钥设置成secret、123456、jwt-secret这种,等于把门锁挂在门把手上。网上有一些公开的JWT密钥字典,攻击者用一个几百MB的字典跑一遍,几分钟就能爆破出密钥,然后任意伪造Token。
我在压测自己的项目时验证过:如果密钥是secret这类弱口令,用字典爆破确实秒破。换成程序生成的32字节随机数之后,爆破就完全不可行了。关于密钥管理,我的建议是:
- 使用
crypto.randomBytes(32).toString('hex')生成至少256位的随机密钥。 - 不同环境用不同密钥,开发、测试、生产分开。
- 密钥通过环境变量或配置中心下发,不要硬编码进代码仓库。
- 定期轮换密钥。轮换时要考虑新旧Token的兼容——我采用的方式是验签时尝试多个密钥,旧的用于宽限期内的老Token。
4.4 签名校验缺失:手动实现时最容易犯的错
有些项目没引入JWT库,自己写了校验逻辑,常见错误是只解Base64、只查过期时间、不验证签名。这种情况下攻击者完全不需要密钥,自己改Payload再重新Base64编码就能拿到任意身份的Token。
这也是我前面强调自己实现一遍没问题、但生产环境一定要用成熟库的原因。成熟库经过大量安全审计,边界情况处理得完整。如果你真的需要手写,记住签名验证和过期时间校验是且必须是第一步,不是可选项。
顺便提醒一个容易踩的坑:Base64Url和标准Base64是不同的编码方式。JWT用的是Base64Url,把+和/替换成-和_,去掉末尾的=。用标准Base64库解JWT,遇到特殊字符会出错或得到错误结果。加上timingSafeEqual做签名比对也是必备操作,普通等值比较存在时序侧信道风险,虽然实际利用难度大,但技术圈对这件事早就有了共识。
5. 令牌续签:从固定过期到Refresh Token的取舍
5.1 固定过期时间为什么让用户恼火
如果Access Token有效期只有2小时,用户用着用着突然401,被迫重新输入账号密码,体验很差。最粗暴的方案是设置超长有效期,比如7天甚至30天——但Token泄露的风险窗口会成倍拉长,一旦前端XSS泄露了Token,攻击者可以持续数天冒用身份,这在安全上不可接受。
所以续签方案的核心目标很简单:让登录态能平滑延续,同时把泄露风险控制在更短的时间窗口内。
5.2 方案一:滑动续签
滑动续签的逻辑不复杂:在每次请求的鉴权中间件里,验签成功后检查Token剩余的有效时间。如果剩余时间低于某个阈值(比如总有效期的四分之一),就签一个新的Token返回给前端,前端下次请求自动带上新的。
// 在鉴权中间件中实现滑动续签 const payload = jwt.verify(token, secret, { issuer: 'my-app', audience: 'my-app-web', algorithms: ['HS256'], }); const THRESHOLD = 15 * 60; // 剩余不足15分钟则续签 const remaining = payload.exp - Math.floor(Date.now() / 1000); if (remaining < THRESHOLD) { const newToken = jwt.sign( { uid: payload.uid, username: payload.username, role: payload.role }, secret, { expiresIn: '2h', issuer: 'my-app', audience: 'my-app-web' } ); res.setHeader('X-New-Token', newToken); } req.user = payload; next();前端在响应拦截器里检测到X-New-Token就更新本地存储,实现了用户无感知的续签。
这个方案的优点是实现简单,不引入额外的状态存储;缺点是每次生成新Token都意味着原Token在理论上仍然有效,如果后端服务不止一个,需要确保所有服务共享同一个密钥,并且新Token能被所有服务识别。另外,频繁续签会产生大量Token扩散在客户端日志或者代理缓存里,风险面会变大。
5.3 方案二:Refresh Token双Token机制
更规范的方案是引入Refresh Token,这个Token有效期长(比如7天),专门用来换取新的Access Token(有效期短,比如15分钟到2小时),并且Refresh Token通常存储在更安全的位置,使用时有更严格的校验。
我落地时的设计规格是这样的:
| 项目 | Access Token | Refresh Token |
|---|---|---|
| 有效期 | 15分钟~2小时 | 7天 |
| 存储位置 | 前端内存/localStorage | 前端内存/HttpOnly Cookie |
| 使用场景 | 访问业务API | 调用刷新接口换取新Access Token |
| 是否可撤销 | 设计上不可撤销 | 可在服务端记录并主动失效 |
刷新接口的逻辑:
// 刷新接口 async function refresh(req, res) { const { refreshToken } = req.body; // 校验Refresh Token,检查是否存在/已撤销 const stored = await redis.get(`refresh:${req.user.uid}`); if (!stored || stored !== refreshToken) { return res.status(401).json({ code: 401, message: 'Refresh Token无效或已过期' }); } const newAccessToken = jwt.sign( { uid: req.user.uid, username: req.user.username, role: req.user.role }, process.env.JWT_SECRET, { expiresIn: '30m', issuer: 'my-app', audience: 'my-app-web' } ); res.json({ code: 0, data: { accessToken: newAccessToken } }); }Refresh Token有一个显著的现实问题:接口本身需要被合法调用。如果Refresh Token也放在localStorage,XSS依旧能偷走;如果放在HttpOnly Cookie,跨域和CSRF问题又回来了。所以在SPA项目里,Refresh Token往往也是存在localStorage的,只是用它换Access Token的频率低,暴露面稍微小一些。真正的强安全场景会引入refresh token轮换、设备绑定、异常检测等复杂策略。
5.4 我的最终选择
那个项目最终采用的是滑动续签,原因是团队规模小、服务数量有限,双Token机制的基础设施收益还没那么明显。如果今天做一个用户量大、安全要求高的产品,我会直接上Refresh Token方案,并且把Refresh Token存进HttpOnly Cookie,刷新接口用独立域名,同时做设备管理和异常登录提醒。这两种方案没有绝对优劣,认清业务阶段比技术选型本身更重要。
6. 一些值得长期坚持的实现习惯
把项目做完之后,我复盘整理了几条习惯,后来每个涉及JWT的项目都在沿用:
第一,Payload只放非敏感的用户标识和角色信息。身份证号、手机号、钱包余额这些一律不进Token,需要时拿uid去查库。
第二,验签统一封装,不要每个接口各自写一套。我在项目里把鉴权中间件做成了一层公共依赖,所有服务共用同一份配置,杜绝"某个服务忘配algorithms白名单"这种不一致问题。
第三,密钥轮换要演练。平时配置好两个密钥,旧密钥保留一个过渡期,新密钥先上线,等所有服务都切换到新密钥后,再在宽限期结束后移除旧密钥。听起来简单,但没演练过的团队真到轮换时往往手忙脚乱。
第四,做好401的规范返回。给前端明确的错误码,区分Token缺失、Token过期、Token无效。前端才知道什么时候该弹登录框,什么时候该静默刷新,什么时候属于异常情况需要上报。
第五,不要为了"无状态"而无状态。业务里需要强制下线用户(比如改密码、封号、踢人)时,最有效的方案是维护一个用户级别的"会话版本号",签进Token里,鉴权时对比当前版本。版本号变了说明用户被强制下线,老Token全部失效。这是无状态和可撤销之间一个很好的折中。
在实际开发和维护的过程中,我最大的体会是:JWT不是一个"配置完就万事大吉"的东西,它更像一把钥匙,设计得合理,整个登录体系会很顺手;设计得随意,后续每一个安全补丁都在为当初的省事买单。希望这篇内容能帮你少踩一些我已经踩过的坑,也让你在选择JWT时心里更有底。