☰
ASP.NET Core JWT刷新令牌安全落地:轮换、存储与重用检测
2026/10/20 13:26:45 网站建设 项目流程

你有没有过这种经历:接手一个后端项目,打开配置文件,看到 JWT 签名密钥就是一个硬编码的短字符串,Access Token 的有效期直接写成 7 天,而刷新令牌明文躺在数据库表里,没有过期时间,也找不到收回逻辑。我当时的第一反应不是“这个系统能跑”,而是“这个方案早晚要出事”。JWT 在很多团队里属于“用了但不完全懂”的技术,刷新令牌机制看起来只是多了一次换发 Token 的动作,实际上牵涉到签名算法、密钥管理、并发控制、客户端存储、吊销策略一整套设计。这篇文章不聊泛泛的概念,只围绕 ASP.NET Core 里刷新令牌机制落地时最容易被忽视的安全细节展开,讲清楚每个环节为什么这么设计,并给出可以直接参考的代码和检查清单。准备接手鉴权相关工作的后端读者,或者已经开始用 JWT 但心里总没底的朋友,都能从这里找到一些可落地的答案。

1. 先回答那个灵魂拷问:你的 JWT 到底在防谁?

1.1 无状态并不等于安全:最常见的五个隐患

JWT 最吸引人的地方是无状态:服务器签发一个令牌,之后每次请求带上它,服务端验签通过就算完成身份认证,不需要查询数据库。这个特性被很多人误读成“安全”本身,实际上它恰恰是 JWT 方案里最需要警惕的部分。所谓无状态,意味着你在签发之后几乎失去了对它的控制——你没法让一个还在有效期内的令牌立刻失效,除非你额外引入状态机制。

常见的隐患我列过无数次,但每次审视新项目时还是会碰到:

第一是签名算法混乱。JWT 的 Header 里有alg字段,常用的是HS256和RS256。如果服务端验证时没有显式指定允许的算法,攻击者可以把算法改成none,或者让服务器误以为 RSA 公钥是 HMAC 密钥来伪造令牌。这类问题在历史上有过非常著名的实际攻击,ASP.NET Core 的TokenValidationParameters里如果没有设置ValidAlgorithms,等于给攻击者留了一扇尝试的门。

第二是签名密钥强度不足。HMAC 系列算法的安全性完全依赖密钥。有些项目的密钥是"my-secret-key"这种字符串,长度远不够 256 位。攻击者拿到几个真实 JWT 后可以离线爆破密钥,一旦爆破成功,就能随意伪造任意用户的令牌。正确的做法是使用至少 32 字节的随机密钥,并以 Base64 形式存储。

第三是 Payload 里塞敏感信息。很多人以为 JWT 是加密的,其实Payload只是 Base64 编码,任何人拿到令牌都能解码出来。手机号、身份证号、权限明细这类数据一旦放进 Payload,就相当于把它写在了公开可读的贴纸上。需要携带的只有用户标识、会话编号这类非敏感信息。

第四是过期时间太长。Access Token 最忌讳的就是“长命”。7 天、30 天的 Access Token 一旦泄露,攻击者可以持续冒用,服务端却毫无感知。JWT 单靠nbf和exp声明只能控制令牌的生命周期,但你真正想控制的是泄露后的影响范围。

第五是无法吊销。JWT 设计之初的假设是“服务端不保留会话状态”,所以没有内置作废能力。没有刷新令牌机制的情况下,用户点“退出登录”只是把本地令牌删掉,服务端根本不知道这个动作,令牌在有效期内依然畅通无阻。这就是为什么单用 JWT 的长效会话在真实业务里几乎总是会被打回票。

1.2 刷新令牌要解决的是哪一类风险

刷新令牌机制不是要取代 JWT,而是要把“长期凭证”拆成两层:短期使用的 Access Token,和相对长期但可控制的 Refresh Token。

Access Token 的有效期设置得很短,通常 5 到 15 分钟。即使它被窃取,攻击者能冒用的时间窗口非常有限。Refresh Token 的有效期可以设置成 7 天、30 天甚至更久,但它保存的是“续签凭证”,而不再是一个直接能访问所有接口的万能钥匙。最关键的是,Refresh Token 是可以被服务端管理的:它能被哈希存储、能被标记为已使用、能被按用户批量吊销、能在异常重用发生时被检测出来。

打个比方,Access Token 就像一张短期的会议通行证,过期就得重新领;Refresh Token 是那张可以申请新通行证的年卡。年卡丢失不能不在意,但服务端至少可以做到:发现有人拿旧卡反复进出时,直接把你名下所有卡都冻结。这套控制力,是传统单一 JWT 方案给不了的。

所以你在设计 JWT 方案时首先要明确一件事:JWT 负责“快速验证”,刷新令牌负责“可控续期”。前者追求无状态和效率,后者必须有状态管理。把这两层混在一起想,是后面所有安全设计的混乱根源。

2. 把刷新令牌机制拆开:轮换、存储与重用检测

2.1 刷新令牌为什么必须是一次性的?

很多团队第一次实现刷新令牌时,写出来的逻辑非常简单:用户拿 Refresh Token 换新的 Access Token,Refresh Token 本身一直不变,直到过期。这个方案最大的问题是“一个令牌走天下”,一旦泄露,攻击者可以在整个生命周期内反复换取 Access Token,而你完全察觉不到。

一次性轮换的意思是:用户每次调用刷新接口,服务端不仅返回新的 Access Token,还必须把旧的 Refresh Token 标记为已使用,同时签发一个新的 Refresh Token。如果把整个会话比作一把钥匙,那么这把钥匙每次开门后都会自动换成新钥匙。攻击者就算偷到了旧钥匙,也只会拿到一把已经报废的残骸。

在设计轮换逻辑时,数据库里有一条关键记录叫ReplacedByTokenHash或类似字段。它记录了“这条 Refresh Token 被哪一条新 Token 替换了”。这个字段的价值不只是为了追踪,更是后面重用检测的基础。

当然,轮换机制会带来一个经典副作用:并发刷新。如果你的客户端在多个标签页同时发起请求,或者多个请求同时遇到 401 后一起触发刷新,两个并发请求可能带着同一个旧 Refresh Token 打到服务端。第一个成功了,第二个就会发现“这个 Token 已经被替换”。这是正常现象,但处理不当会把所有用户踢下线。后面第 4 节我会详细讲怎么从客户端和服务端两个方向同时缓解,这里先记住一个原则:正常轮换和非法重用,在服务端必须能被区分。

2.2 服务端如何安全存储刷新令牌:哈希而不是明文

刷新令牌的随机性和不可预测性是第一道防线。生成时不要用Guid.NewGuid().ToString(),也不要直接用时间戳拼接,这些都太容易预测。应该使用加密安全的随机数生成器,例如RandomNumberGenerator.GetBytes(64),总共 512 位随机性,然后转成 Base64 字符串发给客户端。服务端只保存它的哈希值,一般是 SHA-256 即可。

数据库表结构我习惯这么设计:

CREATE TABLE RefreshTokens ( Id BIGINT PRIMARY KEY IDENTITY(1,1), UserId NVARCHAR(64) NOT NULL, FamilyId UNIQUEIDENTIFIER NOT NULL, TokenHash CHAR(64) NOT NULL, ClientFingerprint NVARCHAR(200) NULL, ExpiresAtUtc DATETIME2 NOT NULL, RevokedAtUtc DATETIME2 NULL, ReplacedByTokenHash CHAR(64) NULL, CreatedAtUtc DATETIME2 NOT NULL, LastUsedAtUtc DATETIME2 NULL ); CREATE INDEX IX_RefreshTokens_TokenHash ON RefreshTokens(TokenHash); CREATE INDEX IX_RefreshTokens_UserId ON RefreshTokens(UserId); CREATE INDEX IX_RefreshTokens_FamilyId ON RefreshTokens(FamilyId);

TokenHash字段是查询入口,所以必须建索引。FamilyId用于把“同一次登录会话”产生的一系列刷新令牌归组。ClientFingerprint用来记录签发时的设备信息、IP、浏览器特征等,刷新时做比对,一旦发现指纹变化,立即触发告警和吊销。RevokedAtUtc标记主动吊销或已被替换的时间,ReplacedByTokenHash指向新令牌的哈希。

为什么服务端绝不能存明文?因为数据库泄露是迟早要面对的现实。如果存的是明文,攻击者拿到表就等于拿到了所有会话凭证,想伪造谁就伪造谁。如果存的是哈希,攻击者虽然能看到一串哈希值,但哈希输入是 512 位随机数,根本无法反推出原始令牌。存储哈希的代价几乎为零,换来的却是数据库失守时最重要的缓冲。

2.3 重用检测:当攻击者盗走 Refresh Token 后如何止损

重用检测是刷新令牌机制相对传统 Session 的一个巨大优势。场景是这样的:攻击者通过某种方式拿到了用户的 Refresh Token,并且在用户之前使用了它。服务端验证通过,给攻击者签发了新的 Access Token 和新的 Refresh Token,同时把旧的 Refresh Token 标记为已使用。接下来用户再用旧 Refresh Token 刷新时,服务端发现它已经被替换,这就不再是单纯的“Token 无效”,而是强烈的泄露信号。

我推荐的处理方式是:一旦检测到“已被标记为已使用或已替换的 Refresh Token 再次出现”,立即撤销整个FamilyId下的所有刷新令牌。因为攻击者很可能已经拿到了这个家族里最新的 Refresh Token,只撤销当前这一条远远不够。必要的时候,还可以把该用户的所有会话全部踢下线,强制重新登录。

具体实现时,刷新接口里要区分几种情况:查不到记录、已过期、已吊销、家族内已重用。这些状态在安全层面的严重程度不同。前几种只拒绝本次请求,后一种必须执行家族撤销。如果ClientFingerprint不匹配,同样建议立即撤销整个家族,因为极有可能是令牌在传输过程中被截获并被其他设备使用。

有一个细节容易被忽略:谁来判断“被替换”和“被重用”?两条路径共用同一条记录时,必须保证并发安全。ASP.NET Core 里最稳妥的方式是把“标记旧令牌 + 插入新令牌”放进同一个数据库事务,并使用行锁或Serializable隔离级别,防止两个请求同时读到同一个旧令牌的未替换状态。

3. ASP.NET Core 落地实现:从配置到中间件的完整链路

3.1 配置、密钥管理与签名算法的固定

开始写代码之前,先把配置模型的边界定清楚。下面是一份常见的JwtOptions:

public sealed class JwtOptions { public const string SectionName = "Jwt"; public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; public string KeyId { get; set; } = "2024-signing-key-v1"; public string SigningKeyBase64 { get; set; } = string.Empty; public int AccessTokenMinutes { get; set; } = 10; public int RefreshTokenDays { get; set; } = 30; }

配置文件里:

{ "Jwt": { "Issuer": "https://api.your-service.com", "Audience": "https://app.your-service.com", "KeyId": "2024-signing-key-v1", "SigningKeyBase64": "请使用环境变量或密钥管理服务注入", "AccessTokenMinutes": 10, "RefreshTokenDays": 30 } }

一个重要的经验是不要把SigningKeyBase64真的写进appsettings.json。本地开发可以临时放一放,但一旦进了版本库,它就成了永久的历史包袱。密钥应该通过环境变量、密钥管理服务或者部署平台的安全配置注入。很多人觉得麻烦,但等密钥真正泄露一次,你就知道这个步骤值多少钱。

注册 JWT Bearer 验证时,除了常规的Issuer、Audience、Lifetime校验,一定要把签名算法固定下来:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtOptions.Issuer, ValidateAudience = true, ValidAudience = jwtOptions.Audience, ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), ValidateIssuerSigningKey = true, IssuerSigningKeys = jwtOptions.ResolveSigningKeys(), ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 } }; });

ValidAlgorithms是很多现有项目里缺失的一项。没有它,系统可能接受alg=none或其他算法,等于把验签逻辑交给了攻击者可控的输入。

3.2 刷新令牌服务:签发、轮换与吊销的核心实现

刷新令牌服务是整个鉴权体系的“心脏”。我直接给出一个可运行的实现骨架,逻辑上覆盖了签发、哈希存储、轮换、重用检测和家族撤销:

