Skip to content

JWT 登录流程和原理(accessToken + refreshToken 双 token 机制)

难度等级:🟡 中级(2-4年)
分类:工程化
归档日期:2026-09-02


📚 理论知识

一、核心概念:JWT 是什么

JWT 全称 JSON Web Token,是一种无状态的身份令牌标准(RFC 7519)。

  • 无状态怎么理解:传统 session 方案里,服务端要在内存或 Redis 里保存一份"谁登录了、登录信息是什么"的记录,每次请求来了都要拿着 sessionId 去查这份记录。而 JWT 方案下,服务端不保存任何会话状态,用户是谁、有什么权限,全部由客户端每次请求时"自己带来"(带在 token 里),服务端只需要验证这个 token 是不是自己签发的、没被改过就行,不用查表——这就是"无状态"。
  • 它的本质是一段**自包含(self-contained)**的字符串:token 自己就带全了验证所需要的信息——用户身份信息(payload)+ 防篡改的签名(signature)——服务端拿到它,只用密钥重新算一遍签名做个比对,不需要再去数据库查"这个用户到底是谁",token 自己就是答案。

二、JWT 的结构:三段式

一个 JWT 由 . 分隔的三段 Base64URL 编码字符串组成:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsInVzZXJuYW1lIjoiYWxpY2UiLCJpYXQiOjE2OTk5OTk5OTksImV4cCI6MTcwMDAwNzE5OX0.dGhpc2lzYXNpZ25hdHVyZQ
  1. Header(头部):声明算法和类型,如 { "alg": "HS256", "typ": "JWT" }
  2. Payload(载荷):真正的数据,也叫 claims,如 { "userId": 1001, "iat": ..., "exp": ... }
  3. Signature(签名)HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)secretKey 只有后端知道

关键细节:Header 和 Payload 只是 Base64 编码,不是加密,任何人拿到 token 都能解码看到明文内容(可以去 jwt.io 粘贴看看)。所以 payload 里绝对不能放密码、身份证号这类敏感信息。真正防篡改的是第三段签名——只要有人改了 payload 里哪怕一个字符,重新计算出来的签名就对不上,后端一验证签名就知道 token 被动过手脚了。

验证过程不是简单比较字符串:后端收到 token 后,把 header 和 payload 原样取出来,用自己保存的密钥重新跑一遍和签发时完全一样的 HMAC 算法,算出一个新签名,再拿它跟 token 自带的第三段签名比较——一致说明没被改过,因为攻击者没有密钥,改了 payload 后根本算不出匹配的签名。安全性不在"比较"这一步本身,而在于"没有密钥就无法伪造出正确的签名"。


三、为什么要"双 token"而不是一个长效 token

这是这道题最容易被追问的点,本质是安全性和用户体验的权衡(trade-off)

  • 只用一个长期 token(比如 7 天有效):一旦泄露,攻击者可以在 7 天内为所欲为,后端还没法轻易让它失效(JWT 无状态特性)。
  • 只用一个短期 token(比如 2 小时):用户体验很差,2 小时就要重新登录一次。
  • 双 token 方案:accessToken 短命(如 2 小时),即使泄露,窗口期短、损失可控;refreshToken 长命(如 7 天)但只用于换新 accessToken 这一件事,严格存 httpOnly Cookie(JS 读不到,防 XSS 窃取),配合后端状态管理(见下文"无状态的代价"),兼顾安全和体验。

四、后端怎么签发 accessToken 和 refreshToken

javascript
// 伪代码,Node.js + jsonwebtoken 库
const jwt = require('jsonwebtoken');

// accessToken:短期,payload 信息可以多一点,方便前端直接用
const accessToken = jwt.sign(
  { userId: user.id, username: user.username, role: user.role },
  ACCESS_TOKEN_SECRET,
  { expiresIn: '2h' }
);

// refreshToken:长期,payload 通常只放 userId,功能单一——只用来换新的 accessToken
const refreshToken = jwt.sign(
  { userId: user.id, tokenVersion: user.tokenVersion },
  REFRESH_TOKEN_SECRET,  // 通常用另一个密钥,跟 accessToken 的密钥不一样,防止互相伪造
  { expiresIn: '7d' }
);

// refreshToken 额外存一份到 Redis,原因见下文"无状态的代价"
await redis.set(`refresh:${user.id}`, refreshToken, 'EX', 7 * 24 * 3600);

