JWT 的本质、误区与正确落地:一套不依赖任何框架的架构方法论-微信小程序代码案例-Api 安全性拉满

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);
  });

这段代码的核心思想就三句话:

  1. 第一个遇到401的请求触发刷新 (设置 isRefreshing = true
  2. 后面遇到401的请求进数组等待 (把重试函数push到 pendingQueue
  3. 刷新成功后遍历数组全部重试(从队列里取出函数依次执行)

一、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):用户 ID
  • iat(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。

这时候要做三件事:

  1. 把新 Token 存到本地(替换掉旧的)
  2. 遍历 pendingQueue 数组,把里面的每个 retryRequest 函数依次取出来执行,执行时传入新 Token
  3. 清空 pendingQueue 数组,重置 isRefreshing = false,清空 refreshPromise

队列里的每个函数执行后,对应的请求就用新 Token 重新发出了,用户不会看到任何报错。

第六步:刷新失败的情况

如果刷新接口返回失败(比如 Refresh Token 也过期了),这时候说明用户确实已经登出了。

要做的事情:

  1. 清除本地存储的 Token
  2. 清空 pendingQueue 数组(这些请求没必要重试了)
  3. 重置 isRefreshing = false,清空 refreshPromise
  4. 提示用户"登录已过期,请重新登录"
  5. 跳转到登录页
关键细节(容易踩坑的地方)

细节一:刷新接口本身不要走 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 + 黑名单记录(可选)

九、总结

  1. JWT 是身份凭证,不是数据载体
  2. 用双 Token 实现无感知刷新
  3. 并发刷新用队列 + 锁,防止重复请求(这里的队列本质上就是一个存着待重试请求的数组)
  4. 黑名单按需引入,不是所有场景都需要
  5. 存储方式由端决定,安全第一
  6. 这套方法论无关语言和框架,放之四海而皆准

JWT 的每一个设计决策(有效期、刷新策略、黑名单、存储方式)都应该是业务安全、用户体验、系统复杂度三者平衡后的结果。理解这套思路,比记住某个框架的 API 重要得多。

相关推荐
禁止摆烂_才浅1 小时前
微信小程序高频面试题
前端·面试·微信小程序
来日方长。。。。long1 小时前
生产级MCP落地指南:FastMCP与官方MCP SDK的选型、架构与实战
架构·mcp
bytemaster1 小时前
SSH 连接被秒断?从握手失败到跳板机自动中转的完整排查路径
后端·架构
绿智校园1 小时前
一套基座替代五类系统:DeepBasic Folar与传统BA/SCADA/IoT平台的架构对比
物联网·架构
吃饱了得干活2 小时前
为什么你的Service越写越臃肿?三层架构的“业务逻辑层”是个黑盒
java·后端·架构
show4332 小时前
2026微信小程序批量处理视频文件架构方案:免费批量实测
微信小程序·小程序·架构
一拳不是超人2 小时前
Godot 信号不是线程安全的:我是怎么在后台线程里翻车的
前端·架构
吃饱了得干活2 小时前
从经典的三层架构到DDD:一次对“业务逻辑层”的解剖与重构
java·后端·架构
一拳不是超人2 小时前
被 Tauri「体积小」种草后,我拿它做了个本地 AI 桌面工具,然后踩了这些坑
前端·架构