public sealed class RefreshTokenService { private readonly AppDbContext _db; private readonly JwtOptions _options; public RefreshTokenService(AppDbContext db, IOptions<JwtOptions> options) { _db = db; _options = options.Value; } public RefreshTokenRecord CreateRefreshTokenRecord(string userId, string fingerprint) { var rawToken = GenerateRefreshToken(); var familyId = Guid.NewGuid(); var record = new RefreshTokenRecord { UserId = userId, FamilyId = familyId, TokenHash = ComputeTokenHash(rawToken), ClientFingerprint = fingerprint, ExpiresAtUtc = DateTime.UtcNow.AddDays(_options.RefreshTokenDays), CreatedAtUtc = DateTime.UtcNow }; _db.RefreshTokens.Add(record); return record; } public TokenResult Refresh( string rawRefreshToken, string fingerprint) { var hash = ComputeTokenHash(rawRefreshToken); var record = _db.RefreshTokens .FirstOrDefault(x => x.TokenHash == hash); if (record is null) throw new SecurityException("刷新令牌不存在"); if (record.RevokedAtUtc is not null) throw new SecurityException("刷新令牌已吊销"); if (record.ReplacedByTokenHash is not null) { RevokeFamily(record.FamilyId); throw new SecurityException("检测到刷新令牌重用,已撤销整个令牌族"); } if (record.ExpiresAtUtc <= DateTime.UtcNow) { RevokeFamily(record.FamilyId); throw new SecurityException("刷新令牌已过期"); } if (!string.Equals(record.ClientFingerprint, fingerprint, StringComparison.Ordinal)) { RevokeFamily(record.FamilyId); throw new SecurityException("客户端指纹不匹配,可能存在令牌被盗"); } var newRawToken = GenerateRefreshToken(); var newHash = ComputeTokenHash(newRawToken); using var tx = _db.Database.BeginTransaction(); record.ReplacedByTokenHash = newHash; record.RevokedAtUtc = DateTime.UtcNow; _db.RefreshTokens.Add(new RefreshTokenRecord { UserId = record.UserId, FamilyId = record.FamilyId, TokenHash = newHash, ClientFingerprint = fingerprint, ExpiresAtUtc = DateTime.UtcNow.AddDays(_options.RefreshTokenDays), CreatedAtUtc = DateTime.UtcNow }); _db.SaveChanges(); tx.Commit(); var accessToken = JwtTokenFactory.CreateAccessToken(record.UserId, _options); return new TokenResult(accessToken, newRawToken); } private static string GenerateRefreshToken() { var bytes = RandomNumberGenerator.GetBytes(64); return Convert.ToBase64String(bytes); } private static string ComputeTokenHash(string token) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); return Convert.ToHexString(bytes); } private void RevokeFamily(Guid familyId) { var tokens = _db.RefreshTokens .Where(x => x.FamilyId == familyId && x.RevokedAtUtc == null) .ToList(); foreach (var token in tokens) { token.RevokedAtUtc = DateTime.UtcNow; } _db.SaveChanges(); } }

这个实现看起来简单,但已经包含了一个完整的令牌生命周期。注意我特意没有在业务代码里捕获SecurityException后返回 200,而是让异常向上传播,由过滤器或中间件统一转成 401。这样日志和审计可以统一记录异常原因。

还有一点必须强调:RevokeFamily不只是把“当前记录”撤销,而是把整个FamilyId下的所有有效记录全部撤销。因为攻击者可能已经完成了第一次刷新,握有同一家族里的最新令牌。只撤销当前这条等于留着后门。

3.3 刷新端点、Token 校验中间件与客户端存储

刷新端点本身只是一个薄层,真正的逻辑都在RefreshTokenService里。比较典型的写法:

[HttpPost("refresh")] public IActionResult Refresh() { // 推荐把 Refresh Token 放在 HttpOnly Cookie 中读取 var rawToken = Request.Cookies["refresh_token"]; if (string.IsNullOrEmpty(rawToken)) return Unauthorized(); var fingerprint = HttpContext.Connection.RemoteIpAddress?.ToString() + Request.Headers.UserAgent.ToString(); try { var result = _refreshTokenService.Refresh(rawToken, fingerprint); Response.Cookies.Append("refresh_token", result.RefreshToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Path = "/auth" }); return Ok(new { accessToken = result.AccessToken }); } catch (SecurityException) { Response.Cookies.Delete("refresh_token"); return Unauthorized(); } }

关于 Refresh Token 存储,我在项目里的默认选择是 HttpOnly Cookie,而不是 LocalStorage。LocalStorage 的优点是被脚本读取方便,但 XSS 一发作,令牌就跟着脚本一起被带走了。HttpOnly Cookie 能挡住 JavaScript 读取,代价是会受到 CSRF 影响。所以我把 Cookie 的SameSite设为Strict,Secure设为true,同时把Path限制在/auth,减少每次请求自动携带的暴露面。Access Token 则保留在内存或 sessionStorage 里,用它作为接口请求的 Authorization Header。

关于中间件自动续期,我一直不太推荐“透明续期”。有些项目习惯在 401 时自动刷新后重放原请求,这是一个体验优化,不是安全设计。做是可以做的,但要保证客户端有单飞机制,绝对不能每个并发请求各自刷新一次。

4. 上线后才会暴露的问题:并发刷新、密钥轮换与分布式吊销

4.1 并发刷新导致旧令牌被“一刀切”的坑

这是轮换机制上线后最容易翻车的地方。用户在浏览器开了多个标签页,Access Token 在同一时间过期。每个标签页都发请求,每个请求都收到 401,然后每个请求都拿着同一个 Refresh Token 去打刷新接口。服务端事务里第一个请求成功,旧令牌被标记为已替换,第二个请求进来后发现“这是一个已使用过的令牌”,于是触发重用检测,整个令牌族被撤销,用户被强制重新登录。

最严重的情况是线上多个客户端同时出问题,短时间内大量用户被踢下线。我在项目里见过一次,那一整天都在处理“怎么回事,我明明没做错什么”的工单。

解决方案要分两端。服务端必须保证原子性和重用检测不放松,但客户端要把并发刷新收敛成一个请求。思路是让所有 401 的请求共享同一个刷新 Promise:

let refreshPromise = null; async function requestWithRefresh(url, options) { const response = await fetch(url, options); if (response.status !== 401) { return response; } if (!refreshPromise) { refreshPromise = refreshAccessToken().finally(() => { refreshPromise = null; }); } await refreshPromise; options.headers.Authorization = `Bearer ${getAccessToken()}`; return fetch(url, options); }

这样无论多少个请求同时遇到 401,最终只会有一个刷新请求发到服务端,其余请求等它完成后再重放。服务端看到的仍然是严格轮换和严格重用检测,客户端体验却是连贯的。

另一个思路是在服务端做“宽限期兼容”,例如允许同一个旧令牌在替换后 10 秒内换取最新的刷新令牌,但这会削弱重用检测的敏感度。我现在倾向于不做服务端宽容,宁可让客户端把并发控制做好。安全机制一旦为了体验开口子,攻击面就会沿着口子一路变大。

4.2 分布式环境下签名密钥的一致性与密钥轮换

JWT 在分布式部署下一旦签名密钥配置不一致,就会出现“这个实例签发的 Token,另一个实例不认”的问题。很多团队图省事,直接把同一个 Base64 密钥复制到所有实例的环境变量里。这能解决一致性问题,但会引入密钥轮换的困境。

要支持密钥轮换,需要给签名密钥一个KeyId,签发时把它写进 JWT Header 的kid字段。验证端的TokenValidationParameters维护一个密钥列表,并根据请求里的kid选择对应密钥。轮换流程分为三步:

  1. 生成新密钥,把新密钥和 KeyId 加入配置中心;
  2. 发布新的IssuerSigningKeys列表,新旧密钥同时有效;
  3. 等所有用旧密钥签发的 Access Token 自然过期后,把旧密钥从列表里移除。

这样不会出现“新密钥一上线,所有用户令牌立刻失效”的灾难。在 ASP.NET Core 里可以这样实现密钥解析:

options.TokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => { return jwtOptions.SigningKeys .Where(key => key.KeyId == kid) .Select(key => key.SecurityKey); };

还要注意,刷新令牌本身并没有被签名,它只是数据库里的随机哈希串,所以不受签名密钥轮换影响。Access Token 的时效很短,新旧密钥并存几天就能平滑过渡。密钥轮换的周期建议和 Access Token 过期时间挂钩,至少保留旧密钥的时长要超过最长 Access Token 有效期。

4.3 登出、改密、封号场景下的实时吊销方案

JWT 无状态,登出只删本地 Token 没有任何服务端效果。要真正让一个用户的会话失效,最直接的办法是把他的 Refresh Token 家族全部吊销。这样即使 Access Token 还在有效期内,他也无法续期。这个方案简单可靠,但 Access Token 本身仍然可能还剩几分钟的存活时间,严格来说不是“立即失效”。

需要立即失效的场景通常和敏感操作绑定:用户修改密码、管理员封禁账号、检测到异常登录。这时候刷新令牌的吊销已经不够了,Access Token 也要被动态校验。我常用的方案有两种。

第一种是“会话版本号”。在用户表里加一个SessionVersion字段,签发 Access Token 时把它放进sid声明。每次改密、封号、强制登出时递增版本号。JWT Bearer 校验通过后,在OnTokenValidated事件里读取当前用户的SessionVersion,和令牌里的sid比较,不一致就拒绝。这个方案相当于每个请求多一次缓存或数据库查询,但换来了真正的实时吊销能力。

第二种是 DenyList。维护一个 Redis 缓存,Key 是令牌的jti,Value 是吊销时间。登出时把当前 Access Token 的jti写进去,过期时间设为令牌剩余寿命。验证时先查缓存:

options.Events = new JwtBearerEvents { OnTokenValidated = async context => { var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; if (!string.IsNullOrEmpty(jti)) { var denied = await cache.GetAsync($"jti:{jti}"); if (denied is not null) { context.Fail("令牌已被吊销"); } } } };

两种方案可以并存:DenyList 处理精确到单条 Access Token 的吊销,会话版本号处理整类会话的失效。刷新令牌家族的吊销处理长期会话的持久失效。三层配合起来,才算基本覆盖了日常业务里所有登录态控制的诉求。

5. 给现有方案做一次安全体检:自查清单与经验教训

5.1 带这份清单去审视你自己的代码

我平时做代码评审时,不会一开始就看业务逻辑,而是先顺一遍鉴权链路。下面这个自查清单是我常用来评估 JWT 方案的,建议你直接把表格复制过去,对照自己的项目逐项检查。

检查项危险信号建议
签名算法是否固定代码里没有ValidAlgorithms,或依赖默认行为只允许显式配置的算法集合
签名密钥强度密钥是短字符串,或写在配置文件里使用 32 字节以上随机密钥,经环境变量或密钥管理服务注入
Access Token 有效期超过 30 分钟建议 5 到 15 分钟
Payload 内容包含手机号、身份证等敏感字段只放用户 ID、会话版本、角色等非敏感声明
刷新令牌是否哈希存储数据库里能看到原始令牌字符只保存 SHA-256 哈希
刷新令牌是否轮换每次刷新用同一个 Refresh Token每次刷新签发新令牌并吊销旧令牌
是否有重用检测被替换的令牌再次出现时只报无效立即撤销整个令牌族
是否记录设备指纹刷新时不校验 IP、UA 等上下文存入指纹字段并在刷新时比对
登出/改密/封号是否吊销服务端没有任何吊销动作撤销家族、提升会话版本、写 DenyList
浏览器存储方式Refresh Token 放在 LocalStorage改用 HttpOnly + Secure + SameSite Cookie,或至少做严谨的 XSS 防护
日志是否泄漏令牌请求日志里打印 Authorization Header脱敏后再记录,只保留令牌前缀或哈希

自查时最需要注意的是那些“看起来能跑”的代码。比如刷新接口返回 200 但没有轮换,或者数据库表里有TokenHash字段但生成 Token 用的是Guid.NewGuid(),这些都是隐蔽的风险点,业务不触发异常时根本不会有人注意。

5.2 我踩过的坑和一些实用性建议

最后分享几个我在实际项目里踩过的坑,希望能给你省下一点时间。

第一次做刷新令牌时,我把重心全部放在了“怎么签发、怎么刷新”上,没有考虑并发。上线第二天就被多标签页用户搞出了大规模的强制下线。那次之后我在客户端加上了单飞刷新,服务端的事务也改成了严格模式,并且把“重用检测”和“并发刷新”的边界写进了技术文档。之后再也没有因为并发问题大面积踢过人。

另一次是在一个旧项目里,Refresh Token 存在 LocalStorage,恰好前端又引入了一个有漏洞的第三方脚本,被注入了窃取令牌的代码。好在那次发现及时,但教训很深。之后我所有涉及登录态的项目都默认用 HttpOnly Cookie 承载刷新令牌。这个选择会带来 CSRF 的顾虑,但用SameSite=Strict加上Path=/auth限制后,风险已经控制在可以接受的范围。

还有一个容易被忽略的细节:日志。调试期间为了方便排查问题,我一度把 Authorization Header 原样打印到日志里,结果日志系统接入第三方分析平台时,Token 就这么被“顺便”同步出去了。现在我对所有日志中间件都会做一层脱敏处理。JWT 本身是一种可被重放的凭证,它出现在日志、缓存、监控系统里的次数越少,泄露面就越小。

如果你现在对照自查清单发现自己的项目里有好几项危险信号,不用急着推倒重来。大多数问题都可以渐进式修复:先用最短的时间把签名算法固定、密钥强度提上来、Access Token 有效期压短,再着手做刷新令牌的哈希存储和轮换。把这三件事理顺后,整个鉴权链路的安全水位已经和之前完全不同了。

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

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

立即咨询