localStoragehttpOnly Cookie
JS 能否读取能,localStorage.getItem() 直接读不能,document.cookie 读不到(这正是 httpOnly 的含义)
主要风险XSS:页面被注入恶意脚本,脚本直接把 token 读出来发走CSRF:Cookie 会被浏览器自动带上,第三方网站诱导用户点击时,请求会"顺带"带上这个 Cookie
是否自动携带不会,需要手动在请求头里加会,浏览器自动带上
适合放什么accessToken:有效期短,需要每次请求手动塞进 AuthorizationrefreshToken:有效期长,只在刷新接口里用得到,放 httpOnly Cookie 直接屏蔽 XSS 窃取

简单说:accessToken 用得频繁、时效短,放 localStorage 图方便,泄露损失也可控;refreshToken 用得少、时效长,一旦泄露危害更大,尽量放 JS 摸不到的 httpOnly Cookie,代价是要单独处理 CSRF(配合 SameSite=Strict/Lax 或额外的 CSRF Token)。


六、完整登录 + 刷新流程

① 登录:用户名密码 → POST /login → 后端校验 → 返回 { accessToken, refreshToken }
② 存储:accessToken 存内存/localStorage;refreshToken 存 httpOnly Cookie(推荐)或 localStorage
③ 携带请求:每次请求 header 带 Authorization: Bearer <accessToken>
   后端中间件验证签名 + exp 是否过期 → 通过则放行,取出 payload.userId 用
④ accessToken 过期:后端返回 401
   → axios 响应拦截器捕获 401 → 用 refreshToken 请求 /refresh 接口
   → 后端验证 refreshToken 签名 + 对照 Redis 是否存在/一致
   → 验证通过:签发新 accessToken(有的还会顺便轮换 refreshToken,见后文)
   → 前端拿到新 accessToken,自动重发刚才失败的原请求,用户完全无感知
⑤ refreshToken 也过期/失效:/refresh 接口本身也返回 401
   → 前端清空登录态 → 跳转登录页,重新输入账号密码

几个标准细节:

  • Authorization: Bearer <accessToken> 是标准做法Authorization 是 HTTP 协议本身定义的标准请求头(RFC 7235)。Bearer 是其中一种"认证方案"(scheme),字面意思是"持有者"——谁拿着(bear)这个 token,谁就默认拥有对应权限,服务端不再额外验证"你是不是本人",只认 token 本身。
  • 401 的含义:HTTP 标准状态码,Unauthorized——凭证缺失或已失效,需要重新提供有效凭证。要和 403 Forbidden 区分:401 是"你没证明自己是谁/证明失效了",403 是"你已经证明了自己是谁,但没权限做这件事"。

七、无状态的代价:为什么还要靠后端存一份状态

这是贯穿这道题大部分追问的核心矛盾:JWT 的签名+exp 验证只能证明"这个 token 合法签发、没被篡改、没过期",但证明不了"服务端现在还认不认这个 token"——这后半句必须靠额外的后端存储(通常是 Redis)才能做到。几个具体场景说明为什么需要它:

  • 用户主动退出登录:accessToken 可能还有 1 小时才过期,不存后端记录的话,服务端没法让它立刻失效,只能等自然过期。
  • 管理员强制踢人下线:同理,需要一个地方能"主动撤销"一个还没过期的凭证。
  • 用户改密码,要求所有设备重新登录:给用户维护一个 tokenVersion,改密码时 +1,验证 refreshToken 时额外比对 payload 里的 tokenVersion 是否等于当前值,不等就拒绝——一次操作让所有旧 token 集体作废,不用逐个删。

以上三个场景,具体怎么落地,分两块展开:

7.1 退出登录怎么实现

用户点击"退出登录"


① 调用后端 /logout 接口(推荐,不是可选项)


② 后端:从 Redis 删除该用户的 refreshToken 记录(或加入黑名单)
   若 refreshToken 存 httpOnly Cookie,需在响应里用 Set-Cookie
   下发一个已过期的同名 Cookie,让浏览器自己清掉
   (前端 JS 读不到也删不掉 httpOnly Cookie,只能靠后端这一步)


③ 不管 /logout 接口成功与否,前端都要清空本地登录态:
   accessToken、refreshToken(如果存 localStorage)、
   store 里的用户信息/权限码/菜单树


④ 清理动态挂载的路由,避免换账号登录后菜单权限残留
   —— 最干净的做法是 logout 后直接整页跳转 location.href = '/login',
   路由实例和 store 全部重建


