尧图精选

登录生成Token全链路解析:JWT结构与双Token续签实战

🕒 发布时间:2026/10/1 5:37:35 📁 来源:尧图网络
不知道你有没有遇到过这种场景用户明明刚登录成功下一秒调取一个接口后端直接抛出一个token expired或者前端明明把 token 带上了后端却说身份认证失败再常见一点刷新页面时直接弹出一句“您的访问令牌无法刷新请退出后重新登录”。这几年我排查这类登录认证问题踩过不少坑最大的感触是很多人并不是不会“生成一个 token”而是对 token 生成之后的整条链路缺少全貌。登录生成 token 这件事表面上只是登录接口多返回一个字段但一旦涉及 access_token、refresh_token、单点登录、第三方 OAuth 登录、短信登录、扫码登录这些混合场景整个链路里的坑会一个接一个。这篇文章打算把“登录生成 token”这条完整链路拆开讲清楚登录接口里 token 应该怎么生成、JWT 的结构和关键参数怎么设置、access_token 和 refresh_token 怎么配合、续签怎么做以及线上最常见的“登录失败: login server error: token exchange failed”“token 刷新报 400”这类问题到底该怎么定位。无论你是刚接触前后端分离开发的新手还是正在维护老系统的同学这篇文章都会给你一套可以直接落地的参考方案。1. 登录为什么需要token你其实在用token解决什么问题1.1 传统Session认证的痛点在进入 token 之前得先搞清楚我们本来面对的是什么问题。早期的 Web 应用大多是服务端渲染登录成功后服务端会在内存里创建一个 session然后把 session ID 通过 Cookie 种到浏览器。下一次请求时浏览器自动带上 Cookie服务端根据 session ID 找到对应的会话就知道“这个请求是谁发来的”。这套机制在单体应用时代非常好用但放到现在的前后端分离架构里就有点尴尬了。后端如果同时部署多个实例用户的 session 存在 A 实例上负载均衡把下一个请求转发到了 B 实例B 实例查不到 session用户就被当成未登录。解决办法无非是 session 粘滞、session 复制或者把 session 抽出来放到 Redis 里统一存储整条链路就多了一套需要维护的分布式会话系统。再加上现在很多业务需要开放接口给小程序、App、第三方网站调用Cookie 在跨域场景下的表现也让人头疼。1.2 Token认证的核心优势与适用边界token 认证解决的就是这个“认证状态如何传播和验证”的问题。服务端不再需要保存一份会话记录用户登录成功后拿到一个 token后续请求只要在 HTTP 头里带上它服务端验一下签名、看一下有效期就能确认请求方的身份。因为 token 本身是自包含的所以它天然支持水平扩展——任何一台后端实例只要有相同的密钥都能独立完成验证不必共享会话存储。但这不意味着 token 认证就是万能的。无状态带来的代价恰恰是“吊销困难”一个 session 服务端随时可以删掉但一个 JWT 只要在有效期内就算服务端想拉黑它也得靠额外的手段比如黑名单或者缩短过期时间。所以实际项目里单纯的“一个 token 走天下”并不是好设计更稳妥的做法是拆成 access_token 和 refresh_token 两把钥匙让短期凭证负责日常访问让长期凭证负责幕后续命。2. 登录生成token的链路全拆解从敲下回车到前端拿token2.1 链路中的七个关键环节我习惯把登录生成 token 的过程拆成七个环节来看这样做的好处是线上出问题时可以按环节逐段排查不至于一上来就在代码里瞎翻。第一步客户端提交身份凭证。不同登录方式的凭证不一样账号密码登录提交的是用户名和密码短信登录提交的是手机号和验证码微信扫码登录提交的是授权码或者回调后的 code。第二步服务端校验凭证。这一步是登录链路的“门卫”验证码对不对、密码哈希是否匹配、授权码是否有效都必须在这里完成。第三步查询用户信息并校验账号状态。凭证通过后要查出用户 ID、角色、权限等基础信息同时检查账号是否被封禁、是否已注销。第四步生成短时 access_token。有效期一般设成 1 到 24 小时具体看业务风险等级核心交易类系统可以压到 30 分钟。第五步生成长时 refresh_token 并保存。refresh_token 的有效期通常是 7 到 30 天而且最好在服务端留一个可检索的标记方便后续强制下线或者轮换。第六步把 token 返回给客户端。响应体里一般包含 access_token、refresh_token 和 expires_in客户端拿到后按自身需求存储。第七步客户端在后续请求中携带 access_token。通常放在Authorization: Bearer token头里后端再走一次统一的鉴权过滤器。2.2 为什么登录链路里需要access_token和refresh_token两把钥匙很多新手会问为什么不只发一个长期有效的 token省事又不会被频繁打断。这句话听起来有道理但实际业务里非常危险。token 一旦落入别人手里有效时间越长损失窗口就越大。一个有效期 30 天的 token被截获后对方能冒充你整整一个月。所以更合理的做法是“短中带长”access_token 有效期很短就算被截获半小时后就自动失效refresh_token 有效期长但它只用来换取新的 access_token不参与日常接口鉴权。这就好比你去大型停车场进场时拿了一张临时小票车停在几号位系统都记录在案你临时出停车场买东西小票过了两小时不能用了但你有一张月卡到闸机前一刷系统重新打印一张新小票给你。若三张同时护体哪怕小票被捡到对方也进不了车库因为刷月卡时系统能发现问题。3. 先把token本身讲透JWT的结构与关键参数3.1 Header里藏着什么目前项目里最常见的是 JWTJSON Web Token。JWT 由三部分组成用点号分隔分别是 Header头部、Payload载荷和 Signature签名。在生成 token 之前先把这三部分各自负责什么搞清楚后面调参数才有底气。Header 通常是一个很小的 JSON包含 token 类型和签名算法。typ固定是JWTalg表示签名算法比如HS256对称签名同一个密钥既加密又验签或者RS256非对称签名私钥签发公钥验签。对外提供开放 API 的第三方平台一般用 RS256 之类非对称算法因为可以在不暴露私钥的情况下把公钥交给调用方验签内部系统自己签发自己验签HS256 简单省事也够用。3.2 Payload里必须有的标准字段Payload 是 JWT 里真正承载信息的部分也是一大堆问题的发源地。标准字段里有几个值得认真对待subSubject主题一般放用户 ID是唯一标识不要直接放手机号这类可枚举的敏感信息。iatIssued At签发时间Unix 时间戳必须取服务器时间不能取客户端传上来的时间。expExpiration Time过期时间Unix 时间戳。这个字段直接决定 access_token 的生命周期签发的 token 如果没设 exp某些库会直接拒绝解析。issIssuer签发方标识用来区分 token 是谁发的尤其在多系统互通时很有用。audAudience接收方标识标明这个 token 是给哪个接口或应用用的防止一个 token 到处乱刷。除了标准字段业务上还常放uid、role、scope这类自定义字段。但我必须强调一句JWT 的 Payload 只是 Base64Url 编码不是加密任何人都能解码看到内容。所以手机号、身份证号、详细地址这类敏感信息绝对不要直接塞进 Payload。如果确实需要携带部分敏感信息可以考虑用额外手段加密整个 token或者改成把敏感信息放到服务端缓存里JWT 里只留一个引用 ID。3.3 Signature签名是怎么算出来的签名部分是 JWT 防篡改的核心。以 HS256 为例签名就是把 Base64Url 编码后的 Header 和 Payload 用点号拼起来拿密钥通过 HMAC-SHA256 算法算出一个摘要再把摘要 Base64Url 编码追加到第二部分后面。signature HMACSHA256(base64Url(header) . base64Url(payload), secret)服务端验签时用同样的算法重新计算一遍签名跟 token 里的第三段比对不一样就直接拒绝。这么做能保证 token 在传输过程中没有被篡改因为攻击者没有密钥改了 Payload 里的exp就过不了签名验证。理解这一点很重要签名的目的是保整、防篡改而不是保密。所以“我用了 JWT我的数据就很安全”这个想法是错的JWT 的安全性依赖密钥强度和签名算法的正确使用跟外层 HTTPS 是两码事。4. 实操环节写一个短信验证码登录并生成token的完整接口4.1 前置准备光讲理论容易飘落到代码里才踏实。这里我以 Python Flask 为例写一个短信验证码登录接口完整实现“验证码校验 → 查用户 → 生成 access_token 和 refresh_token → 返回前端”的全过程。你本地需要装好 Python 3.9、Redis存验证码和 refresh_token、以及一个用户表字段至少包括 id、手机号、密码哈希、创建时间。需要安装的依赖很简单pip install flask flask-cors pyjwt redis用到的主要库就是 PyJWT负责 JWT 的签发和解析。Redis 库负责存取验证码和 refresh_token。实际生产里你可能还会用 SQLAlchemy 连数据我这里为了聚焦 token 链路用字典模拟一下用户表查询。4.2 核心代码实现先定义密钥和两个生成 token 的函数。密钥在真实项目里一定要放到环境变量或者密钥管理系统里不要写死在代码里更不要提交到 Git 仓库。import time import uuid import jwt import redis from flask import Flask, request, jsonify app Flask(__name__) # 实际项目务必从环境变量读取 SECRET_KEY your-secret-key-change-me ACCESS_TOKEN_EXPIRE 7200 # 2小时 REFRESH_TOKEN_EXPIRE 7 * 24 * 3600 # 7天 redis_client redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) def generate_access_token(user): payload { sub: str(user[id]), username: user[username], scope: user, iss: my-app, iat: int(time.time()), exp: int(time.time()) ACCESS_TOKEN_EXPIRE, } return jwt.encode(payload, SECRET_KEY, algorithmHS256) def generate_refresh_token(user): # refresh_token 也做成 JWT但 type 字段标记为 refresh payload { sub: str(user[id]), type: refresh, iss: my-app, jti: uuid.uuid4().hex, # 唯一ID便于后续吊销追踪 iat: int(time.time()), exp: int(time.time()) REFRESH_TOKEN_EXPIRE, } return jwt.encode(payload, SECRET_KEY, algorithmHS256)然后是登录接口。这里我用短信验证码登录作为例子因为它在移动端和小程序里太常见了。app.route(/api/login/sms, methods[POST]) def login_by_sms(): data request.get_json() or {} phone data.get(phone, ) code data.get(code, ) # 1. 校验短信验证码 saved_code redis_client.get(fsms:code:{phone}) if not saved_code or saved_code ! code: return jsonify({code: 40001, message: 验证码错误或已过期}), 400 # 2. 查询用户实际项目换成数据库查询 user find_user_by_phone(phone) if not user: return jsonify({code: 40004, message: 用户不存在}), 404 if user.get(status) ! 1: return jsonify({code: 40003, message: 账号已被禁用}), 403 # 3. 生成 access_token 和 refresh_token access_token generate_access_token(user) refresh_token generate_refresh_token(user) # 4. refresh_token 存入 Rediskey 里带上用户ID方便后续查询和吊销 redis_client.set( frefresh:token:{user[id]}, refresh_token, exREFRESH_TOKEN_EXPIRE ) # 5. 一次性验证码用完立即删除防止重放 redis_client.delete(fsms:code:{phone}) return jsonify({ code: 0, data: { access_token: access_token, token_type: Bearer, expires_in: ACCESS_TOKEN_EXPIRE, refresh_token: refresh_token } })再补一个刷新接口。客户端发现 access_token 过期或者预判它即将过期时带上 refresh_token 来换新的 access_token。这也是“登录生成 token”链路里最容易被忽略的部分。app.route(/api/auth/refresh, methods[POST]) def refresh(): data request.get_json() or {} refresh_token data.get(refresh_token, ) try: payload jwt.decode(refresh_token, SECRET_KEY, algorithms[HS256]) except jwt.ExpiredSignatureError: return jsonify({code: 40010, message: refresh_token已过期请重新登录}), 401 except jwt.InvalidTokenError: return jsonify({code: 40011, message: 无效的refresh_token}), 401 if payload.get(type) ! refresh: return jsonify({code: 40011, message: token类型错误}), 401 # 比对 Redis 里的 refresh_token等于才放行 stored_token redis_client.get(frefresh:token:{payload[sub]}) if not stored_token or stored_token ! refresh_token: return jsonify({code: 40012, message: refresh_token已失效请重新登录}), 401 user find_user_by_id(payload[sub]) if not user: return jsonify({code: 40004, message: 用户不存在}), 404 # 轮换 refresh_token生成新的并删除旧的 new_access_token generate_access_token(user) new_refresh_token generate_refresh_token(user) redis_client.set( frefresh:token:{user[id]}, new_refresh_token, exREFRESH_TOKEN_EXPIRE ) return jsonify({ code: 0, data: { access_token: new_access_token, refresh_token: new_refresh_token, expires_in: ACCESS_TOKEN_EXPIRE } })4.3 方案设计中的几个关键决策这个示例看着简单里面三个决策值得单独拿出来说。第一refresh_token 为什么也做成 JWT而不是直接用随机 UUID其实两种做法都有。用 JWT 的好处是自包含 payload服务端可以不查库就能读到用户 ID 和过期时间坏处是吊销仍然需要依赖额外存储。用随机 UUID 的好处是不可猜测、存储体积小服务端只需要把它当一把“钥匙”存在 Redis 里即可。我示例里选了 JWT Redis 的双保险写法JWT 负责携带信息Redis 负责校验和吊销两者各司其职。第二refresh_token 存储的 key 为什么要带用户 ID因为这样可以快速实现“用户主动退出”和“管理员强制下线”。只要删掉refresh:token:{user_id}这个 key用户的 refresh_token 立刻失效后续再刷新就报 401。如果不用这种 key 结构想批量吊销某个用户的所有 token 会很麻烦。第三为什么刷新后要轮换 refresh_token因为如果 refresh_token 始终不变攻击者一旦拿到它就能一直续签新的 access_token等于永久掌控账号。轮换之后每次刷新都发一个新的 refresh_token旧的那个服务端立刻删掉。这样做的主要代价是并发刷新时可能出现“旧 refresh_token 还未用就失效”的竞争后面第 5 章我会讲怎么处理。5. token的存储、校验与续签让登录状态真正“活”起来5.1 服务端存储方案对比登录返回 token 后服务端并不是什么都不用管了。要回答“token 存在哪里、存什么”这个问题得先分清角色access_token 一般是无状态的 JWT服务端不存refresh_token 则一定要在服务端留有痕迹不然“强制下线”“修改密码后让所有旧 token 失效”这类需求根本没法实现。我整理过几种常见方案存储方案access_tokenrefresh_token优点缺点纯 JWT 无存储JWTJWT服务端不存实现最简单完全无状态无法吊销泄露后很难处理JWT RedisJWTJWTRedis 存一份冗余吊销可控实现成本低Redis 需要保活要处理过期淘汰随机串 Redis随机串Redis 存用户状态随机串Redis 存映射吊销方便体积小安全可控每次请求都要查 Redis有状态数据库持久化JWT 或随机串存表记录状态可审计、可查询、能做细粒度控制会话表增长快需要定期清理实际项目里我见过最多的是“JWT Redis”方案因为它兼顾了无状态验签和可控吊销。你要是做的是高安全要求的金融类系统我建议直接上“随机串 Redis”让每个请求都经过状态校验牺牲一点性能换安全性。做对外提供 API 的平台则更常采用“纯 JWT”因为调用方拿到 token 就可以离线验签服务端不需要为每一个请求都访问缓存。5.2 续签流程与并发场景下的处理续签的场景在实现时会遇到一个绕不开的问题并发。用户在浏览器里打开多个标签页明明只用了一个标签操作另一个标签在很久之后发出请求access_token 可能已过期于是多个标签几乎同时调用刷新接口都拿着同一个 refresh_token。如果服务端每次刷新都直接删掉旧 token 再写入新 token就会出现一个标签刷新成功后另一个标签拿着已被删掉的旧 token 再来刷新结果被判定为“refresh_token 已失效”。解决思路通常有三种。第一种容忍并发。前端在等待刷新请求期间把其他并发请求先挂起Promise 队列等刷新完成后再用新 access_token 重放。这是最推荐的做法它能从源头避免同一时间发出多个刷新请求。let refreshPromise null; function getAccessToken() { if (isAccessTokenValid()) return Promise.resolve(accessToken); if (!refreshPromise) { refreshPromise requestRefresh().finally(() { refreshPromise null; }); } return refreshPromise.then(res res.access_token); }第二种服务端做原子操作。刷新接口里把“比对旧 refresh_token → 生成新 refresh_token → 删除旧值 → 写入新值”这几个动作用 Redis 的 Lua 脚本或者事务包起来保证并发请求只有一个能成功。第三种引入 refresh_token 家族机制。记录同一个 refresh_token 派生出的一系列新 token一旦发现旧 token 被重复使用就判定为可能的 token 泄露把这个家族全部吊销强制用户重新登录。这种策略更多用在安全要求极高的系统里。我个人的建议是前端请求队列 服务端原子校验一起上双保险。只靠前端队列刚好遇到客户端断网或者 App 状态异常时服务端依然可能被重复请求打到只靠服务端原子操作前端一堆报错日志也影响体验。6. 登录token常见问题排查实录那些报错到底在说什么6.1 token交换失败类报错的定位思路很多人在接入第三方 OAuth 登录或者企业级单点登录时会碰到一类非常让人头疼的报错大概长这样login server error: token exchange failed: token endpoint returned...或者token exchange failed: error sending request for url...。这个报错的根源通常不是在业务代码里而是在 OAuth 2.0 / OIDC 授权码流程的“换取 token”环节。授权码流程大体上是用户点击“使用某平台登录”浏览器跳转到授权服务器用户同意授权后授权服务器通过回调地址返回一个 code后端再用这个 code 去请求 token endpoint换回 access_token。token exchange failed就是在“后端请求 token endpoint”这一步出了问题。按我的排查习惯会先看响应状态码再分方向定位如果是 400 Bad Request大概率是 code 已过期、code 已被使用过一次、redirect_uri 和授权时不一致、或者请求参数格式错误。如果是 401 Unauthorized基本是 client_id / client_secret 没配对或者密钥更新了服务端还在用旧的。如果是 403 Forbidden就比较复杂了。有可能是授权服务器根据请求来源 IP 或客户端环境判定该次请求不被服务允许从而拒绝换取 token。这种策略限制通常由授权服务器或平台侧配置控制排查方向集中在访问出口 IP 是否在允许范围、目标服务的可用性策略、以及请求本身是否带有被识别为异常的频率特征。如果是 500 / 网络层错误先检查服务到授权服务器之间的连通性再看 TLS 证书是否过期、代理设置是否拦截了请求、DNS 解析是不是被切到了异常地址。6.2 刷新报400与鉴权失败的典型原因另一类高频报错是刷新 token 相关。比如your access token could not be refreshed. please log out and sign in again.和failed to refresh token: 400 bad request: invalid refresh_token: empty string。第二个报错信息非常直白后端收到了一个空字符串当作 refresh_token。但实际业务里问题往往出在客户端传参不当而不是用户真的没传。常见原因有三个前端把 access_token 当成 refresh_token 塞进刷新接口后端刷新接口参数名和前端约定不一致比如后端要refreshToken前端传的是refresh_token还有一种刷新接口的处理器在启动时没有正确初始化导致参数解析失败。我遇到最离谱的一回是前端在请求拦截器里对“是否带 refresh_token”的判断写反了导致每次刷新请求都拿不到真正的 refresh_token后端收到一个空串于是循环报错。排查这类问题时最快的办法是直接在浏览器开发者工具里看网络请求确认实际发出的请求体里 refresh_token 字段到底有没有值值是不是对的。至于your access token could not be refreshed这类更上层的提示它通常是业务层对“刷新失败”的统一封口。看到它先不要慌去后端日志里捞对应时间点的刷新记录看是过期、不匹配、还是网络问题。很多团队在这个提示上踩的坑是没有把真正的失败原因记录到日志导致用户反馈问题时只能靠猜。所以我一直建议刷新接口的异常分支一定要打日志至少要把失败类型和用户 ID 记下来。6.3 常见问题速查表结合我在不同项目里的经验把登录 token 链路里最常见的几个问题整理成了一张表方便你遇到问题时直接对照报错或现象可能原因处理建议登录接口返回 401但账号密码正确Redis 里用户信息缺失或密码哈希算法不一致确认用户表数据检查密码加密库版本接口返回 401access_token 明明没过期服务端和客户端时间不一致JWT 校验时使用的时钟偏差过大校准服务器时间设置 NTP 同步检查签名算法是否一致JWT 解析时报签名错误服务端密钥换过但旧 token 还在有效期内多密钥轮转验签时允许按 kid 匹配旧密钥刷新接口返回 400 invalid refresh_token empty string前端传参有问题或参数名不匹配抓包确认请求体统一前后端参数名刷新接口返回 401 refresh_token 已失效旧 refresh_token 被并发刷新轮换掉了前端做请求队列服务端做原子刷新刚登录就退出用户无法保持登录access_token 有效期太短且前端没有实现刷新检查前端是否在 401 后自动调用刷新接口强制下线功能不生效refresh_token 没有存服务端无法吊销至少把 refresh_token 存 Redis删除对应 key 实现下线第三方登录报 token exchange failedcode 失效、client 配置错误、网络或 TLS 问题按 400/401/403/网络错误分类排查7. 登录token链路里我踩过的坑三个务必养成的习惯登录生成 token 的链路并不复杂但越简单的地方越容易在细节上翻车。我自己经历过几次线上事故之后养成了三个习惯影响最大的是这套流程真正跑起来之后能不能保持稳定。第一个习惯是给登录和刷新接口打全日志。很多服务上线后日志里只有“登录成功”和“登录失败”完全没有记录失败类型、用户 ID、IP、请求参数。一旦用户反馈登录异常连问题出在验证码环节还是 token 生成环节都分不清。现在我至少会在每个分支都记录一条结构化日志尤其是刷新接口的异常分支这个习惯救过我很多次。第二个习惯是严格控制 token 里的信息量。我见过团队把用户手机号、邮箱、真实姓名全部塞进 JWT 当普通字段用直到有一天 token 泄露用户隐私信息全被解码看光才意识到问题。后来我定的规则很简单Payload 里只放用户 ID、签发信息和过期信息业务数据一律通过用户 ID 去服务端查询。第三个习惯是设置密钥轮换机制。很多系统上线后JWT 密钥三年不换一次等于给攻击者留了一扇永远不锁的门。比较稳妥做法是支持多密钥验证新签发的 token 用新密钥旧的密钥只用来验证等旧 token 全部过期后再下线旧密钥。这样既不影响在线用户又避免了密钥长期固定不换的隐患。如果你现在正准备设计登录模块我建议先把方案写成一页纸token 类型、有效期、存储位置、吊销方式、刷新逻辑、前端处理逻辑每个环节都写清楚。写的过程中你会发现很多“当时以为没问题”的细节其实都没想透。等这张纸上的所有决策都有了明确答案再动手写代码踩坑的概率会小很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →