☰
JWT从原理到实战:结构、签名校验、SPA接入与漏洞防御全解析
2026/10/9 6:26:02 网站建设 项目流程

前两年接手一个后台管理系统,当时技术选型拍板用 JWT 做登录态管理,结果上线第一周就出了个安全告警:有人拿改过的 token 直接访问了管理接口。排查下来不是密钥泄露,也不是代码漏洞,而是团队里对 JWT 的原理理解只停留在“会用”,压根没搞清楚它到底是怎么防篡改的。从那以后我就养成了一个习惯——不管项目多急,凡是涉及 token 方案,先把 JWT 的认知补齐再动手。

这篇就把我这些年用 JWT 的完整心得整理出来,从结构原理、服务端生成校验、SPA 前端配合验证码的接入流程,到令牌续签的几种实战方案,再到网上被问烂的 JWT 漏洞清单。内容以实际可落地为主,代码基于 Python Flask 和 JavaScript 双版本讲解,尽量让前后端同事都能对号入座。

1. JWT 到底是什么,以及为什么它比 session 更适合现代应用

很多教程一上来就贴 JWT 的结构图,但少有人把“为什么需要 JWT”讲透。我先从最朴素的问题开始:传统的 session 方案里,用户登录后服务端把 session id 存在内存或 Redis 里,客户端只拿一个随机字符串。这套模型在单机时代没毛病,可一旦上了微服务、多实例部署,session 共享就成了噩梦——要么引入额外的 session 存储中间件,要么做粘性会话。而 JWT 的思路完全不同:服务端把用户身份信息直接签名后发给客户端,后续请求客户端原样带回来,服务端验签通过就信任它,完全不依赖服务端存储。

这个差异是理解 JWT 一切特性的基石。因为无状态,服务端天然支持水平扩展,负载均衡随意转发请求都不用担心 session 丢失。而且 JWT 自带结构化的用户信息,比如用户 id、角色、过期时间,直接在网关层就能做权限校验,不需要透传到下游服务再查库。

1.1 JWT 的经典三段式结构:Header、Payload、Signature

JWT 的字符串长这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

三个部分用点号分隔:

  • Header:固定包含签名算法(alg)和令牌类型(typ),默认是{"alg":"HS256","typ":"JWT"}。这里有个陷阱——很多攻击就是针对 alg 字段做文章,后面漏洞章节我会展开。
  • Payload:承载业务声明,常见的有sub(主题,通常存用户 id)、exp(过期时间)、iat(签发时间)、iss(签发者)。注意,这一段虽然经过了 base64url 编码,但它是明文!任何人把字符串粘到 jwt.io 上都能直接看到内容,所以千万别往里面塞密码、手机号这类敏感信息。
  • Signature:签名部分,服务端用密钥把 header.payload 拼接串做哈希运算生成的。客户端手里没有密钥,改任何一段内容都会导致签名校验失败。

理解这三段有一个关键认知:JWT 的防篡改能力不在“字面量加密”,而在签名校验。这就好比你的身份证,卡片上印着身份信息人人可见,但上面的防伪编码是公安系统专用密钥才能核验的,伪造一个同样格式的身份证并不能通过验证。

1.2 JWT 工作流程:从登录到鉴权的完整链路

一次典型的 JWT 请求生命周期分四步:

  1. 客户端提交账号密码,服务端校验成功后生成 JWT 返回。
  2. 客户端拿到 JWT,通常存在 localStorage 或内存里(SPA 项目怎么存,后面单独说)。
  3. 后续每次请求,客户端在 Authorization 头加Bearer <JWT>或塞进 Cookie。
  4. 服务端拦截请求,校验签名与过期时间,通过后从 Payload 里取用户身份,放行到业务逻辑。

这个链路里服务端做的事情极其简单——不需要回查数据库、不需要读 session,一个统一验签函数全部搞定。也正是因为简单,JWT 特别适合做 API 网关的轻量级鉴权拦截。

2. 基础实操:5 分钟实现 JWT 的生成与校验

选 JWT 库的时候不需要纠结,生态已经非常成熟。后端我用过 Node 的jsonwebtoken、Python 的PyJWT、Java 的jjwt,核心 API 大同小异,都是“一个函数签发、一个函数验证”。这篇以 Python 版本为主线,Node 作为对照,方便不同技术栈的同学迁移。

2.1 环境准备:安装依赖并准备密钥

先装库:

pip install PyJWT # Node 环境等价命令:npm install jsonwebtoken