⑤ 多标签页同步(如果做了):清空 token 触发 storage 事件,
   其他标签页监听到后各自跳转登录页

为什么一定要调后端接口,前端清了 token 不就行了? 如果只清前端,token 本身依然是"签名有效、没过期"的合法凭证——万一之前已经被 XSS 窃取,攻击者手里那一份完全不受"清空前端"影响,还能在有效期内继续用。必须让后端也把它标记失效,才是真正的登出。

7.2 强制下线:accessToken 没过期时能不能立即让用户下线

不能——只要 accessToken 还没到 exp,服务端拿它没办法。因为 accessToken 的验证从来不查 Redis,只靠"重新计算签名 + 检查 exp"两步纯本地运算完成。所以管理员删掉 Redis 里的 refreshToken,只意味着这个人没法再换新的 accessToken,但剩余有效期内(比如还剩 1 小时 50 分钟)他依然能正常访问所有接口。

如果业务要求"立即"下线,本质上都是往"无状态验证"里加回一部分状态检查:

  • 缩短 accessToken 有效期:治标不治本,只是缩小"最坏延迟",没有真正做到立即生效。
  • accessToken 也做黑名单:每次验证时额外查一次 Redis,看这个 token(或它的 jti)是否被拉黑。能立即生效,但代价是把"验证不用查库"的无状态优势也搭进去了。
  • 用户级"强制失效时间戳"(实践中最常见、开销最小):给每个用户在 Redis 里维护一条 forceLogoutAt:{userId} 时间戳,而不是给每个 token 单独记录。验证 accessToken 时多一步:比较 payload 里的 iat(签发时间)是否早于这个时间戳,早于就拒绝。管理员点"强制下线"时只需要把这条时间戳更新成当前时间,不用管这个用户名下签发过多少个 token。
  • 配合 WebSocket/SSE 实时推送:管理员操作后主动推消息给用户当前打开的页面,前端收到立刻清状态跳登录页,体验上做到"秒级踢人",但只对当前在线连着 WS 的页面有效,通常和"强制失效时间戳"一起用——前者管接口层真安全,后者管前端体验的实时感。

八、刷新令牌轮换(Refresh Token Rotation,进阶点)

更安全的做法是:每次用 refreshToken 换新 accessToken 时,顺便也签发一个新的 refreshToken,把旧的作废。这样即使 refreshToken 泄露,攻击者用一次之后,真正的用户下次刷新时会发现自己的 refreshToken 已经失效,从而能感知到被盗用,及时采取措施(强制登出所有设备)。


九、Redis 黑名单 / 白名单机制详解

前面反复提到的"后端存一份 token 状态",具体实现一般有两种思路:

  • 白名单模式(本文代码示例用的就是这种):Redis 里只存"当前有效"的 refreshToken,一个用户对应一条记录(refresh:{userId} → token 值),验证时要求"签名有效" + "Redis 里存的值跟传上来的一致"两个条件同时满足。逻辑直观,但每个用户默认只能有一个有效 refreshToken 在线(要支持多端登录,key 要设计成 refresh:{userId}:{deviceId} 维度)。
  • 黑名单模式:Redis 平时不存有效 token,只在"需要提前作废"的场景(退出登录、强制下线、刷新令牌轮换后旧 token 作废)才把这个 token(或它的唯一标识 jti)写进黑名单,验证时先看签名和 exp,再查一下是否在黑名单里。黑名单记录的过期时间跟 token 自身 exp 对齐,自动清理不会无限增长。天然支持多端同时在线、互不影响,代价是多一次查询开销,且要单独设计 jti 字段。

后台管理系统里白名单模式因为实现简单、够用,更常见;对多端登录、更细粒度控制有要求的系统会倾向用黑名单或"白名单 + 设备维度"的组合方案。


十、绕回来看:JWT 无状态到底强在哪,什么场景该用它

看到这里容易有个疑问:既然主动失效、强制下线、退出登录都得靠 Redis 兜底,那 JWT"无状态"的意义到底在哪?

