JWT 的本质、误区与正确落地:一套不依赖任何框架的架构方法论
很多前端同学写了一辈子代码,依然对 JWT 有三个灵魂拷问:
① 它到底解决了什么问题?
② 和 Session 的核心区别到底是什么?
③ 过期刷新到底该怎么设计才算是"无感知"?
本文不写任何前端代码,不讲任何框架,只讲本质和方法论。
无论你是 Java、Go、Python、Node.js,还是移动端、小程序、Web,这套思路完全通用。

附:微信小程序完整实战案例
在开始正文之前,先看一个微信小程序的完整封装。这份代码按照本文的核心思想实现:用三个变量控制并发、用数组存储等待请求、刷新成功后遍历重试。每个方法都有完整注释。
javascript
// api.js - 微信小程序统一API封装
// ============================================================
// 第一步:定义三个全局状态变量
// ============================================================
let isRefreshing = false; // 标记"现在是否正在刷新Token"
let pendingQueue = []; // 等待队列(数组),存的是"待重试的请求函数"
let refreshPromise = null; // 存当前刷新操作的Promise,防止重复刷新
const BASE_URL = 'https://api.example.com/v1';
// ============================================================
// 第二步:判断Token是否过期(和后端约定好)
// ============================================================
function isTokenExpired(response) {
// 根据你的后端返回格式来写,下面是常见写法
if (response.statusCode === 401) return true;
if (response.data && response.data.code === 401) return true;
if (response.data && response.data.message &&
response.data.message.includes('token expired')) return true;
return false;
}
// ============================================================
// 第三步:核心请求方法(所有请求都走这个方法)
// ============================================================
function request(url, method, data) {
return new Promise((resolve, reject) => {
// 从本地存储取出Token
const token = wx.getStorageSync('token') || '';
wx.request({
url: BASE_URL + url,
method: method,
data: data,
header: {
'Content-Type': 'application/json',
'Authorization': token ? 'Bearer ' + token : ''
},
success: function(res) {
// 如果Token过期,走刷新逻辑
if (isTokenExpired(res)) {
handleTokenExpired(url, method, data, resolve, reject);
return;
}
resolve(res.data);
},
fail: function(err) {
// 网络错误也可能伴随401
if (err && err.statusCode === 401) {
handleTokenExpired(url, method, data, resolve, reject);
return;
}
reject('网络异常');
}
});
});
}
// ============================================================
// 第四步:处理Token过期的核心逻辑(最关键的实现)
// ============================================================
function handleTokenExpired(url, method, data, resolve, reject) {
// 情况1:如果已经在刷新中,把当前请求放进队列等待
if (isRefreshing) {
// 这里存的就是"重试函数"------等新Token拿到后,用新Token重发当前请求
pendingQueue.push(function(newToken) {
retryRequest(url, method, data, newToken, resolve, reject);
});
return;
}
// 情况2:如果没有在刷新,当前请求负责触发刷新
isRefreshing = true;
const oldToken = wx.getStorageSync('token') || '';
// 如果还没有刷新Promise,就创建一个
if (!refreshPromise) {
refreshPromise = doRefreshToken(oldToken);
}
refreshPromise
.then(function(newToken) {
// 刷新成功:保存新Token
wx.setStorageSync('token', newToken);
// 重置状态
isRefreshing = false;
refreshPromise = null;
// 执行队列里所有等待的请求(用新Token重试)
while (pendingQueue.length > 0) {
const retryFn = pendingQueue.shift(); // 从队列头部取出一个函数
retryFn(newToken); // 执行它,传入新Token
}
// 当前请求也重试
retryRequest(url, method, data, newToken, resolve, reject);
})
.catch(function(err) {
// 刷新失败:清除Token,跳转登录
isRefreshing = false;
refreshPromise = null;
pendingQueue = []; // 清空队列,这些请求没必要重试了
wx.removeStorageSync('token');
wx.showToast({ title: '登录已过期,请重新登录', icon: 'none' });
wx.navigateTo({ url: '/pages/login/login' });
reject('登录已过期');
});
}
// ============================================================
// 第五步:用新Token重试请求
// ============================================================
function retryRequest(url, method, data, newToken, resolve, reject) {
wx.request({
url: BASE_URL + url,
method: method,
data: data,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + newToken
},
success: function(res) {
// 极端情况:用新Token还是过期,递归再处理一次
if (isTokenExpired(res)) {
handleTokenExpired(url, method, data, resolve, reject);
return;
}
resolve(res.data);
},
fail: function(err) {
if (err && err.statusCode === 401) {
handleTokenExpired(url, method, data, resolve, reject);
return;
}
reject('重试请求失败');
}
});
}
// ============================================================
// 第六步:调用刷新接口
// ============================================================
function doRefreshToken(oldToken) {
return new Promise(function(resolve, reject) {
wx.request({
url: BASE_URL + '/auth/refresh',
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + oldToken
},
success: function(res) {
if (res.data && res.data.code === 0 && res.data.data && res.data.data.token) {
resolve(res.data.data.token);
} else {
reject(new Error(res.data.message || '刷新失败'));
}
},
fail: function() {
reject(new Error('网络异常'));
}
});
});
}
// ============================================================
// 第七步:导出给页面使用
// ============================================================
export {
request,
get,
post,
put,
del
}
// 封装四个基础方法
function get(url, data) {
return request(url, 'GET', data);
}
function post(url, data) {
return request(url, 'POST', data);
}
function put(url, data) {
return request(url, 'PUT', data);
}
function del(url, data) {
return request(url, 'DELETE', data);
}
页面调用示例:
javascript
// pages/index/index.js
import { get, post } from '../../utils/api.js';
// 调用方式非常简单,不需要关心Token刷新逻辑
get('/users', { page: 1 })
.then(res => {
console.log('用户列表', res);
})
.catch(err => {
console.log('请求失败', err);
});
post('/orders', { product_id: 123, count: 2 })
.then(res => {
console.log('下单成功', res);
});
这段代码的核心思想就三句话:
- 第一个遇到401的请求触发刷新 (设置
isRefreshing = true) - 后面遇到401的请求进数组等待 (把重试函数push到
pendingQueue) - 刷新成功后遍历数组全部重试(从队列里取出函数依次执行)
一、JWT 是什么?它到底解决了什么问题?
1.1 定义
JWT(JSON Web Token)是一种紧凑且自包含的令牌格式,用于在各方之间安全传输声明信息。
它由三部分组成:
- Header:算法与类型
- Payload:声明数据
- Signature:签名(防篡改)
1.2 它解决的核心问题
问题的本质是:在无状态的 HTTP 协议中,服务端如何确认"你是谁"?
JWT 的答案是:服务端不再保存会话状态,把"身份信息 + 有效期"加密签名后交给客户端,客户端每次请求时带上,服务端只需验证签名和有效期即可。
1.3 它为什么被广泛接受?
- 无状态:服务端不存储 session,利于水平扩展
- 跨域友好:可放在 Header 中,不受 Cookie 域限制
- 自包含:可携带非敏感用户信息,减少 DB 查询
- 标准化:RFC 7519,所有语言都有成熟库
二、JWT vs Session:不是"谁好谁坏",而是"适用场景"不同
| 维度 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端内存 / Redis | 客户端(LocalStorage / Cookie / Memory) |
| 状态性 | 有状态 | 无状态 |
| 扩展性 | 需共享存储(如 Redis) | 天然水平扩展 |
| 吊销能力 | 强(删除 session 即可) | 弱(需额外黑名单机制) |
| 性能 | 每次请求查存储 | 验签即可,CPU 开销略高 |
| 适用场景 | 后台管理系统、高安全要求 | 开放 API、移动端、微服务间认证 |
核心结论
- 单体应用 + 强管控 → Session 更简单
- 分布式 / 微服务 / 多端 → JWT 更自然
- 不存在谁取代谁,只存在谁更合适
三、JWT 该放什么?不该放什么?
应该放的内容
sub(subject):用户 IDiat(issued at):签发时间exp(expiration):过期时间jti(JWT ID):唯一标识(用于黑名单)role/scope:权限范围(非敏感)tenant_id/shop_id:租户或业务上下文
绝对不应该放的内容
- 密码(即使是加密的)
- 身份证号、银行卡号
- 完整的用户手机号、邮箱(除非业务强制)
- 任何 GDPR / 个保法定义的敏感数据
设计原则
JWT 是"身份凭证",不是"数据载体"。它只放用于鉴权和路由决策的最小信息集。
四、前端 / 客户端如何存储 JWT?
| 存储方式 | 安全性 | XSS | CSRF | 适用端 |
|---|---|---|---|---|
| LocalStorage | 中 | 高(可被读取) | 无 | Web SPA |
| SessionStorage | 中 | 高 | 无 | Web |
| Cookie(HttpOnly) | 高 | 低 | 高(需 SameSite) | Web |
| Memory(变量) | 中 | 低 | 无 | 移动端 / 小程序 |
| Keychain / Keystore | 高 | 无 | 无 | 原生 App |
推荐策略
- Web 端:优先 HttpOnly Cookie(防 XSS),配合 SameSite=Strict 防 CSRF
- 移动端 / 小程序:存储在安全存储(如 iOS Keychain、Android Keystore、微信 Storage 加密)
- 不推荐:LocalStorage 存敏感 Token(易被 XSS 窃取)
五、最核心的问题:过期刷新如何做到"无感知"?
这是 JWT 落地的最大难点。
5.1 为什么不能只用一个 Token?
- 有效期太长 → 风险高
- 有效期太短 → 用户频繁登录体验差
正确方案:双 Token 机制
| Token | 作用 | 有效期 |
|---|---|---|
| Access Token | 日常请求鉴权 | 短(15分钟 ~ 2小时) |
| Refresh Token | 用于刷新 Access Token | 长(7天 ~ 30天) |
5.2 刷新流程
1. 客户端发起请求,携带 Access Token
2. 服务端验签 + 检查 exp
├── 未过期 → 正常返回
└── 已过期 → 返回特定状态码(如 401)
3. 客户端收到 401
├── 如果有 Refresh Token
│ ├── 调用 /refresh 接口换取新 Access Token
│ └── 用新 Token 重试原请求(用户无感知)
└── 如果没有 Refresh Token → 跳转登录
5.3 并发请求下的"无感知刷新"核心设计(完整实现思路)
问题场景
用户在页面上同时打开了多个标签页,或者页面初始化时同时发起了 5 个接口请求。很不巧,这 5 个请求发出的时候,Access Token 刚好过期了。
如果没有特殊处理,会发生什么?
5 个请求都会收到服务端返回的 401(Token 过期)。如果你在代码里写的是"收到 401 就调刷新接口",那刷新接口会被连续调用 5 次。这 5 次里,前面 1-2 次可能成功刷新了,后面几次带着旧 Token 去刷新,服务端会报错(Token 已被刷新过),然后整个逻辑就乱了。
更严重的是,如果刷新接口本身有并发限制,可能直接导致服务端报错,用户看到的就是一堆红色报错,体验极差。
正确的目标:
- 这 5 个请求里,只有第 1 个请求去触发刷新
- 后面 4 个请求原地等待,不发起任何新请求
- 刷新成功后,后面 4 个请求用新 Token 自动重试
- 用户完全感知不到这个过程
核心思路(三样东西)
要实现这个目标,只需要维护三样东西:
| 变量名 | 类型 | 作用 |
|---|---|---|
isRefreshing |
布尔值 | 标记"现在有没有人在刷新 Token" |
pendingQueue |
数组 | 存着所有等待重试的请求(本质上就是一个数组,每个元素是一个函数) |
refreshPromise |
Promise 对象 | 存着当前正在进行的刷新操作,防止重复发起刷新 |
就这么简单,没有别的花哨东西。
一步一步拆解执行过程
第一步:发请求之前,先准备一个"重试函数"
每个请求在发出之前,我们心里要清楚:如果这个请求因为 Token 过期失败了,我需要一个函数,它能用新 Token 把同样的请求再发一遍。
这个函数大概长这个样子(用伪代码描述):
function retryRequest(新Token) {
用新Token重新发起当前这个请求
把结果返回给调用者
}
注意,这个函数现在还没执行,只是准备好了。每个请求都有自己独立的 retryRequest 函数,因为每个请求的 url、method、data 都不同。
第二步:请求发出去了,服务端返回 401
这个时候,代码进入"处理 Token 过期"的逻辑分支。
先看第一个变量 isRefreshing:
- 如果
isRefreshing === false,说明目前没有人正在刷新,那当前这个请求就站出来,负责触发刷新操作 - 如果
isRefreshing === true,说明已经有人在刷新了,当前请求不需要做任何事,直接进入等待
第三步:第一个请求触发刷新
第一个请求把 isRefreshing 设为 true,然后调用刷新接口。
注意,刷新接口用的是旧 Token(因为新 Token 还没拿到)。
在调用刷新接口之前,还有一个重要操作:把刷新接口的调用结果存到 refreshPromise 里。这样做的好处是,后续如果再有请求也想触发刷新,直接拿这个 refreshPromise 来用就行,不需要再发起一次刷新请求。
第四步:后续请求进入等待
在第一个请求正在刷新 Token 的这段时间里,其他 4 个请求陆续收到了 401。
它们检查 isRefreshing,发现是 true,于是不做任何操作,只是把自己的 retryRequest 函数放进 pendingQueue 数组里。
此时队列里存了 4 个函数,分别对应 4 个不同的请求。
第五步:刷新成功,分发新 Token
刷新接口返回成功,拿到了新的 Access Token。
这时候要做三件事:
- 把新 Token 存到本地(替换掉旧的)
- 遍历
pendingQueue数组,把里面的每个retryRequest函数依次取出来执行,执行时传入新 Token - 清空
pendingQueue数组,重置isRefreshing = false,清空refreshPromise
队列里的每个函数执行后,对应的请求就用新 Token 重新发出了,用户不会看到任何报错。
第六步:刷新失败的情况
如果刷新接口返回失败(比如 Refresh Token 也过期了),这时候说明用户确实已经登出了。
要做的事情:
- 清除本地存储的 Token
- 清空
pendingQueue数组(这些请求没必要重试了) - 重置
isRefreshing = false,清空refreshPromise - 提示用户"登录已过期,请重新登录"
- 跳转到登录页
关键细节(容易踩坑的地方)
细节一:刷新接口本身不要走 Token 过期处理逻辑
刷新接口 /refresh 自己也可能返回 401,但这时候绝对不能再进入"处理 Token 过期"的循环,否则会死循环。所以代码里要区分:如果是刷新接口本身报错,直接失败,不再重试。
细节二:刷新成功之前,所有请求都不要发出去
等待队列里的请求,在刷新完成之前,绝对不能发出。如果发出去了,用的还是旧 Token,还会继续报 401,造成无限循环。所以队列里存的是函数(闭包),而不是直接发请求。
细节三:刷新接口的并发控制
上面说到的 refreshPromise 就是为了解决这个问题。如果第一个请求已经发起了刷新,后续请求直接复用这个 Promise 对象,而不是再发起一次。
细节四:文件上传的特殊处理
文件上传用的是 wx.uploadFile 或者 XMLHttpRequest 的 upload 方法,它们和普通请求的 API 不同,但逻辑完全一样:收到 401 → 检查 isRefreshing → 进队列或触发刷新 → 刷新成功后用新 Token 重新上传文件。
唯一需要注意的是,文件上传的"重试函数"要重新构造上传请求,而不是直接调用普通的 request 方法。
细节五:刷新期间的请求超时问题
如果刷新接口响应很慢(比如网络不好),等待队列里的请求会一直 pending。前端可以设置一个总超时时间(比如 5 秒),超时后直接让所有等待请求失败,提示用户网络异常或重新登录。
用一句话总结这个设计
第一个过期请求触发刷新,后续过期请求进数组排队,刷新成功后遍历数组依次重试,刷新失败则清空数组跳转登录。
这个方案的核心价值
- 刷新接口只调用一次,节省服务端资源
- 所有并发请求都能成功重试,用户无感知
- 逻辑清晰,无论什么语言、什么框架都能实现
- 对服务端没有额外要求,只需要提供标准的刷新接口
六、黑名单(Token 吊销)到底怎么实现?
JWT 无状态,意味着签发后就无法主动失效,除非引入额外机制。
6.1 黑名单的本质
在服务端维护一个"已失效 Token 列表",每次请求时校验。
6.2 实现方案
方案一:短有效期 + 刷新机制(推荐)
- Access Token 有效期 15~30 分钟
- 不维护黑名单,依靠短有效期降低风险
- 适合绝大多数业务场景
方案二:Redis 黑名单(主动吊销)
- 用户登出 / 修改密码 / 封号时,将
jti存入 Redis,并设置过期时间(= Token 剩余有效期) - 每次请求验签后,再查 Redis 是否存在于黑名单
- 性能影响可控,适合高安全场景
方案三:版本号机制(无 Redis)
- 用户表维护
token_version字段 - JWT Payload 中携带
version - 每次请求对比用户当前版本,不一致则拒绝
- 缺点:每次请求多一次 DB 查询
6.3 核心结论
黑名单不是必须的,而是安全与性能的权衡。绝大多数业务用"短 Access Token + Refresh Token"即可满足安全要求。
七、集群 / 分布式环境下的落地要点
| 组件 | 建议 |
|---|---|
| JWT 验签 | 所有服务节点使用相同密钥(对称)或公钥(非对称) |
| Refresh Token | 可存于 Redis,支持跨节点共享 |
| 黑名单 | 使用 Redis 集中存储 |
| 登出 | 清除客户端 Token + 服务端黑名单记录 |
| 密钥轮换 | 支持多密钥版本,旧 Token 仍可验证直到过期 |
八、一套完整的 JWT 认证流程
1. 用户登录 → 服务端签发 Access + Refresh Token
2. 客户端存储 Token(按端选择安全方式)
3. 每次请求携带 Access Token(Header: Authorization: Bearer <token>)
4. 服务端验签 → 检查过期 → 检查黑名单(如有)
5. 若 Access 过期 → 客户端用 Refresh 换取新 Access
6. 若 Refresh 也过期或无效 → 强制重新登录
7. 用户主动登出 → 清除本地 Token + 黑名单记录(可选)
九、总结
- JWT 是身份凭证,不是数据载体
- 用双 Token 实现无感知刷新
- 并发刷新用队列 + 锁,防止重复请求(这里的队列本质上就是一个存着待重试请求的数组)
- 黑名单按需引入,不是所有场景都需要
- 存储方式由端决定,安全第一
- 这套方法论无关语言和框架,放之四海而皆准
JWT 的每一个设计决策(有效期、刷新策略、黑名单、存储方式)都应该是业务安全、用户体验、系统复杂度三者平衡后的结果。理解这套思路,比记住某个框架的 API 重要得多。