密钥管理是第一步就要想清楚的事。建议用环境变量或密钥管理服务存放,比如SECRET_KEY=your-256-bit-secret,千万别硬编码在代码仓库里。HS256 算法理论上密钥长度建议不少于 32 字节,我用secrets.token_urlsafe(48)生成一串随机值备用。

2.2 服务端签发 Token 的完整代码

登录接口校验完密码之后,生成 token 的代码非常短:

import jwt import datetime SECRET_KEY = "your-secret-key" # 实际使用从环境变量读取 def create_access_token(user_id: str, role: str, expires_minutes: int = 30): payload = { "sub": user_id, "role": role, "iat": datetime.datetime.now(datetime.timezone.utc), "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=expires_minutes), } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") return token

注意几个容易被新手忽略的点:

  • exp一定要用 UTC 时间,不要用datetime.now()的本地时间,否则服务器时区不同会导致 token 提前或延迟过期。
  • sub字段存用户 id 是惯例,RESTful API 里它就是“资源的拥有者标识”。
  • 自定义字段别乱塞,比如role这种用于权限控制的字段,一定要配合签名机制才能信任。

Node 版等价写法:

const jwt = require('jsonwebtoken'); function createAccessToken(userId, role, expiresIn = '30m') { return jwt.sign( { sub: userId, role }, process.env.SECRET_KEY, { expiresIn } ); }

jsonwebtoken的expiresIn选项会自动帮你填上exp,不需要手动算时间。

2.3 服务端校验 Token 的中间件写法

签发容易,校验才是重头戏。一个合格的校验中间件要处理三件事:格式对不对、签名是否合法、token 是否过期。Python Flask 的装饰器版:

from functools import wraps from flask import request, jsonify def jwt_required(f): @wraps(f) def wrapper(*args, **kwargs): auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return jsonify({"error": "missing or invalid authorization header"}), 401 token = auth_header.split(" ")[1] try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) request.user_id = payload.get("sub") request.role = payload.get("role") except jwt.ExpiredSignatureError: return jsonify({"error": "token expired"}), 401 except jwt.InvalidTokenError: return jsonify({"error": "invalid token"}), 401 return f(*args, **kwargs) return wrapper

Node/Express 版:

function jwtRequired(req, res, next) { const authHeader = req.headers.authorization || ''; if (!authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'missing or invalid authorization header' }); } const token = authHeader.split(' ')[1]; try { const payload = jwt.verify(token, process.env.SECRET_KEY); req.userId = payload.sub; req.role = payload.role; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ error: 'token expired' }); } return res.status(401).json({ error: 'invalid token' }); } }

校验时的algorithms参数极其关键。一定要显式指定允许的算法白名单,不能留空。如果你偷懒不写,某些库就会照单全收,攻击者把 Header 里的 alg 改成none就能伪造 token,后面漏洞部分细说。

2.4 SPA 项目里 JWT 与验证码的配合实现

说完基础的签发校验,把视角切到热搜里的另一个场景:SPA 项目开发中的 JWT 验证码实现。实际做过几个前后端分离项目之后,我总结了前端接入 JWT 的套路,核心是“进登录页先要验证码,登录成功再存 token”。

先看验证码环节。图形验证码的常规做法是后端生成图片,并把验证码答案连同会话标记一起临时存储。JWT 模式下没有 session,常见的替代方案是把验证码答案用短期 JWT 或 Redis 保存。下面这个方案用 Redis + 图片验证码,兼容性最好:

import redis import random from PIL import Image, ImageDraw, ImageFont redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) def generate_captcha(): code = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', k=4)) # 生成图片逻辑省略,核心是把答案和唯一标识绑定 captcha_id = uuid.uuid4().hex redis_client.setex(f"captcha:{captcha_id}", 300, code) return captcha_id, image_bytes

前端登录流程:

  1. 页面加载时请求/api/captcha,拿到图片 base64 和一个captcha_id。
  2. 用户输入账号、密码、验证码,提交时带上captcha_id。
  3. 后端先校验验证码,再校验账号密码,最后签发 JWT。
  4. 前端拿到 JWT 存起来,后续请求统一在 axios 拦截器里加 Authorization 头。

验证码这步为什么要单独说?因为很多 SPA 项目直接把验证码答案塞进 JWT payload 里返回给前端,这等于把验证码功能架空了——攻击者解码 payload 就能读到答案。记住一条红线:验证码答案只能存在服务端临时存储里,绝不进 JWT payload。

前端 axios 拦截器的 token 注入写法:

axios.interceptors.request.use(config => { const token = localStorage.getItem('access_token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; }); axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { // token 过期,触发续签或重新登录 } return Promise.reject(error); } );

3. 令牌续签方案:从滑动续签到 Refresh Token

JWT 无状态是优点,但“过期即失效”在真实业务里很痛苦。把过期时间设太长,被劫持的 token 能在很长一段时间内畅通无阻;设太短,用户半小时就得重新登录一次。所以 token 续签是每一个 JWT 项目都躲不开的实践课题。

3.1 续签的三种主流方案对比

我把网上常用的续签方案归成三类,放表格里对照看:

方案核心思路优点缺点适用场景
滑动过期用户每次请求时,若 token 剩余时间不足阈值,服务端签发新 token 并随响应返回实现简单,用户基本无感知请求响应多一步;每个接口都要判断是否签发新 token内部后台系统、低并发管理端
Refresh Token 双令牌短期 access token + 长期 refresh token,access 过期后用 refresh 换新 access安全性好,access 可以设很短;权限可动态变更需要额外维护 refresh token 的存储与轮换逻辑面向消费者的 C 端应用
Redis 版续签签发 token 时把 jti 存进 Redis,续签前先查 jti 状态可控性强,能主动吊销指定 token破坏了 JWT 无状态特性,引入 Redis 依赖要求能立即踢人下线的系统

3.2 方案一:滑动续签的完整实现

滑动续签理解成本最低。思路是在校验 token 的中间件里,检查exp与当前时间的剩余量,若不足总时长的一半,就生成一个新 token 放进响应头,前端自动捕获并替换存储。后端代码:

def jwt_required_with_refresh(f): @wraps(f) def wrapper(*args, **kwargs): token = extract_token_from_header(request) try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) # 剩余时间少于总时长的 50% 时,重新签发 exp_time = datetime.datetime.fromtimestamp(payload["exp"], tz=datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc) remaining = (exp_time - now).total_seconds() total = datetime.timedelta(minutes=30).total_seconds() if remaining < total * 0.5: new_token = create_access_token(payload["sub"], payload["role"]) response = f(*args, **kwargs) response.headers["X-New-Token"] = new_token return response except jwt.InvalidTokenError: return jsonify({"error": "invalid token"}), 401 return f(*args, **kwargs) return wrapper

前端收到自定义响应头后:

axios.interceptors.response.use(response => { const newToken = response.headers['x-new-token']; if (newToken) { localStorage.setItem('access_token', newToken); } return response; });

滑动续签的坑在于:只对一直活跃的用户有效,好几天不来的用户 token 早就过期了,依然要走重新登录流程。而且如果接口只读不敏感,可以只在涉及写操作的接口做续签判断,减少不必要的签发压力。

3.3 方案二:Refresh Token 双令牌机制

Refresh Token 是目前主流推荐方案。核心设计是签发两个 token:

  • access token:有效期短,比如 15 分钟到 2 小时,放在前端内存里,每次请求带。
  • refresh token:有效期长,比如 7 天到 30 天,存 HttpOnly Cookie,只能通过专用接口换新的 access token。

服务端实现:

def generate_token_pair(user_id: str, role: str): access_token = create_access_token(user_id, role, expires_minutes=15) refresh_payload = { "sub": user_id, "type": "refresh", "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7) } refresh_token = jwt.encode(refresh_payload, REFRESH_SECRET_KEY, algorithm="HS256") return access_token, refresh_token

换取新 access token 的接口:

@app.route("/api/auth/refresh", methods=["POST"]) def refresh(): refresh_token = request.cookies.get("refresh_token") try: payload = jwt.decode(refresh_token, REFRESH_SECRET_KEY, algorithms=["HS256"]) if payload.get("type") != "refresh": return jsonify({"error": "invalid refresh token"}), 401 # 业务可选:校验 refresh token 是否在服务端黑名单中 new_access_token = create_access_token(payload["sub"], payload.get("role", ""), expires_minutes=15) return jsonify({"access_token": new_access_token}) except jwt.InvalidTokenError: return jsonify({"error": "invalid refresh token"}), 401

这套方案的关键细节有三个:

  • refresh token 和 access token 必须用不同的密钥。如果同一个密钥,攻击者拿 refresh token 换个角色字段就能当成 access 用。
  • refresh token 一定要设置 HttpOnly,前端 JS 读不到,防止 XSS 窃取。这就把“双令牌”从简单的两个 token 变成了“一个内存 token + 一个 Cookie token”的安全组合。
  • refresh 接口要做频率限制,防止有人拿泄露的 refresh token 疯狂换新。

3.4 方案三:Redis 版续签与主动吊销

第三种方案适合对安全性要求极高的场景。签发 JWT 时把jti(JWT 唯一 ID)存入 Redis,并设置和 token 一致的过期时间。校验时先查 Redis 中是否存在该 jti:

def jwt_required_with_redis(f): @wraps(f) def wrapper(*args, **kwargs): token = extract_token_from_header(request) try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) jti = payload.get("jti") if not redis_client.exists(f"token:{jti}"): return jsonify({"error": "token revoked"}), 401 # 续签逻辑:若剩余时间不足,删除旧 jti,签发新 token 并写入新 jti except jwt.InvalidTokenError: return jsonify({"error": "invalid token"}), 401 return f(*args, **kwargs) return wrapper

主动吊销用户所有 token 时,只要在 Redis 里按用户维度记录一个“版本号”,签发 JWT 时把版本号放进 payload,校验时对比即可。用户下线就递增版本号,所有旧 token 立即失效。这套逻辑牺牲了 JWT 无状态的纯粹性,但换来了对账号安全的绝对掌控,取舍要按业务来。

4. JWT 漏洞总结:网上被问烂的安全坑,一次性讲清

这个章节对应热搜里的“jwt漏洞总结”。JWT 的安全问题集中在签名绕过、信息泄露和密钥管理三大类。我按攻击手法逐个写清楚原理和修复方案。

4.1 篡改攻击:alg 设为 none,直接伪造身份

这是 JWT 最出名的低级漏洞。攻击者把 token 的 Header 里alg改成none,再去掉签名部分,服务端如果没限制算法白名单,就会把这段没签名的 token 当成合法身份接收。

修复办法就是对症下药——校验时硬性规定算法白名单:

# 错误的写法:algorithms 不传,可能被库默认接受多种算法 payload = jwt.decode(token, SECRET_KEY) # 正确的写法:显式指定 payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Node 版同理,jwt.verify的algorithms选项必填。同时针对none攻击,服务端还可以在 decode 前检查 Header 里的 alg 字段是否在允许列表内,双保险。

4.2 算法混淆攻击:把 HS256 换 RS256 骗过验签

这个漏洞坑过不少资深开发者。场景是服务端签发和校验时用的公钥/密钥对:签信用 RSA 私钥,验信用 RSA 公钥。但攻击者把 token 的 alg 改成 HS256,然后把服务端已经公开的 RSA 公钥当作 HMAC 的对称密钥去签名。如果服务端校验时不区分算法,直接用 RSA 公钥作为 HS256 的密钥去验证,就能通过校验。

防护思路同样是算法白名单,并且明确避免“一个密钥两种算法”的歧义:

# 错误示范:根据 header 里的 alg 动态选择验签密钥 # 正确示范:固定算法,HS256 只认一个密钥,RS256 只认公钥/私钥 payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])

如果一定要同时支持多种算法(比如迁移期),需要分别存储并匹配算法对应的密钥,用字典映射,不要给攻击者留下“用公钥当密钥”的空间。

4.3 敏感信息泄露:Payload 是明文,不是保险箱

Payload 只做了 base64url 编码,不是加密。我在不少项目里见过有人把用户手机号、邮箱甚至密码哈希放进 payload,美其名曰“减少查询”。“减少查询”是可以减少数据库压力,但代价是这些信息等于公开暴露给了每个能拿到 token 的人(比如分析前端网络请求的任何人)。

合理做法:payload 只存最小必要信息,比如sub(用户 id)、role(角色)、iat、exp。需要用户详情时,拿sub去 Redis 或数据库查。如果确实需要隐藏某些声明,可以用 JWE(JSON Web Encryption)结构,但那属于另一个话题,认知层面记住“JWT 不等于加密”就够了。

4.4 密钥泄露与弱密钥爆破

HS256 是哈希算法,密钥强度不够就会被离线爆破。攻击者手持一个合法 token,在本机上就能无限尝试不同密钥做签名比对,GPU 加持下弱密码跑不了几个小时。

防护要点:

  • 密钥至少 32 字节以上随机串,推荐openssl rand -hex 32生成。
  • 定期轮换密钥。轮换时同步逻辑要兼容旧密钥,比如在校验层配置旧密钥列表,只用于验签不用于签发。
  • 不要把密钥提交到 Git 仓库,CI/CD 流水线里通过密钥管理服务注入环境变量。

ES256(椭圆曲线)比 HS256 的密钥要求短且抗爆破能力更强,新项目我一般优先推荐 RS256/ES256 这类非对称方案——公钥分发给下游服务,私钥只留在认证服务,钱包和保险箱分开放。

4.5 过期校验缺失与日志敏感字段

另一个常见的开发失误是只验签不验exp。有的团队图方便,decode 的时候没开 verify_expiration 选项,导致 token 永久有效。PyJWT 默认verify_exp是开着的,但有些库需要显式开启,或者自定义解析 payload 时直接把exp忽略了。

建议统一封装校验函数,明确开启所有验证项。另外线上日志里不要打印完整 token,签名段的泄露可能帮助攻击者分析算法强度,payload 段的泄露则属于用户信息事件。打日志时只保留 token 的前 8 位和后 4 位用于追踪。

4.6 安全防护 checklist 速查表

风险点攻击方式防护措施
算法篡改alg=none校验时指定 algorithms 白名单
密钥混淆HS256/RS256 混用算法与密钥一一对应,禁止动态映射
敏感泄露解码 payloadpayload 只存 uid/role,不多存
弱密钥离线爆破32字节以上随机密钥,定期轮换
过期失效永不失效强制开启 exp 校验
重放攻击相同 token 重复使用短 access + Refresh 轮换 + jti 落地存储
XSS 窃取JS 读取本地 tokenaccess token 存内存,refresh 存 HttpOnly Cookie

5. 实战中不得不防的坑与排查思路

最后一部分写我在真实项目里踩过、也帮别人排查过的典型问题。JWT 本身不难,难的是跟项目环境、浏览器策略、团队协作叠加后冒出来的一堆幺蛾子。

5.1 实测踩坑:从 401 循环到前端存储之争

先说 401 循环问题。axios 拦截器里写了“401 就刷新 token”,但 refresh 接口自己没做“refresh token 过期”的判断,于是出现死循环:refresh 也返回 401,前端再刷新,再 401。修复方式是在拦截器里加一个刷新状态锁:

let isRefreshing = false; let pendingQueue = []; axios.interceptors.response.use( response => response, async error => { const { response, config } = error; if (response?.status !== 401 || config._retry) { return Promise.reject(error); } if (isRefreshing) { return new Promise(resolve => pendingQueue.push(() => resolve(axios(config)))); } config._retry = true; isRefreshing = true; try { const { data } = await axios.post('/api/auth/refresh'); localStorage.setItem('access_token', data.access_token); config.headers['Authorization'] = `Bearer ${data.access_token}`; pendingQueue.forEach(cb => cb()); pendingQueue = []; return axios(config); } catch (refreshError) { pendingQueue = []; window.location.href = '/login'; return Promise.reject(refreshError); } finally { isRefreshing = false; } } );

再有就是前端 token 存哪儿的经典争论。localStorage 优点是刷新后不丢,缺点是任意 XSS 都能偷;内存变量(比如 Pinia/Vuex)安全性好一点,一刷新就没了,需要配合 refresh token 自动恢复。我的建议是:C 端业务优先“内存存 access + HttpOnly Cookie 存 refresh”,受控后台系统可以用 localStorage 换取开发效率,但要确保内容安全策略足够严格。

Node 后端有个坑是关于Authorization头大小。JWT 越长,Cookie 或 Header 体积越大,一旦超过部分网关或反代服务器的 header 大小限制(NGINX 默认large_client_header_buffers为 8k),请求直接被断掉。所以在 payload 里塞大字段除了泄露风险,还有实际请求失败的可能。

5.2 常见问题速查表

现象可能原因排查方向
解密能通,验签失败密钥不一致或算法不一致确认签发与校验使用相同密钥和算法
刚登录马上就过期exp 用了本地时间而非 UTC检查服务器时区和 iat/exp 的时钟同步
换一台机器 token 失效用了 HMAC 密钥且密钥绑定了 IP 特征检查是否误把客户端 IP 写进 payload
前端拿不到自定义响应头CORS 未暴露该 header服务端配置Access-Control-Expose-Headers
刷新 token 接口被刷爆没有频率限制加 IP 限流 + refresh token 轮换
用户踢下线后旧 token 仍可用纯 JWT 无状态,无吊销机制引入 Redis 存 jti 或用户版本号

日志排查的安全底线是永远不要把完整 token 打进去,我见过排查问题时把 token 贴进工单里的,等于把账号凭据公开了。截取前后段打码是底线。

最后分享一个从踩坑里打磨出来的经验:JWT 方案的复杂度不在于它本身,而在于跟业务场景的匹配度。内部工具、低并发后台,一个滑动续签就够;面向大量用户的产品,必须上 Refresh Token 双令牌;涉及金融、账号安全要求高的,再加 Redis 吊销层。没有万能的 auth 方案,只有适合当前业务规模的安全取舍。把这个取舍想清楚,再动手写代码,比任何库的选择都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询