答案是:JWT 的优势场景,恰恰是不需要精细化撤销单个凭证的地方——这时候完全不用碰 Redis,纯本地验证的优势才是纯收益:

  • 省掉一次网络 I/O:签名验证是纯本地 CPU 运算,不像查 session 那样要走一次网络往返。在超高并发、对延迟极度敏感的场景(比如内部微服务之间大量的服务间调用鉴权)这个差距会被放大。
  • 微服务架构下的服务间信任传递:请求经过网关 → 服务 A → 服务 B → 服务 C,各服务只要持有同一套验证密钥就能各自独立验证身份,不需要都去连一个中心化的 session 存储,是真正的去中心化验证。
  • 跨机房/边缘节点验证不用回源:部署在 CDN 边缘节点的鉴权逻辑离用户很近但离中心数据库很远,JWT 可以直接在边缘节点本地验证签名,不用跨地域回源查库。
  • 跨系统/跨组织边界的身份传递:第三方开放平台、OAuth 场景下,资源服务器和授权服务器往往不是同一团队维护,不可能共享一个 session 存储,JWT 作为自描述的通行证,对方拿到公钥就能独立验证。
  • 一次性、短生命周期场景:邮箱验证链接、密码重置链接,本来就不需要"随时撤销"的能力,用 JWT 把过期时间和防篡改能力编码进 token 本身,不需要单独建表管理状态,很轻量。

选型判断标准:核心是问"要不要精细化撤销单个凭证的能力"——不需要(内部微服务调用、跨系统鉴权、一次性 token、高并发低延迟优先)就用纯 JWT;需要(要能随时踢掉某台设备、需要"登录设备管理"这类功能)就该用有状态 session,接受多一次查询换取可控性(网页/多端应用如抖音、微信的多会话架构就是这个思路,见 [[multi-device-login]])。实际项目里的"JWT + Redis 存 refreshToken"就是一种折中:高频的 accessToken 验证路径保持无状态,只在低频的刷新/撤销路径上引入状态查询。


常见误区

  • 误以为 JWT 是加密的,把敏感信息塞进 payload —— ,payload 明文可见,只是签名防篡改,不是防查看。
  • 误以为用了 JWT 就完全不需要后端存储 —— 不完全对,纯无状态 JWT 没法主动失效,实际项目里几乎都会给 refreshToken(甚至 accessToken 黑名单)配合 Redis 做状态管理。
  • 混淆"过期"和"失效"——过期是 exp 字段到了时间点,签名验证时自动判定;失效是后端主动让一个还没到期的 token 作废(比如改密码后),这必须依赖后端存储,JWT 本身做不到。

❌ 错误答法示范

错误示例 1:「JWT 就是把用户信息加密后传给前端,前端每次带着这个加密数据请求,后端解密就知道是谁了。」
❌ 问题:JWT 的 header 和 payload 是 Base64 编码,不是加密(encryption),任何人都能解码看到内容。真正起防伪作用的是第三段签名的验证,不是"解密"这个动作。混淆编码和加密是这道题最常见的硬伤。

错误示例 2:「refreshToken 过期时间设置成永久不过期就行了,这样用户就不用总登录了。」
❌ 问题:refreshToken 永不过期意味着一旦泄露,攻击者拥有永久的登录能力,且完全依赖后端手动去黑名单里删除才能失效,是非常危险的设计。合理做法是设置一个相对长但有限的过期时间(如 7-30 天),并配合 Redis 存储实现主动失效能力。


🎤 模拟面试作答

嗯,这道题我来说一下我的理解。

我们项目里用的是双 token 机制,accessToken 加 refreshToken,核心是在安全性和用户体验之间做权衡:只用一个长期 token,泄露了损失就是长期的,而且 JWT 无状态很难主动失效;只用短期 token,用户又得频繁重新登录。所以拆成两个:accessToken 有效期短,比如两小时,泄露损失可控;refreshToken 有效期长,但只做一件事——换新的 accessToken,而且存 httpOnly Cookie,JS 读不到,防 XSS 窃取。

JWT 本身是三段式结构,header.payload.signature,前两段只是 Base64 编码不是加密,谁都能解码看到内容,所以不会把密码这类敏感信息放进去。真正防篡改的是签名,后端拿 header 和 payload 用密钥重新算一遍 HMAC,跟 token 自带的签名比对,攻击者没有密钥,改了内容也算不出匹配的签名。

这里有个很关键的点是:JWT 的验证只能证明"没被篡改、没过期",证明不了"服务端现在还认不认这个 token"。所以退出登录、强制下线这些场景,光删前端的 token 没用,必须在后端也存一份状态(一般是 Redis)——比如退出登录时把 Redis 里的 refreshToken 记录删掉,之后即使拿着没过期的 refreshToken 也换不了新的 accessToken 了。但要注意,就算 refreshToken 失效了,用户手里那个还没过期的 accessToken 依然能正常用到自然过期,因为 accessToken 验证从来不查 Redis——如果业务要求"立即"强制下线,通常做法是给每个用户维护一个强制失效时间戳,验证时比对 token 的签发时间是否早于这个时间戳。

