1. Token这个词,为什么总让人一头雾水
第一次被人问"什么是Token",我下意识回答"就是一种令牌",说完自己都觉得等于没说。后来带过几批新人做接口对接,才慢慢摸清这个词的坑在哪——它在不同语境里指的完全是不同的东西。OAuth流程里说的Token,是服务端签发的一串凭证字符串;大模型API账单里说的Token,是文本切分后的最小计量单位;区块链钱包里说的Token,是链上合约发行的一类资产;而某些桌面软件里说的Token,仅仅是本地存着的一个激活凭据。同一个词,四套内核,混在一起讨论,必然吵架。
我把这些年碰到的和Token有关的问题整理成一篇。里面有概念拆解,也有实操代码和排查表。如果你刚开始接触接口对接、模型调用或者系统集成,这篇能帮你少绕几个弯;如果你已经被跳转链接里的token参数、401报错、刷新失败折磨过,后面第4章和第5章的对照表可以直接拿去用。
先给一个我认为最准确的定性:Token本质上是"一次性的、有期限的、可验证的授权凭证"。它不承载业务数据,它只回答一个问题——"你是谁,你能干什么,这个答案有效到什么时候"。至于这个答案是写在一张纸上(自包含JWT),还是存在服务端数据库里(不透明令牌),那是实现细节。理解了这一层,后面所有分支就都能挂上去。
1.1 从"寄存柜号牌"说起
不用急着看代码,先想一个生活场景。你去健身房,前台给你一个手环,里面存着你的会员编号和到期时间。你拿着手环去储物柜、去泳池、去淋浴间,每个闸机扫一下就知道你能不能进。前台不需要每次打电话问你"你是会员吗",因为手环本身就是答案。
这就是Token最核心的价值:把"验证身份"这件事从每次请求都查数据库,变成了一次签发、多次校验。签发的那一刻前台是见过你本人的(用户名密码、扫码、短信验证码),之后所有环节都只认这个手环。
但手环有个天然弱点——它不能自己作废。你把号牌借给别人,闸机照样放行。所以现实中会加各种补丁:手环有效期只有一天、进出要刷指纹、丢了要去前台挂失登记。这就是后面要讲的过期时间、二次校验和撤销机制。所有Token方案的复杂度,几乎都来自"既要方便,又要能收回"这对矛盾。
1.2 四类Token的边界划分
| 类型 | 典型场景 | 核心作用 | 是否计费 |
|---|---|---|---|
| 身份认证令牌 | 登录态、接口鉴权 | 证明调用方的身份与权限 | 否 |
| 计量型Token | 大模型输入输出、翻译服务 | 衡量算力消耗,作为计费单位 | 是 |
| 资产型Token | 区块链钱包、链上合约 | 代表某种可流转的权益 | 视链上规则 |
| 许可型Token | 软件激活、许可证绑定 | 证明本机有使用授权 | 否(一般为买断或订阅) |
这张表建议先记着。很多沟通事故就是双方各说各的:研发说"token过期了要重新登录",运营以为是"额度用完了要充值",两拨人讨论半小时才发现根本不是一个东西。开口前先确认对方说的是哪一类,能省掉大量无效会议。
我在实际协作里养成了一个习惯:凡是涉及Token的沟通,第一句话先补一句限定语——"我说的是鉴权令牌"或"我说的是计费token"。就这么一句话,团队里因为名词歧义产生的返工至少少了一半。
1.3 为什么大家总把它搞混
除了中文翻译都叫"令牌",还有个原因是这几类Token的外观确实像。它们通常都是一串没有可读性的字符,都带有效期,都会过期,都会在过期时报错。从使用者的角度看,"昨天还能用今天不能用了"这个现象在四类场景里一模一样,但处理方法完全不同:鉴权令牌过期要刷新或重新登录,计费额度用完要充值,激活凭据失效要重新绑定,链上资产则跟服务器毫无关系。
还有一个认知误区值得单独点一下:很多人以为Token是加密过的,拿到就一定能反解出信息。实际上大量Token根本没有加密,只是编码。JWT的载荷部分用Base64Url编码,任何人拿去解码就能看到里面的字段内容。所以千万不要往JWT里塞手机号、身份证号、内部密钥这类东西,你以为是保险箱,其实是个透明塑料袋。
2. 身份认证类Token:JWT、Access Token与Refresh Token
这是绝大多数开发者真正会天天打交道的一类。它的设计演变过程很能说明问题,我按"为什么会有这个东西"的顺序讲一遍,比直接背定义好理解得多。
2.1 早期方案的问题在哪
最早的做法很朴素:用户登录成功后,服务端生成一个随机字符串,存进数据库,和用户ID关联,返回给客户端。客户端每次请求带上这个字符串,服务端查表确认是谁。这套方案逻辑清晰、撤销容易,删掉表里那行记录就行。
问题出在规模上。当你的服务拆成十几个微服务,每个服务都要查同一张会话表,数据库压力陡增;如果做了多机房部署,会话表还得跨机房同步,延迟和一致性都是麻烦。于是有人提出:能不能让令牌自己携带信息,服务端不用查库就能验证?
这就是JWT(JSON Web Token)的出发点。它的设计哲学是自包含——所有需要的信息都写在令牌里,验证方只要拿到签名密钥,本地算一遍就能确认令牌有没有被篡改。
2.2 JWT的三段结构到底长什么样
一个JWT长这样,三部分用点号连接:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8oOS4iSIsImV4cCI6MTcxMjM0NTY3OH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk- 第一段是头部,声明签名算法和令牌类型,比如HS256还是RS256。
- 第二段是载荷,装的是业务声明,比如用户ID、角色、签发时间、过期时间。标准字段有简写约定:
sub是主体,exp是过期时间,iat是签发时间,nbf是生效时间。 - 第三段是签名,用头部声明的算法,把前两段拼接后用密钥算一个摘要。
这里有个关键细节必须说清楚:签名不是加密。它保证的是"内容没被改过",而不是"内容看不到"。任何拿到令牌的人,把中间那段做个Base64Url解码,就能看到里面的明文JSON。我见过有团队把用户的内部等级、部门编号甚至审批权限位都塞进载荷,前端一解码全暴露了。这种设计在内部系统里可能没人追究,一旦对外开放就是明确的信息泄露。
注意:验证签名时一定要显式指定允许的算法。早年有些库支持从令牌头部读取算法,攻击者可以把算法改成none,或者从非对称算法降级成对称算法,用公钥当密钥来伪造签名。这不是理论问题,是有过公开案例的真实漏洞。
2.3 双令牌机制是怎么来的
只用JWT会撞上一个死结。前面说了,JWT自包含、不查库,好处是快;但代价是它签出去就收不回来。用户点了退出登录,或者手机被偷了,你没法让一个还没过期的JWT立刻失效,因为它根本不经过你的服务器。
一个折中的做法是:把有效期设得很短,比如15分钟,甚至5分钟。短到即使泄露,窗口期也有限。但这样用户体验就崩了——每5分钟让人重新输一次密码,谁都受不了。
于是**Refresh Token(刷新令牌)**登场,形成双令牌机制:
- Access Token:有效期短,5到30分钟,每次业务请求都带它,验证快。
- Refresh Token:有效期长,几天到几个月,只用来换新的Access Token,不参与业务请求。
刷新令牌通常是不透明的随机字符串,存在服务端数据库里。这样撤销就有了抓手——想踢人,删掉数据库里那条刷新令牌记录,用户下次刷新就会失败,被迫重新登录。最长也就多活一个Access Token的有效期。
| 对比项 | Access Token | Refresh Token |
|---|---|---|
| 有效期 | 5-30分钟 | 3-90天 |
| 存储位置 | 内存或短时缓存 | 服务端数据库可查 |
| 是否自包含 | 常用JWT | 通常为随机串 |
| 使用频率 | 每次业务请求 | 仅在刷新时 |
| 撤销能力 | 弱,靠短有效期兜底 | 强,删记录即失效 |
2.4 令牌续签的具体实现思路
续签这件事看起来简单,写起来坑不少。我按常见做法拆一遍。
客户端发现请求返回401,并且错误码表明是令牌过期,就拿着Refresh Token去调用刷新接口。服务端校验刷新令牌有效性、是否过期、是否被撤销,通过后签发新的Access Token返回。
这里有几个设计选择值得琢磨。
第一种是无感刷新:在HTTP客户端加一个拦截器,请求前检查Access Token是否临近过期(比如剩余时间少于60秒),提前刷新;遇到401也自动刷新一次并重放原请求。用户体验上完全无感。要注意的是并发问题——如果页面同时发了8个请求,全都发现令牌过期,就会同时触发8次刷新。解决办法是加一个刷新中的Promise,后面的请求等这个Promise的结果,而不是各自发起。
第二种是刷新令牌轮换:每次刷新时,服务端签发新的Refresh Token,同时作废旧的。这样万一刷新令牌泄露被用了,真正的用户下次刷新会因为"令牌已被使用"而失败,触发告警。代价是必须处理"网络抖动导致新令牌没收到"的情况,通常做法是允许旧令牌在短时间内(比如30秒)重放一次,给客户端一个重试窗口。
伪代码大致是这样:
def refresh(refresh_token: str): record = db.find_refresh_token(refresh_token) if not record or record.revoked: raise AuthError("refresh token invalid") if record.expires_at < now(): raise AuthError("refresh token expired") # 轮换:旧令牌立即作废,新令牌入库 record.revoked = True new_access = sign_jwt(user_id=record.user_id, ttl=900) new_refresh = issue_refresh_token(record.user_id, ttl=7 * 86400) db.save(new_refresh) return {"access_token": new_access, "refresh_token": new_refresh}2.5 令牌撤销为什么是个难题
热搜里"令牌撤销难题"这个词出现频率很高,它确实是个结构性问题,不是某个框架的缺陷。
难点在于:验证方不查库,就不知道令牌是否被撤销;一旦查库,就失去了JWT省掉的那次查询。这是一道选择题,没有标准答案。实践中常见的几种解法,各有取舍:
- 短有效期 + 黑名单:Access Token设5到15分钟,撤销时把要作废的令牌ID扔进Redis,TTL设为该令牌的剩余寿命。验证时先查一次Redis。代价是每次请求多一次缓存查询,但Redis查询通常在毫秒级,可以接受。
- 版本号法:用户表加一个
token_version字段,签发时写进JWT。撤销时把这个字段加一。验证时比对版本号,不一致就拒绝。这需要查一次用户信息,但用户信息本来就有缓存,成本更低。缺点是撤销的是"某个用户的全部令牌",做不到精确到单个设备。 - 设备级会话表:每个登录设备一行记录,JWT里带上会话ID。这样既能精确撤销单台设备,代价也是每次验证查一次会话缓存。
我个人在中小规模系统里更偏向版本号法,因为实现简单、心智负担低;如果产品明确需要"查看我的登录设备并逐个踢下线"这种功能,那就得上会话表。
实操心得:不管选哪种方案,验证失败时返回的错误码要区分清楚。"令牌过期"和"令牌被撤销"是两回事,前者客户端可以自动刷新,后者必须直接跳登录页。如果都返回一个笼统的401,客户端就只能一律跳登录,用户会觉得"怎么老让我重新登录"。
3. 大模型时代的Token:计量单位而不是凭证
聊完鉴权,换到另一个完全不同的语境。近两年"token"这个词在热搜上的高频出现,很大一部分来自大模型服务的计费体系。这里的Token跟身份验证没有任何关系,它就是文本被切分后得到的最小片段单元。
3.1 Token不是字,是子词切分的结果
很多人以为一个汉字算一个token,一个英文单词算一个token,其实不对。模型处理文本前会先做分词,常见的做法是把词拆成更小的子词单元。英文里"tokenization"可能被切成"token"+"ization"两个token,常见短词整词保留,生僻长词拆得更碎。
中文的情况更微妙。一个常用汉字通常占1个token,但生僻字、组合词、标点可能占更多。粗略的经验值是:中文文本大约1个汉字对应1到1.5个token,英文文本大约4个字符对应1个token。这个比例随模型的分词表不同而浮动,只能用来做估算,不能当精确公式。
之所以要关心这个,是因为绝大多数模型服务的计费、限流、上下文长度限制都是按token算的。你发一段话,输入token和输出token分别计价,输出通常比输入贵好几倍。
3.2 一次调用的成本怎么估
假设你要翻译一份2万字的文档,目标语言是英文。
中文2万字,按1汉字约1.3个token估算,输入约2.6万token。英文译文按每词1.3个token、总词数约1.2万词估算,输出约1.6万token。如果输入单价是每百万token若干元、输出是输入的3到4倍,一次全量翻译的总成本就能算出来。
但实际做文档翻译不会一次性把2万字塞进去,因为上下文窗口装不下。常见做法是分块——把文档按段落切成每块2000到4000字,逐块翻译,块之间传递上一段的译文作为上下文,保证术语和语气连贯。这样输入token会显著增加,因为每块都要带上重叠的上下文。粗略估算,实际消耗可能是纯文本量的1.5到2倍。
这里有个容易被忽略的点:分块大小不是越小越省钱。块太小,每块都要重复携带上下文和提示词,固定开销占比高;块太大,超出窗口会被截断或者报错。我的经验是3000字左右一块比较平衡,同时给前后各预留200字的重叠区,用来对齐段落边界。
| 场景 | 输入量估算 | 需要注意 |
|---|---|---|
| 短问答 | 几百token | 提示词模板的固定开销占比高 |
| 长文翻译 | 文本量1.5-2倍 | 分块重叠会放大消耗 |
| 代码审查 | 视文件大小 | 代码的token密度比自然语言高 |
| 多轮对话 | 逐轮累积 | 历史消息会一直重复计入 |
3.3 credits和token不是一回事
热搜里出现"credits和token"这个词组,我猜提问的人是在某些平台上看到了两种计量单位。这里要说清楚:token是底层的技术计量单位,credits是平台在token之上包装的一层额度凭证。
平台这么做的原因通常是商业上的灵活。不同模型单价差好几倍,如果直接暴露token价格,用户会反复比较;换算成统一的credits,定价和促销都更好操作。有的平台还会在credits和token之间加一层汇率,不定期调整。
作为使用者,我建议在选型时把credits换算回token单价再对比,否则容易被"每月赠送海量credits"这种表述迷惑。换算方式很简单:找平台的计费说明,看1个credits对应多少token,再乘上模型单价。如果平台不公开这个换算关系,那本身就是个需要警惕的信号。
3.4 上下文超限报错怎么定位
"your request exceeded model token limit"这类报错非常常见,处理套路基本固定:
- 先确认是输入超限还是输入加输出超限。有些模型的窗口是两者之和,你预留的输出空间也算在内。
- 统计历史消息的总长度。多轮对话里最容易被忽略的就是历史累积,聊到第20轮时,前面19轮的问答全都还在上下文里。
- 检查是否有超长文档被整段塞进提示词。这种情况应该改成分块或者检索增强,只把相关内容片段喂进去。
- 加上token计数工具做前置校验。主流模型库都提供了计数方法,在发请求前算一遍,超了就主动裁剪,比等服务端报错再处理体验好得多。
实操心得:不要用字符数除以固定系数来估算token,中英文混杂、代码块、特殊符号都会让误差变得很大。能调用官方的计数接口就调用,一次调用的成本远低于一次失败的请求。
4. 常见报错实战排查:那些让人抓头发的Token故障
这一章是整篇里最实用的部分。我把热搜里出现频率最高的几类报错归了归类,按"看到什么错误、大概率是什么问题、先查哪里"的顺序整理。
4.1 令牌交换失败类报错怎么分层看
"token exchange failed"这类报错看起来吓人,其实可以分层拆解。所谓令牌交换,指的是客户端拿一份凭证去换取另一份令牌的过程——比如拿授权码换访问令牌,拿刷新令牌换新的访问令牌。整条链路可以拆成四层:
| 层级 | 可能的问题 | 典型表现 |
|---|---|---|
| 网络层 | 连不上服务端、超时 | 报错里带"sending request"字样 |
| 客户端配置层 | 客户端ID、密钥、回调地址填错 | 服务端直接拒绝,返回400 |
| 服务端策略层 | 来源受限、账户状态异常 | 返回403,附带动因说明 |
| 令牌本身层 | 过期、格式错、已被使用 | 返回401或明确的invalid提示 |
排查顺序建议从外往里:先确认网络能通,再核对配置项一个字一个字比对,再看服务端返回的具体错误码,最后才怀疑令牌本身。很多人一看到报错就去翻代码,结果发现是配置文件里多了一个空格。
特别注意"error sending request"这种描述,它出现在报错文本里,说明请求根本没发出去或者没收到响应,跟令牌内容没有半点关系。这时候该查的是网络连通性、代理设置、DNS解析,而不是去刷新令牌。
至于返回403并明确提到来源区域受限的情况,这类报错说明服务方对调用来源有明确的地域策略。遇到这种情况,正确做法是联系服务方确认你的使用场景是否在其服务范围内,或者选择在你所在区域提供服务的替代方案。不要在客户端层面做无谓的尝试,那只会浪费时间。
4.2 401和刷新失败怎么一步步拆
"token is invalid"配401,说明服务端认为你带的令牌不合法。排查顺序:
- 令牌有没有带全。有些客户端会在请求头里写成
Bearer后面漏空格,或者把令牌截断了。 - 令牌是不是从正确的环境取的。测试环境的令牌拿到生产环境用,一定失败。
- 签名密钥有没有换过。服务端轮换密钥后,旧令牌全部失效,这是最常见也最难第一时间想到的原因。
- 时钟偏移。
exp和nbf都是按UTC时间比对的,如果客户端或服务端时钟偏差超过几分钟,会出现"刚签发就过期"或"还没生效"的诡异现象。建议验证时留30到60秒的容差。
**"invalid refresh_token: empty string"**这个报错信息其实已经把答案写在脸上了——传过去的刷新令牌是空字符串。常见原因就三个:客户端读取存储时key写错了;刷新令牌存在cookie里但跨域请求没带上;前置代码里有个分支没赋值就往下走了。加个断言在发请求前校验非空,能省掉大量排查时间。
"could not be refreshed, please log out and sign in again",这是刷新令牌本身已经失效。可能是被撤销了、过期了,或者服务端因为安全策略主动清空了全部会话。这种只能重新登录,不用纠结。
4.3 看起来不像Token问题的Token问题
有几类报错容易被误判方向,单独拎出来说。
"文档安全令牌的格式不正确"。这类办公套件集成场景里的"令牌",通常不是身份认证令牌,而是服务端之间约定的一个共享密钥或者签名字符串。报格式不正确,八成是两边的配置文件里这个值不一致,或者一边配了另一边留空。处理方式是比对两端的配置文件,逐字符核对,注意有没有多余的空格或者换行符。这类问题占我遇到的相关工单的绝大多数。
"试图引用不存在的令牌"。这个描述一般出现在模板引擎、文档系统或者流程引擎里,"令牌"指的是一个占位符标识。报这个错说明代码引用了某个已经被清理掉或者从未注册的标识符。排查方向是看这个标识符在哪里注册、什么时候被清理,常见于异步任务里,注册和引用之间存在时间差。
"invalid token image/jpeg"。这是把一串令牌字符串误当成了图片数据去解析。多数出现在缓存或者文件读取环节,路径拼错了,读到了一个文本文件却按图片解码。
"check api token or version"。这类报错来自开发者工具类客户端,通常是访问令牌没配、配错了,或者客户端版本和服务端接口不兼容。先确认令牌来源正确,再确认版本匹配。
4.4 排查速查表
| 报错关键词 | 首先怀疑 | 优先动作 |
|---|---|---|
| sending request failed | 网络不通 | 测连通性,查代理与DNS |
| 403 附来源说明 | 服务端策略限制 | 联系服务方确认可用范围 |
| 401 token is invalid | 令牌不合法或密钥变更 | 核对环境、密钥、时钟 |
| refresh_token empty | 取值链路断了 | 检查存储key与跨域传参 |
| 令牌格式不正确 | 两端配置不一致 | 逐字符比对配置文件 |
| 引用不存在的令牌 | 标识符已失效 | 查注册与清理的时序 |
| exceeded token limit | 上下文超限 | 统计历史消息长度并裁剪 |
| 登录后可刷新但一直失败 | 刷新接口并发冲突 | 检查是否有重复刷新竞态 |
4.5 一个反直觉的经验
排查Token问题,先看完整报错原文,再看代码。现在很多客户端会把错误折叠成一句"登录失败",把后面那句"token endpoint returned status 400"藏起来,而真正有用的信息恰恰在后半句。我养成的一个习惯是,任何登录相关问题,第一件事是打开网络面板或者日志,把原始响应体完整看一遍。九成情况下,问题的原因就写在响应体里,只是被前端友好提示盖住了。
5. 自己动手:做一套可续签、可撤销的令牌方案
概念和排查讲完了,这一章落到底层实现。我按一个中等规模Web服务的需求来设计,目标是把前面提到的机制串起来。
5.1 设计目标和几个关键取舍
需求假设:用户登录后保持登录状态7天;支持主动退出并让令牌立即失效;支持查看登录设备并踢下线;单机加多实例部署。
关键取舍有三处:
签名算法选HS256还是RS256。HS256用对称密钥,签发和验证用同一个密钥,实现简单,适合单体或可信内网。RS256用私钥签发、公钥验证,验证方拿不到签发能力,适合多服务、多团队场景。我的选择是:如果验证方只有自己,用HS256省事;一旦有第三方服务需要验证令牌,必须换RS256。
Access Token有效期设多长。太短会让刷新接口压力大,太长会让撤销延迟大。我一般设15分钟,配合前端提前60秒自动刷新。这个值调过几次,5分钟太频繁,30分钟撤销延迟又太长。
撤销粒度。前面分析过,精确到设备需要会话表。既然需求里有"查看登录设备",那就得建会话表,登录时写一行,包含设备标识、刷新令牌、最后活跃时间。
5.2 核心代码实现
签发部分:
import jwt, time, secrets ACCESS_TTL = 900 # 15分钟 REFRESH_TTL = 7 * 86400 # 7天 SECRET = load_secret() # 从密钥管理服务读取,不要硬编码 def issue_access_token(user_id: int, session_id: str, version: int) -> str: now = int(time.time()) payload = { "sub": str(user_id), "sid": session_id, # 会话ID,用于精确撤销 "ver": version, # 版本号,用于全量撤销 "iat": now, "nbf": now - 30, # 留30秒时钟容差 "exp": now + ACCESS_TTL, } return jwt.encode(payload, SECRET, algorithm="HS256") def issue_refresh_token(user_id: int, session_id: str) -> str: token = secrets.token_urlsafe(48) db.save_session( session_id=session_id, user_id=user_id, refresh_hash=hash_token(token), expires_at=now() + REFRESH_TTL, ) return token刷新令牌在数据库里存的是哈希值而不是明文。这样即使数据库泄露,攻击者也无法直接拿来用。哈希用SHA-256就够,因为它本身已经是高熵随机串,不需要抗暴力破解的慢哈希。
验证部分:
def verify_access_token(token: str) -> dict: try: payload = jwt.decode( token, SECRET, algorithms=["HS256"], # 显式指定,防止算法混淆 leeway=60, # 时钟容差1分钟 ) except jwt.ExpiredSignatureError: raise AuthError("token_expired") except jwt.InvalidTokenError: raise AuthError("token_invalid") # 检查会话是否已被撤销 session = cache.get(f"sid:{payload['sid']}") if session is None: session = db.find_session(payload["sid"]) if session is None or session.revoked: raise AuthError("token_revoked") cache.set(f"sid:{payload['sid']}", session, ttl=300) # 检查用户级版本号 if payload["ver"] != get_user_version(payload["sub"]): raise AuthError("token_revoked") return payload这里有个性能上的考虑:会话检查走缓存,缓存TTL设5分钟。这意味着撤销操作最多5分钟后全局生效。如果业务要求立即生效,就把TTL设短,或者撤销时主动删缓存键。
撤销接口:
def revoke_session(session_id: str): db.mark_session_revoked(session_id) cache.delete(f"sid:{session_id}") def revoke_all_sessions(user_id: int): db.revoke_all(user_id) incr_user_version(user_id) # 版本号加一,所有旧令牌立即失效5.3 有效期参数据怎么定
这几个数字不是拍脑袋来的,我给一下推导过程。
Access Token的15分钟,是从"撤销延迟可接受度"倒推的。如果系统能做到会话级实时撤销,理论上可以设得更长;但如果你的验证路径上有5分钟缓存,那实际最大延迟就是15分钟加5分钟。对一般业务来说20分钟内的延迟可以接受,对金融类业务可能就得设到1分钟。
Refresh Token的7天,是从用户体验反推的。移动端用户希望一个月不用重新登录,Web端用户对一周一次的重新登录容忍度较高。如果是内部系统,可以设到30天;如果是面向公众的高敏感系统,缩短到1天也合理。
时钟容差60秒是因为NTP同步在虚拟化环境里偶尔会漂移几秒到几十秒。设太小会误判有效令牌为过期,设太大会给攻击者留出重放空间。60秒是常见的折中值。
5.4 上线后要盯哪几个指标
令牌系统的故障往往不是突然崩掉,而是慢慢劣化。我一般盯四个数:
- 刷新失败率。突然升高说明可能是密钥轮换出问题或者客户端版本不兼容。
- 平均刷新间隔。如果明显低于Access Token有效期,说明客户端在频繁误刷新,通常是拦截器逻辑写错了。
- 401响应占比。这个数的基线要摸清楚,偏离基线就是信号。
- 单个用户的活跃会话数。异常增多可能意味着令牌泄露被多处使用。
6. 我在Token这件事上踩过的坑
最后这一章不讲原理,讲教训。都是实际项目里真金白银换来的。
6.1 几个当时觉得没问题、事后一身冷汗的做法
把Access Token写进日志。早期的接口日志会把完整请求头打出来,包括Authorization。后来做日志审计时才发现,几个月的日志里躺着几万条有效令牌。现在我的做法是,日志组件里加一层脱敏规则,凡是匹配到Bearer和JWT格式的字符串,一律只保留前8位。这件事没有商量余地。
在localStorage里存Refresh Token。前端图省事,把两个令牌都存localStorage,结果任何一个XSS漏洞都能把长期凭证偷走。现在我的规则是:Access Token放内存变量,页面刷新就重新走一次静默刷新;Refresh Token放HttpOnly加Secure加SameSite的Cookie,前端JS读不到。
把令牌放在URL参数里。早期做文件下载要用令牌鉴权,图方便拼在了查询串里。问题是URL会进浏览器历史、进服务器访问日志、进Referer头。正确做法是在请求头里带令牌,或者用一次性的短期下载票据。
多实例部署时撤销状态放本地内存。开发环境单机跑得好好的,上线后用户反馈"点了退出还能继续用",查了半天发现负载均衡把请求打到了另一台机器上,那台的本地缓存里还有会话。撤销状态必须放共享存储,Redis是最省事的选择。
用字符数估算token用量。这个坑我踩过不止一次。中英混排加代码块的场景下,用字符数除以4估出来的值和实际差了一倍多,导致预算超支。后来老老实实接入了官方的计数方法,在发请求前先算一遍。
6.2 关于"免费Token"这件事
热搜里经常出现"免费token"这类搜索。我的态度很明确:任何声称可以免费提供商业API调用额度的第三方渠道,都要先打个问号。道理很简单,模型推理是有真实算力成本的,没有人会长期做亏本生意。
常见的风险有几种:一是这些渠道可能记录你的全部请求内容,包括你贴进去的业务数据;二是令牌可能随时失效,导致你的业务在关键时刻中断;三是部分渠道的调用路径不合规,可能让你承担额外责任。如果你的项目要长期跑,走官方渠道拿正式额度是最省心的选择,短期成本高一点,长期麻烦少很多。
如果只是想学习和测试,主流平台基本都有免费试用额度或者低价的入门档位,够用来跑通流程。没必要为了省这点钱去冒数据泄露的风险。
6.3 一个实用的小习惯
我现在维护一份团队内部的"Token问题手册",每遇到一个新的报错就补一条,记清楚报错原文、触发场景、根因和解决动作。半年下来攒了四十多条,新人上手时直接查这份表,平均定位时间从半小时降到几分钟。
手册的格式很简单,就是一个表格,四列:报错关键词、完整报错样例、根本原因、处理动作。不用写得很正式,关键是把原始报错完整记录下来。因为很多报错的措辞非常具体,只要关键词对上了,下次遇到几乎能秒判。
另外提醒一句,涉及密钥和令牌的配置文件,永远不要提交到代码仓库。哪怕删掉了,历史提交里还留着。用环境变量或者密钥管理服务,配合一个.gitignore规则,能避免绝大多数低级事故。我在代码审查里看到过太多次"密钥还在历史记录里"的情况,清理起来远比一开始就做对麻烦。