在分布式系统的浪潮中,JSON Web Token(JWT)凭借其无状态、跨域友好的特性,已成为现代应用认证的基石。然而,令牌的撤销难题与续签困境,也让无数开发者为之困扰。本文将深入剖析 JWT 的架构设计、核心优缺,并详细阐述如何通过双令牌机制实现既安全又“无感”的会话管理,助你在系统安全与用户体验之间找到最佳平衡点。
第一章:重新认识 JWT——不仅仅是另一种 Token
1.1 什么是 JWT?
JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间以 JSON 对象的形式安全地传输信息 。在身份认证领域,JWT 通常被用作访问令牌(Access Token),客户端在登录成功后获得此令牌,并在后续每次请求 API 时携带它,服务端通过验证令牌即可识别用户身份。
1.2 JWT 的结构解剖
一个 JWT 令牌看起来像是一串由点(.)分隔的三段式字符串,例如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
它由以下三部分构成 :
Header(头部)
位置:第一部分。
内容:通常包含两个部分:令牌的类型(
typ,通常是“JWT”)和使用的签名算法(alg,如 HMAC SHA256 或 RSA)。示例:
{"alg": "HS256", "typ": "JWT"},经 Base64Url 编码后形成第一部分。
Payload(载荷)
位置:第二部分。
内容:包含实际的声明(Claims)。声明是关于实体(通常是用户)和其他数据的陈述。分为三种类型:注册声明(如
iss签发者,exp过期时间,sub面向的用户)、公共声明和私有声明(自定义字段,如userId、role)。注意:Payload 仅仅是 Base64Url 编码,并不是加密的。这意味着任何人都可以解码并读取其内容,因此绝对禁止在 Payload 中存放密码、信用卡号等敏感信息。
示例:
{"sub": "1234567890", "name": "John Doe", "iat": 1516239022}。
Signature(签名)
位置:第三部分。
生成方式:需要将编码后的 Header、编码后的 Payload 以及一个密钥(Secret)组合,然后使用 Header 中指定的算法进行签名。例如,使用 HMAC SHA256 算法时,签名是这样创建的:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )作用:签名用于验证消息在传递过程中没有被篡改。对于使用私钥签名的令牌,它还可以验证 JWT 的发送者是否真实。
1.3 JWT 与 Token、Session 的本质区别
在深入讨论之前,有必要厘清几个易混淆的概念 :
Token(令牌):一个广义的概念,泛指用于身份认证的凭证。它可以是一个毫无意义的随机字符串(如
f8a3d7c0b1e9),也可以是一个包含信息的结构化字符串(如 JWT)。Session(会话):一种有状态的认证机制。服务器会为登录用户创建一个会话记录(通常存储在内存、数据库或 Redis 中),并返回一个会话 ID(通常存储在 Cookie 中)给客户端。后续请求携带此 ID,服务器通过查找服务器端的会话数据来识别用户。
JWT:一种无状态的、自包含的 Token 实现。它将用户信息编码在令牌本身,服务器无需存储会话数据,只需验证令牌的签名和有效期即可。
第二章:JWT 的核心优势——为什么它如此流行?
JWT 之所以在现代 Web 开发,特别是微服务和前后端分离架构中备受青睐,主要归功于以下四大特性 :
2.1 无状态与可扩展性
这是 JWT 最显著的优势。因为 JWT 自包含了用户信息,服务器不需要保存 Session 信息,这使得应用极易水平扩展。在处理高并发请求或部署多台服务器时,无需考虑复杂的 Session 共享机制(如粘性会话或集中式 Redis 存储)。任何一台服务器,只要持有正确的验证密钥,都能独立地验证任何请求的 JWT,大大减轻了服务端的存储压力,并提升了系统的伸缩性和可用性 。
2.2 有效避免 CSRF 攻击
跨站请求伪造(CSRF)是一种利用用户已通过身份认证的 Cookie,在用户不知情的情况下伪造请求的攻击方式。在基于 Session 的认证中,浏览器会自动携带 Cookie,因此容易受到 CSRF 攻击。
而在 JWT 的最佳实践中,令牌通常存储在客户端的localStorage或sessionStorage中,并通过Authorization: Bearer <token>头部手动发送 。由于浏览器不会自动在跨站请求中附加Authorization头,这使得 JWT 天然地对 CSRF 攻击具有免疫力 。
2.3 适合移动端应用
Session 认证依赖 Cookie,而移动端应用(如 iOS、Android)对 Cookie 的支持并不友好,且移动设备的网络连接可能不稳定,维护长期的会话状态会增加复杂性 。JWT 则完全无此问题,它只是一个普通的字符串,可以轻松地存储在移动设备的安全存储区(如 Keychain 或 Keystore),并在每次 API 调用时由代码添加到请求头中。
2.4 单点登录友好
单点登录的核心挑战在于如何在多个独立的系统间共享认证状态。基于 Session 的方案需要共享 Session 存储或处理 Cookie 的跨域问题,实现复杂。而 JWT 的自包含特性使其天生适合跨域认证 。一个系统签发的 JWT,只要签名算法和密钥被其他信任的系统所共享,就能被这些系统接受和验证,从而轻松实现单点登录。
第三章:JWT 的痛点与挑战——光环背后的阴影
尽管 JWT 优点突出,但它在实际应用中也面临着一系列棘手的问题,尤其是关于令牌控制权的问题 。
3.1 不可控的“幽灵”:注销与权限变更失效
这是 JWT 最常被诟病的缺点。由于服务器不保存 JWT 的状态,一个令牌一旦签发,在它到期之前将始终有效。这就导致了以下场景难以处理 :
用户退出登录:客户端虽然可以删除本地的 JWT,但服务端无法使已发出的令牌失效。如果攻击者在此之前已经窃取了该令牌,他仍然可以继续使用。
密码修改/账号封禁:用户修改密码后,理论上所有旧的会话都应失效。但在 JWT 的世界里,使用旧令牌发起的请求在令牌过期前依然会被认为是合法的。
权限变更:管理员将用户的角色从“管理员”降级为“普通用户”,但该用户持有的 JWT 中依然包含“管理员”角色声明,在令牌有效期内,他仍然可以行使管理员权限。
3.2 续签难题:如何让用户不频繁登录?
为了安全,JWT 的有效期通常设置得比较短(例如 30 分钟)。但这样会导致用户体验极差,用户需要每隔半小时就重新登录一次。如何在不牺牲安全的前提下,优雅地延长用户的登录状态,是一个必须解决的难题 。
3.3 令牌大小与传输开销
相比一个简单的 Session ID,JWT 由于包含了 Header、Payload 和签名,体积要大得多 。如果 Payload 中存放了过多的用户信息,这个体积会更加可观。在需要频繁传输、带宽受限的场景下(如移动网络),这可能会带来不容忽视的性能开销。
3.4 存储安全:XSS 攻击的隐患
为了防范 CSRF,我们通常将 JWT 存储在localStorage或sessionStorage中。然而,这引入了新的安全风险——跨站脚本攻击(XSS)。如果应用存在 XSS 漏洞,攻击者注入的恶意脚本可以轻松地从localStorage中读取 JWT,并将其发送到自己的服务器,从而完全劫持用户身份。
如果选择将 JWT 存储在HttpOnly的 Cookie 中以防范 XSS,又会重新面临 CSRF 的风险,并且在某些跨域场景下变得复杂 。
3.5 针对上述痛点的初步解决方案
在引入双令牌机制前,我们先看一下针对这些问题的常见应对思路 :
| 痛点 | 初步解决方案 | 优缺点 |
|---|---|---|
| 令牌无法撤销 | 1. 黑名单机制:在 Redis 等缓存中维护一个黑名单,将需要提前失效的 JWT 的jti(JWT ID) 加入黑名单。每次请求都检查令牌是否在黑名单中。 | 优点:能实现即时撤销。缺点:引入了状态,违背了 JWT 的无状态初衷,且增加了每次请求的查询开销。 |
| 2. 短期令牌:将 JWT 的有效期设置得非常短(如 5 分钟)。 | 优点:大大缩小了令牌被滥用的时间窗口。缺点:用户需要极其频繁地登录,体验极差。 | |
| 令牌续签问题 | 1. 快过期时自动续签:服务端在验证 JWT 时,如果发现它即将过期(如剩余时间不足 10 分钟),则生成一个新的 JWT 返回给客户端。 | 优点:实现相对简单。缺点:增加了服务端的逻辑,且客户端需要处理令牌更新的逻辑。 |
| 2. 每次请求都续签:每次请求都返回一个新的 JWT。 | 优点:能保持令牌始终新鲜。缺点:开销巨大,尤其是在高并发场景下。 | |
| XSS 攻击风险 | 输入输出过滤:对所有用户输入进行严格的过滤和转义,从根本上防止 XSS 攻击的发生。 | 优点:是防御 XSS 的根本手段。缺点:难以做到 100% 覆盖,一旦有疏漏则前功尽弃。 |
从上述解决方案可以看出,单一的策略往往顾此失彼。于是,一种更为成熟、平衡的方案应运而生——双令牌机制。
第四章:双令牌机制——实现无感知会话管理的银弹?
双令牌机制,或称“刷新令牌模式”,是目前业界解决 JWT 安全与体验矛盾的主流方案。它通过引入两个分工明确的令牌,在安全性、用户体验和服务器控制力之间取得了精妙的平衡。
4.1 什么是 Access Token 和 Refresh Token?
在双令牌机制中,不再只有一个 JWT,而是有两个 :
访问令牌(Access Token)
职责:用于访问受保护的 API 资源。
特性:短期有效,例如 15 分钟或 30 分钟。它遵循 JWT 的所有特性,自包含用户信息(如
userId、scope),服务端 API 只需验证其签名和有效期即可。传输方式:通常在客户端的每次请求中,通过
Authorization: Bearer <Access Token>头部发送。存储位置:可以在
localStorage或内存中。由于其有效期很短,即便被盗,危害的时间窗口也有限。
刷新令牌(Refresh Token)
职责:专门用于在 Access Token 过期后,向服务器请求新的 Access Token(和新的 Refresh Token)。
特性:长期有效,例如 7 天或 30 天。它通常不包含用户的具体信息,只是一个不透明的字符串,或者是一个包含极少信息(如
userId)的 JWT。传输方式:仅在与服务器特定的刷新端点(如
/refresh)通信时使用,绝不用于调用普通 API。存储位置:必须存储在更安全的地方。对于 Web 应用,最佳实践是存储在
HttpOnly、Secure、SameSite=Strict的 Cookie 中,以杜绝 XSS 攻击窃取它的可能性 。对于移动应用,则应存储在系统提供的安全密钥链中。
4.2 双令牌工作流程详解
双令牌机制的核心思想是:用短期令牌保证安全,用长期令牌维持会话,并通过定期轮换将风险降到最低 。
流程说明:
用户登录:客户端携带用户名/密码请求登录接口。
签发双令牌:服务器验证凭证成功后,生成一对令牌:一个短期的 Access Token 和一个长期的 Refresh Token,并将其返回给客户端。同时,服务器存储这个 Refresh Token(或其哈希值),并与用户 ID、设备信息等关联 。
请求资源:客户端使用 Access Token 调用普通业务 API。
返回数据:服务端验证 Access Token 有效,处理请求并返回数据。
令牌过期:当 Access Token 过期后,客户端再次携带它请求 API。
返回 401:服务端验证发现 Access Token 过期,返回
401 Unauthorized状态码,提示客户端需要刷新。刷新令牌:客户端捕获到 401 错误(且非登录失效),携带 Refresh Token 向专门的刷新接口(如
/refresh)发起请求。验证与轮换:服务器验证 Refresh Token 的有效性(签名、过期时间、是否在存储中且未被撤销)。验证通过后,服务器执行令牌轮换(Token Rotation):立即使此次使用的 Refresh Token 失效,并生成一个新的 Access Token 和一个全新的 Refresh Token。
返回新令牌:服务器将新的令牌对返回给客户端。
重试请求:客户端使用新的 Access Token,重新发起第 5 步中被拒绝的 API 请求。
返回资源:服务端验证新 Access Token 有效,处理请求并返回数据。
4.3 为什么双令牌机制能解决核心问题?
解决安全问题:
Access Token 泄露:由于其有效期极短(15-30 分钟),攻击者可以利用的时间窗口非常有限。
Refresh Token 泄露:Refresh Token 被存储在
HttpOnlyCookie 中,XSS 攻击无法读取。即使因极端情况泄露,由于服务端实现了令牌轮换和重复使用检测,一旦攻击者使用它刷新,原合法用户的下一次刷新请求就会因为令牌已被使用而失败,服务端可以据此判断令牌泄露并立即撤销该用户的所有会话,将损失降到最低 。
解决用户体验问题:
整个刷新过程在后台静默进行,用户完全感知不到 Access Token 的过期。只要用户定期(在 Refresh Token 有效期内)使用应用,会话就能被无限期延续,无需反复登录 。
解决令牌撤销问题:
虽然无法直接撤销 Access Token,但通过撤销 Refresh Token,我们就能从根本上切断会话的延续能力。当用户登出、修改密码或管理员封禁账号时,服务器只需删除或标记与对应用户关联的 Refresh Token 无效。这样,即使攻击者持有旧的 Access Token,它也会很快过期;而一旦 Access Token 过期,由于 Refresh Token 已失效,攻击者也无法再获取新的 Access Token 。
第五章:实战指南——如何设计和实现双令牌机制
理论终究要服务于实践。本章将结合代码示例,详细讲解如何在前端和后端实现一套安全、健壮的双令牌机制。
5.1 后端实现核心要点
我们以 Node.js (NestJS) 为例,展示后端的关键实现逻辑。其他语言和框架(如 Spring Boot, ASP.NET Core, Go)的思路完全一致 。
1. 生成令牌对(登录接口)
typescript
// 登录接口示例 async login(userDto: UserDto) { // 1. 验证用户名密码... (省略) const user = { id: 1, username: 'john' }; // 2. 生成 Access Token (短期,例如 15分钟) const accessToken = this.jwtService.sign( { sub: user.id, username: user.username, type: 'access' }, { expiresIn: '15m' } ); // 3. 生成 Refresh Token (长期,例如 7天) const refreshToken = this.jwtService.sign( { sub: user.id, type: 'refresh' }, // Refresh Token 通常不携带过多信息 { expiresIn: '7d' } ); // 4. 将 Refresh Token 的哈希值存储在数据库中,关联用户ID和设备信息 // 这里为了简化,演示了存储逻辑,实际应存储哈希值。 await this.redisClient.set(`refresh:${refreshToken}`, user.id, 'EX', 7 * 24 * 60 * 60); // 5. 返回令牌,Refresh Token 应通过 HttpOnly Cookie 发送 // 这里先返回,在 Controller 层设置 Cookie return { accessToken, refreshToken }; }2. 刷新令牌接口(核心逻辑)
typescript
@Post('refresh') async refresh(@Req() req: Request) { // 1. 从 HttpOnly Cookie 中获取 Refresh Token const oldRefreshToken = req.cookies['refreshToken']; if (!oldRefreshToken) { throw new UnauthorizedException('Refresh token not found'); } try { // 2. 验证 Refresh Token 的签名和有效性 const payload = this.jwtService.verify(oldRefreshToken); // 3. 检查服务端是否存储了此 Refresh Token(防止令牌已被撤销) const userId = await this.redisClient.get(`refresh:${oldRefreshToken}`); if (!userId || userId !== payload.sub) { throw new UnauthorizedException('Invalid refresh token'); } // 4. 【核心:令牌轮换】立即删除旧的 Refresh Token await this.redisClient.del(`refresh:${oldRefreshToken}`); // 5. 【可选:重复使用检测】如果删除失败或已不存在,说明可能被重复使用,应告警并撤销该用户所有会话 // 为了简化,此处省略复杂逻辑,但在生产环境至关重要。 // 6. 生成新的令牌对 const newAccessToken = this.jwtService.sign( { sub: payload.sub, type: 'access' }, { expiresIn: '15m' } ); const newRefreshToken = this.jwtService.sign( { sub: payload.sub, type: 'refresh' }, { expiresIn: '7d' } ); // 7. 存储新的 Refresh Token await this.redisClient.set(`refresh:${newRefreshToken}`, payload.sub, 'EX', 7 * 24 * 60 * 60); // 8. 返回新的令牌,新的 Refresh Token 通过 Cookie 下发 return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } catch (e) { // 验证失败(如过期、签名错误),清除 Cookie 并报错 throw new UnauthorizedException('Refresh token expired or invalid'); } }3. 登出接口
typescript
@Post('logout') async logout(@Req() req: Request) { const refreshToken = req.cookies['refreshToken']; if (refreshToken) { // 从存储中删除 Refresh Token await this.redisClient.del(`refresh:${refreshToken}`); } // 清除客户端的 Refresh Token Cookie // 返回成功,客户端自行删除 Access Token return { success: true }; }5.2 前端实现核心要点
前端(以 Axios 为例)需要封装请求拦截器和响应拦截器,以实现自动附加令牌和静默刷新 。
javascript
// axios 实例 const axiosInstance = axios.create({ baseURL: 'https://api.example.com', withCredentials: true, // 关键:允许携带 HttpOnly Cookie (用于发送 Refresh Token) }); // 请求拦截器:附加 Access Token axiosInstance.interceptors.request.use((config) => { const accessToken = localStorage.getItem('accessToken'); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // 响应拦截器:处理令牌过期和刷新 axiosInstance.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // 如果是 401 错误,且不是刷新接口本身的请求,且尚未重试过 if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/refresh') { originalRequest._retry = true; // 标记已重试,防止死循环 try { // 发起刷新请求(注意:此请求会自动携带 HttpOnly Cookie 中的 Refresh Token) // 需要后端刷新接口支持从 Cookie 读取 Refresh Token const response = await axiosInstance.post('/refresh'); const newAccessToken = response.data.accessToken; // 新的 Refresh Token 会通过 Set-Cookie 自动更新,前端无需处理 // 更新本地存储的 Access Token localStorage.setItem('accessToken', newAccessToken); // 更新原请求的 Authorization 头 originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; // 重新发起原请求 return axiosInstance(originalRequest); } catch (refreshError) { // 刷新失败(Refresh Token 也过期或无效),跳转到登录页 localStorage.removeItem('accessToken'); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } );5.3 最佳实践与安全配置
使用不同的签名密钥:Access Token 和 Refresh Token 应使用不同的签名密钥,以增加攻击者猜解的难度。
存储 Refresh Token 的哈希值:在服务器端,不要直接存储 Refresh Token 明文,而是存储其哈希值(如 SHA-256)。这样即使数据库泄露,攻击者也无法直接使用 Refresh Token。
绑定设备/IP 信息:在签发 Refresh Token 时,可以将客户端 IP、User-Agent 等信息作为声明写入,或在服务端存储时与之关联。刷新时进行校验,若发现 IP 或设备发生剧烈变化,可以要求用户重新登录 。
实施令牌轮换和重复使用检测:这是双令牌机制安全的精髓。每当 Refresh Token 被使用时,必须立即轮换。如果检测到一个 Refresh Token 被多次使用,应立即判定为攻击行为,并撤销该用户所有的 Refresh Token,强制其在所有设备上重新登录 。
设置合理的过期时间:
Access Token:15分钟到2小时。
Refresh Token:7天到30天,具体取决于应用对“保持登录”状态的期望。
使用 HTTPS:这已是不言而喻的前提。所有令牌的传输都必须基于 HTTPS,防止中间人攻击。
第六章:架构决策——何时选择 JWT + 双令牌?
任何技术方案都不是万能的,JWT + 双令牌机制也有其最适合的应用场景。在做技术选型时,我们需要根据具体的业务需求和安全要求来权衡。
6.1 双令牌机制的优势总结
最佳的用户体验:实现了真正的“无感刷新”,用户只需登录一次,即可长期使用 。
高安全性:通过短期令牌限制泄露危害,通过长期令牌的存储和轮换机制,提供了强大的控制和泄露检测能力。
良好的扩展性:API 服务依然保持无状态,可以轻松水平扩展。只有负责签发和管理 Refresh Token 的认证服务需要持有状态(存储 Refresh Token),这通常是一个专门的、独立的服务。
精细的会话控制:能够实现单设备登出、全设备登出等高级会话管理功能。
6.2 架构决策指南
以下表格可以帮助你根据项目特点,选择最合适的认证方案 :
| 决策维度 | 推荐方案:JWT + 双令牌 | 推荐方案:传统 Session (Cookie) | 推荐方案:简单 JWT (单令牌) |
|---|---|---|---|
| 应用架构 | 微服务、分布式系统、前后端分离 (SPA)、移动 App | 单体应用、服务端渲染的传统 Web 应用 | 内部 API、对安全性要求不高的原型或工具 |
| 用户体验要求 | 要求长时间“保持登录”,且登录频率越低越好 | 可以接受会话超时后重新登录 | 可以接受短时间登录或频繁登录 |
| 安全要求 | 极高。需要防范 XSS/CSRF,并能及时撤销权限变更、强制登出 | 较高。对即时撤销要求高,需防范 CSRF | 较低。安全由短期令牌保证,但无法主动撤销 |
| 会话控制粒度 | 需要精细控制,如“登出所有设备”、“踢人下线” | 很容易实现精细控制 | 很难实现主动控制 |
| 服务端复杂性 | 中等偏高。需要管理 Refresh Token 的存储和轮换逻辑,但业务 API 无状态 | 中等。需要管理 Session 存储(如 Redis),且需要考虑共享问题 | 低。业务 API 完全无状态 |
| 典型场景 | 主流互联网应用、SaaS 平台、移动 App 后端 | 企业内部系统、CMS、银行类对安全极其敏感的 Web 应用 | 临时性的服务间调用、简单的 API 密钥替代品 |
6.3 结论
JWT + 双令牌机制并不是为了取代 Session,而是为了解决在分布式、跨平台环境下,无状态认证与有状态会话控制之间的矛盾。它通过巧妙的职责分离,既让大部分业务 API 保持了无状态的简洁与高效,又通过一个专门的有状态服务(管理 Refresh Token)来获得对用户会话的控制权。
如果你的应用是面向互联网用户的、需要良好扩展性的现代 Web 或移动应用,那么 JWT + 双令牌机制无疑是当前最成熟、最平衡的解决方案。它让你可以在享受 JWT 带来的便利的同时,不必被其核心痛点所束缚,从而构建出既安全又友好的应用体验。