用户在页面里填了一整页表单点了提交——啪跳回登录页。JWT 过期了刷新令牌没接上所有数据白填。这篇文章讲 Blazor 里怎么正确实现 JWT 刷新令牌让用户无感续期。先说为什么 Blazor 里这个问题特别恶心。在普通 SPAReact/Vue里JWT 存在 localStorage请求拦截器统一加 header401 时自动刷新——一套 Axios 拦截器搞定。但 Blazor 不一样Blazor Server 跑在服务端Blazor WebAssembly 跑在浏览器里两种模式下 token 的存储位置、刷新时机、信号传递方式完全不同。而且 Blazor 的组件状态是活的——用户在页面上操作时组件不会重新渲染你没法靠页面刷新来触发重新认证。必须在 HTTP 层静默处理。01 为什么 JWT 过期会踢人JWT 有过期时间exp claim。过期后API 服务器验证失败返回 401。如果你的应用不做刷新401 的处理就是跳登录页。问题是——JWT 的过期时间通常很短15-30 分钟这是安全设计。长过期时间 token 被盗后攻击窗口大。所以业界做法是短 access token 长 refresh tokenAccess Token — 15-30 分钟过期放 Authorization header Refresh Token — 7-30 天过期存安全位置httpOnly cookie / protected storage ↓ access token 过期时用 refresh token 换新的用户无感续期的流程是① 请求 API → 带 access token ② API 返回 401 → access token 过期了 ③ 拦截 401 → 用 refresh token 调 /refresh 端点换新 access token ④ 拿到新 token → 重放原始请求 ⑤ 用户完全无感表单数据不丢坑在哪Blazor 的 HttpClient 不是浏览器 fetch不能简单套 Axios 那套。Blazor Server 的 HttpClient 是 HttpClientFactory 创建的Blazor WebAssembly 的 HttpClient 是 WASM runtime 里的——两者拦截 401 的方式完全不同。02 Blazor ServerDelegatingHandler 拦截Blazor Server 跑在服务端HttpClient 可以套 DelegatingHandler——这是 .NET 原生的 HTTP 管道机制所有请求经过 handler 链。核心思路自定义一个AuthTokenHandler在 SendAsync 里自动加 token401 时自动刷新。public class AuthTokenHandler : DelegatingHandler { private readonly TokenService _tokenService; public AuthTokenHandler(TokenService tokenService) { _tokenService tokenService; } protected override async TaskHttpResponseMessage SendAsync( HttpRequestMessage request, CancellationToken ct) { // 1. 加 access token var token await _tokenService.GetAccessTokenAsync(); if (!string.IsNullOrEmpty(token)) request.Headers.Authorization new AuthenticationHeaderValue(Bearer, token); // 2. 发请求 var response await base.SendAsync(request, ct); // 3. 401 → 尝试刷新 if (response.StatusCode HttpStatusCode.Unauthorized) { var refreshed await _tokenService .TryRefreshTokenAsync(); if (refreshed) { // 4. 重放原始请求 var newToken await _tokenService .GetAccessTokenAsync(); request.Headers.Authorization new AuthenticationHeaderValue( Bearer, newToken); // 注意HttpClient 会缓存请求 // 需要克隆一份再发 response.Dispose(); var retry await CloneAndSendAsync( request, ct); return retry; } } return response; } private async TaskHttpResponseMessage CloneAndSendAsync( HttpRequestMessage req, CancellationToken ct) { var clone new HttpRequestMessage(req.Method, req.RequestUri); // 克隆 content如果有的话 if (req.Content ! null) { var bytes await req.Content.ReadAsByteArrayAsync(ct); clone.Content new ByteArrayContent(bytes); } foreach (var h in req.Headers) clone.Headers.TryAddWithoutValidation( h.Key, h.Value); return await base.SendAsync(clone, ct); } }关键细节请求克隆——HttpRequestMessage 发送后不能复用必须克隆一份再重发。这是最容易漏的一步漏了直接报请求已发送异常。并发刷新锁——多个请求同时 401 会触发多次刷新refresh token 可能被消耗多次如果是一次性 token。加一个SemaphoreSlim保证只刷新一次其他请求等结果。刷新失败处理——refresh token 也过期了跳登录页。但 Blazor Server 里不能直接NavigationManager.NavigateTo(/login)那是客户端导航需要通过 JS Interop 调location.href做整页跳转。// TokenService 里的并发刷新 private static readonly SemaphoreSlim _refreshLock new(1, 1); public async Taskbool TryRefreshTokenAsync() { await _refreshLock.WaitAsync(); try { // 双重检查也许别的线程刚刷过 if (IsAccessTokenValid()) return true; var refreshReq new { RefreshToken _refreshToken }; var resp await _httpClient.PostAsJsonAsync( /api/auth/refresh, refreshReq); if (!resp.IsSuccessStatusCode) { // refresh token 也过期 → 强制登出 await _jsRuntime.InvokeVoidAsync( location.replace, /login?expired1); return false; } var tokens await resp.Content .ReadFromJsonAsyncTokenResponse(); _accessToken tokens.AccessToken; _refreshToken tokens.RefreshToken; return true; } finally { _refreshLock.Release(); } }03 Blazor WebAssembly自定义 HttpMessageHandlerWASM 模式下思路一样但实现有区别——token 存在浏览器里而且没有SemaphoreSlim的跨组件同步问题因为 WASM 是单线程的。存储选择// Program.cs — 注册带 token 拦截的 HttpClient builder.Services.AddScopedAuthTokenHandler(); builder.Services.AddScoped(sp { var handler sp.GetRequiredServiceAuthTokenHandler(); handler.InnerHandler new HttpClientHandler(); return new HttpClient(handler) { BaseAddress new Uri(builder.HostEnvironment.BaseAddress) }; }); // Token 存 protected localStorage // Blazor WASM 的 ProtectedLocalStorage 在 Server 端 // WASM 用 IJSRuntime 直接调 localStorage API builder.Services.AddSingletonITokenStorage, LocalTokenStorage();WASM 里的 handler 和 Server 版几乎一样区别在刷新失败时的跳转——WASM 可以直接用NavigationManager做客户端导航不需要 JS Interop。WASM 专属坑localStorage 是异步的但DelegatingHandler.SendAsync也是异步的没问题。但如果你在OnInitializedAsync里调 API刷新 重放的延迟会导致组件渲染两次——务必在组件里处理 Loading 状态否则用户会看到一闪而过的错误。04 两种模式都要注意的三件事① refresh token 的存储位置Blazor Server存在服务端内存 / Session / Redis。别存 cookie——Blazor Server 用 SignalR 连接cookie 跟着连接走断线重连会丢。Blazor WASM存 localStorage。XSS 风险WASM 默认不做dangerouslySetInnerHTMLXSS 攻击面比传统 SPA 小。但如果你的 WASM 应用加载了第三方 JS 模块仍有风险。折中方案access token 存内存refresh token 存 localStorageJS 模块拿不到内存里的 access token。② 刷新端点的设计POST /api/auth/refresh Body: { refreshToken: xxx } Response: { accessToken: 新 access token, refreshToken: 新 refresh token // 轮换 }refresh token 必须轮换——每次刷新后旧的失效返回新的。不轮换 refresh token 被盗后永久有效。进一步可以加 refresh token rotation reuse detection如果检测到旧 token 被使用说明可能被窃取立即吊销整个 token 家族。③ 预刷新别等 401 再刷等 401 再刷的问题是——那个请求已经失败了。虽然重放能恢复但用户可能看到一瞬间的错误状态。更好的做法是预判过期在发请求前检查 access token 的 exp claim如果快过期了比如 5 分钟内先刷新再发请求。// TokenService 里加预判 public async Taskstring GetAccessTokenAsync() { if (_accessToken ! null) { var jwt new JwtSecurityToken(_accessToken); // 过期前 5 分钟刷新 if (jwt.ValidTo DateTime.UtcNow.AddMinutes(5)) return _accessToken; } // 快过期了 → 主动刷新 await TryRefreshTokenAsync(); return _accessToken; }这样AuthTokenHandler里GetAccessTokenAsync自带预判401 分支只是兜底——正常情况下永远不会走到 401 分支。Blazor JWT 刷新令牌核心就四步DelegatingHandler 拦截 → 401 自动刷新 → 请求重放 → 预判过期抢先刷用户从头到尾无感表单数据不丢。收藏这篇下次别再让用户填了一半被踢出去。refresh token 接好用户体验直接上一个台阶。关注 CSharp精选营每周二四 get 能直接抄的 C# 实战。阅读原文Blazor 里 JWT 过期用户正下单就被踢 - 码录集