那既然主动失效都要靠 Redis,JWT 无状态的意义在哪呢?意义在于不需要这种精细化撤销能力的场景——比如微服务之间大量的服务间调用鉴权,各服务拿同一套密钥就能各自独立验证,不用都连一个中心存储;或者跨系统、开放平台的场景,双方不可能共享 session 存储。所以实际项目里常见的做法是一种折中:高频的 accessToken 验证保持纯无状态,只在低频的刷新、撤销路径上引入 Redis 查询,两头的好处都占一点。

如果面试官感兴趣,我还可以聊聊刷新令牌轮换是怎么做的,或者聊聊多端同时在线的系统(比如抖音这类)为什么反而更倾向用有状态的 session 而不是 JWT。


❓ 面试官追问预测

追问 1:JWT 的签名到底是怎么防止篡改的?如果我拿到一个 token,把 payload 里的 userId 改了,会发生什么?

应对思路:签名是基于 header+payload 和只有后端知道的密钥算出来的,攻击者改了 payload 但没有密钥,算不出匹配的新签名,后端一验证就会失败返回 401。关键词:单向哈希、密钥不可逆、验证不通过。

追问 2:accessToken 存 localStorage 和存 httpOnly Cookie 各有什么安全风险?

应对思路:localStorage 能被 JS 读取,有 XSS 风险;httpOnly Cookie 读不到但会自动带上,有 CSRF 风险,需要配合 SameSite 属性或 CSRF Token 防护。没有绝对安全的方案,是权衡取舍。

追问 3:如果同时有多个请求触发了 401,会不会同时发起多次刷新请求?怎么解决?

应对思路:这是并发刷新问题,常见解法是加一个"是否正在刷新"的锁标记,第一个 401 触发真正的刷新请求,后续的 401 请求先挂起(用 Promise 队列缓存),等刷新结果回来后统一用新 token 重发。

追问 4:既然主动失效都要靠 Redis 兜底,JWT"无状态"的优势到底体现在哪,为什么不干脆全用 session?

应对思路:优势体现在不需要精细化撤销单个凭证的场景——省掉查库的网络 I/O、微服务间各自独立验证不用依赖中心存储、跨机房边缘节点验证不用回源、跨系统跨组织不方便共享 session 存储。选型标准就是问"要不要随时踢掉某个凭证",需要就上有状态 session,不需要就用纯 JWT 换性能和解耦。

追问 5:如果 accessToken 被盗了(比如被 XSS 窃取),怎么应对?

应对思路:因为 accessToken 有效期短,即使被盗,攻击者能用的时间窗口有限;再配合服务端做 IP/设备指纹校验、异常行为检测,发现异常直接让对应用户的 refreshToken 失效,必要时用"强制失效时间戳"机制让所有 accessToken 也立即作废。

追问 6:白名单模式下,Redis 只存一条 refreshToken 记录,怎么支持同一个用户在手机和电脑同时登录?

应对思路:把 Redis 的 key 从 refresh:{userId} 改成按设备维度区分,例如 refresh:{userId}:{deviceId},每个设备各自维护一条 refreshToken 记录,退出登录/踢下线时可以精确到某一个设备;也可以换成黑名单模式,天然不限制同时在线的 token 数量。

追问 7:用户点"退出登录",前端具体要做哪些事?只清 localStorage 里的 token 够不够?

应对思路:不够。要先调后端 /logout 接口让 Redis 里的 refreshToken 失效(否则被窃取的 token 在有效期内依然能用),再清空前端 token、reset store 里的用户信息和权限数据、清理动态挂载的路由(一般整页跳转最干净),如果做了多标签页同步还要处理 storage 事件。httpOnly Cookie 前端读不到也删不掉,必须靠后端下发过期 Cookie 覆盖。

追问 8:管理员在后台把某个用户强制下线,如果他的 accessToken 还没过期,是不是没法让他立即下线?

应对思路:是的,这是无状态验证的必然代价——accessToken 校验只做签名+exp 的本地运算,不查 Redis。要做到立即下线,得在验证路径里加回状态检查,比如给每个用户维护一条 forceLogoutAt 时间戳,验证时比对 token 的 iat 是否早于这个时间戳,再配合 WebSocket 推送做到前端秒级感知。

个人面试题归档,持续更新中