前后端分离的项目里,Token 过期让用户重新登录,这体验太差了。
双 Token 机制就是为了解决这个问题——一个短时效的 Access Token 走接口鉴权,一个长时效的 Refresh Token 专门用来续期。用户甚至感知不到 Token 有过期这回事。
今天用 .NET 8 把整套流程跑通,登录、发 Token、过期刷新、强制下线,全部配齐。
双token机制是什么
先从一个最朴素的问题说起:你把 Web API 接口暴露出去,总不能谁都能随便调吧?
接口鉴权是底线。不加鉴权的 API,等于把数据库的大门敞开,别人想怎么拿数据都行,更别提恶意刷接口、耗尽服务器资源这类攻击了。所以绝大多数 Web API 都会要求客户端在请求时带上一张“通行证”。
这张通行证,目前业界最通用的方案是 JWT(JSON Web Token)。
JWT 本质上是一串经过签名的 Base64Url 编码字符串,里面装了三部分:头部(算法类型)、载荷(用户身份信息、过期时间等)、签名(防止篡改)。服务端收到 JWT 后验签通过,就从载荷里读出“这是谁”,不再需要查一次数据库——这也是它被叫做“无状态认证”的原因。
JWT 官网:
https://jwt.io/
那问题来了:只发一个 JWT,也就是单 Token 方案,会怎样?
流程上很简单:登录成功 -> 服务端生成一个 JWT -> 客户端存起来 -> 后续请求塞到 Authorization Header 里。看似没什么毛病,但实际跑起来有几个绕不过去的坎:
过期时间设短了:
用户正在填表单,突然 Token 过期,接口全挂,只能跳登录页,体验稀碎。 过期时间设长了:
Token 有效期拉长到 7 天甚至 30 天,被盗用的风险成倍增加。而且 JWT 一旦签发,在过期之前服务端没法主动让它失效(除非额外做黑名单)。 刷新只能靠重新登录:
Token 过期后没有任何“续期”手段,用户必须重新输一遍密码。
这就是单 Token 方案的尴尬——安全性和体验之间只能二选一。
双 Token 机制就是冲着这个问题来的。
它的核心思路很朴素:两个 Token,各司其职,分开放。
Access Token 短,即使被盗,攻击窗口有限。Refresh Token 长,但只在“换新 Token”这个特定场景下使用,不参与业务接口鉴权,暴露面小很多。
流程上,客户端在 Access Token 过期后,拿着 Refresh Token 去调用刷新接口,服务端验证 Refresh Token 有效后,签发一个新的 Access Token(有时也可以一起刷新 Refresh Token)。整个过程用户无感知,体验上相当于 Token“自动续期”。
工程价值主要体现在这三条:
安全性提升:
Access Token 短时效,泄露后可用窗口小;Refresh Token 路径单一,攻击面窄。 用户体验改善:
后台静默续期,用户不需要反复登录。 服务端可管控:
Refresh Token 存储在服务端(数据库或 Redis),可以随时撤销,实现“强制下线”“踢人”等操作,弥补了 JWT 无状态难以主动失效的短板。
关于“状态”需要多说一句:双 Token 机制本质上是一种混合状态架构——业务接口的鉴权保持绝对无状态(微服务间传递 Access Token 即可完成鉴权,无需查库),同时仅将状态收拢在 Refresh Token 的存储中。用极小的有状态代价,换取了对用户凭证的可控能力和更高的安全水位。
接下来我们把这套机制在 .NET 8 里完整落地。
怎么引入
需要安装的包:
执行以下命令安装 JWT 处理库:
这个包提供了 JWT 的创建、序列化与验证能力。另外,如果你打算把 Refresh Token 存数据库,顺便把 EF Core 装上;如果存 Redis,装对应的 Redis 客户端即可。正文不展开存储层细节,按你实际选型来。 Program.cs 最小接入配置(必须项 / 可选项): 以下配置以 JWT Bearer 认证为核心,后续所有受保护接口都走这个方案。 配置文件(appsettings.json)需要添加 JwtSettings 节点: 如何验证是否接入成功? 新建一个标记 dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
[Authorize] 的 GET 接口,不携带 Authorization Header 请求时返回 401;携带有效 JWT 时返回 200。两个状态码都符合预期,说明中间件已生效。
快速上手
这里给出 4 个由浅入深的示例,覆盖登录发双 Token、接口鉴权、刷新续期、强制注销。
示例 1:登录接口——生成 Access Token + Refresh Token
这是整套机制的入口。登录成功后同时签发两个 Token。
预期输出(登录成功):
验证步骤: 调用 /api/auth/login,拿到两个 Token。之后受保护接口的请求头中加入 Authorization: Bearer {access_token},就能正常访问。
示例 2:受保护接口 + Access Token 校验
用 [Authorize] 标记一个需要登录才能访问的接口,演示 Token 如何被自动校验。
验证步骤:
不携带 Header 调用,返回 401。 携带有效 Access Token 调用,返回 200 和用户信息。 等待 Token 过期(或手动将 exp设为过去时间),再携带过期 Token 调用,返回 401(具体是WWW-Authenticate: Bearer error="invalid_token")。
示例 3:刷新接口——用 Refresh Token 换新的 Access Token(安全版)
这是双 Token 机制的核心动作。关键点:刷新接口必须同时校验旧的 Access Token 和 Refresh Token,且两者必须属于同一用户。
请求示例:
预期输出:
验证步骤:
正常登录拿到 Access Token 和 Refresh Token,Refresh_token 有效期有两种处理方式:1. 接口动态返回;2. 前后端约定固定时长。 等待 Access Token 过期(或手动调系统时间)。 用过期的 Access Token 调用 /api/auth/profile,返回 401。调用 /api/auth/refresh同时传入过期的 Access Token 和 Refresh Token,拿到新的 Access Token。用新 Access Token 再次调用 /api/auth/profile,恢复正常。尝试用篡改过的 Access Token + 正确的 Refresh Token 调用刷新接口,返回 401(签名校验失败)。 尝试用正确的 Access Token + 不匹配的 Refresh Token 调用刷新接口,返回 401(用户不匹配)。
这个设计堵住了一个常见的安全漏洞:即使 Refresh Token 泄露,攻击者如果没有同时拿到对应的 Access Token(且不知道其中的用户身份),也无法成功刷新。
示例 4:强制注销(Refresh Token 主动失效)
这是双 Token 相比单 Token 的另一个优势——服务端可以主动让 Refresh Token 失效,从而实现“踢人下号”或“修改密码后全端下线”。
验证步骤:
登录后调用 /api/auth/logout传入 Refresh Token。服务端将该 Refresh Token 从存储中删除。 再拿着这个 Refresh Token 去调用 /api/auth/refresh,返回 401(因为已找不到)。
生产环境说明:
ConcurrentDictionary只是演示。真实场景建议用 Redis(设置过期时间,自然过期)或数据库(增加is_revoked字段,手动标记失效)。
实战中最常见的 2~3 个报错:
401 UnauthorizedWWW-Authenticate: Bearer error="invalid_token" | ||
401 Unauthorized | Authorization: Bearer xxx | |
SecurityTokenInvalidAudienceException | Audience 与 ValidAudience 是否一致 | |
适用场景
单体和一般 Web 应用:
双 Token 机制完全适用。如果你的应用是标准的浏览器/移动端 + Web API,用户需要登录且对体验有一定要求,这套方案直接落地即可。前置条件是服务端需要有存储 Refresh Token 的地方(数据库或 Redis)。
分布式和微服务:
在这类场景下优势更明显。Access Token 的无状态特性使得它可以在各个微服务间轻松传递,无需共享 Session。Refresh Token 集中存储在一个地方(比如用户中心服务或 Redis),各服务通过统一的认证网关校验 Access Token 的有效性即可。边界在于:如果服务间调用链很长,Access Token 的验证开销会累积,此时可以考虑在网关层统一做验证,内部服务间用更轻量的机制。
高并发场景:
Access Token 的验证是 CPU 密集操作(非对称加密验签成本较高),如果采用对称签名(HMAC),性能会好很多。Refresh Token 的请求量远低于普通业务接口,一般不会成为瓶颈。但如果用户量极大(千万级),Refresh Token 的存储查询可能成为热点,需要做好缓存和索引。
风险:
客户端(尤其是 SPA 应用)如果直接把 Refresh Token 存 LocalStorage,仍然面临 XSS 风险,这个和单 Token 一样。更稳妥的方式是结合 HttpOnly Cookie 存放 Refresh Token(后续单独聊)。 Refresh Token 的存储引入了状态,不再是纯粹的无状态方案,架构上需要额外考虑存储的可用性和一致性。
实战建议
以下 3 个坑位来自真实项目经验,值得提前规避。
坑位 1:Refresh Token 无有效期约束,永久有效
触发条件:
生成 Refresh Token 时只存了字符串,没有设置过期时间,也没有在存储层加 TTL。 错误表现:
Refresh Token 永不失效,即使 Access Token 不断刷新,Refresh Token 始终可用。一旦泄露,等于永久后门。 修复策略:
Refresh Token 在服务端存储时附带过期时间字段,验证时同时检查是否超期。 存 Redis 时直接设置 TTL(例如 7 天)。 做 Refresh Token 轮换——每次刷新时同时换发新的 Refresh Token,旧的一律失效。
坑位 2:并发刷新导致 Refresh Token 被提前消耗(仅出现在启用轮换时)
触发条件:
当你开启了更高级的安全策略——Refresh Token 轮换(每次刷新都换发新 Refresh Token)时,前端多个请求同时发现 Access Token 过期,同时发起刷新请求,都带着同一个 Refresh Token。 错误表现:
第一个刷新请求成功后,服务端轮换了 Refresh Token,后续的刷新请求拿着旧 Refresh Token 被拒绝,导致用户被强制下线。 修复策略:
前端做请求队列或锁机制:同时只有一个请求执行刷新,其他请求等待并复用新 Token。 服务端支持“一次刷新,所有旧 Refresh Token 仍然有效 30 秒”的宽容窗口(需要设计 Token 版本号或父级 Token 关联)。 如果不启用轮换,Refresh Token 复用的情况下不会触发此问题,但安全性略低。
坑位 3:服务器时间不同步导致 Token 被误判过期
触发条件:
多台服务器部署,其中一台系统时间比另一台慢了几分钟。客户端请求轮询到慢服务器上生成的 Token,在快服务器上验证时被判定为已过期。 错误表现:
同样的 Token,有时候有效有时候无效,排查极其困难。 修复策略:
所有服务器统一配置 NTP 时间同步。 设置 ClockSkew = TimeSpan.FromMinutes(5)(已在 Program.cs 中配置),容错时间偏移。
选型建议
什么场景更适合采用双 Token 机制:
面向 C 端用户的产品,登录体验是核心考量指标之一。 Token 有效期需要设置得比较短(安全要求高),但又不希望用户频繁登录。 有服务端强制用户离线的需求(如管理后台踢人、用户修改密码后全端登出)。 团队有 Redis 或数据库等存储设施可用,能承接 Refresh Token 的存储需求。
什么场景不太适合:
纯内部系统、用户量极少、安全要求不高,单 Token 设长一点就够用了。 无状态服务的极致追求者——双 Token 的 Refresh Token 存储引入了状态,打破了 JWT“完全无状态”的初衷。 客户端是服务间调用(Server-to-Server),没有“用户感知”问题,且调用频率固定,单 Token 设合理时长即可。 小团队短期项目,开发资源紧张,双 Token 的客户端逻辑(过期检测、刷新拦截、队列控制)需要一定的开发量。
参考建议:
团队规模 3~5 人、并发量不高、上线周期紧张的项目,可以先用单 Token 设较长时间(如 24 小时),后期再升级到双 Token。团队 10 人以上、面向线上用户的正式产品,建议第一版就上双 Token——后续切换成本比一开始就做要高得多。
总结收尾
单 Token 方案最大的问题,不是 JWT 本身不行,而是它在“安全”和“体验”之间让你做选择题。双 Token 把这两个目标解耦了——Access Token 保安全,Refresh Token 保体验,各司其职。
可以直接执行的落地建议: 下周要上线的项目,把文中示例 1 到示例 3 的代码直接粘过去,改一下 SecretKey 和配置文件,Refresh Token 存储换成 Redis(设好 TTL),前端配合做一下 401 拦截刷新逻辑,整套双 Token 机制一天之内就能跑通。
额外多说一句: 这套双 Token 的认证骨架,一旦搭好、跑通、踩完坑,它就是一笔可以反复复用的资产。下一个新项目来了,把 AuthController、JWT 配置、Refresh Token 存储层这堆东西整个目录拷贝过去,改改配置文件和 Claim 字段,十分钟认证体系就有了。你不必每次都从零开始搭一遍。
所以有空的话,还是争取把这块基础架构认真写一写、沉淀下来。花一天时间打磨好模板,后面每一个项目都能省出两到三天的前期搭建时间,这笔账怎么算都不亏。
以上代码基于 .NET 8 编写,在你的项目里微调下 Claim 和存储层实现,基本能无缝复用。如果你在接入过程中遇到并发刷新、Refresh Token 轮换、或者跨域携带 Header 的问题,欢迎在评论区留言,我看到都会回复。