☰
Blazor 里 JWT 过期,用户正下单就被踢
2026/10/10 6:15:12 网站建设 项目流程

用户在页面里填了一整页表单,点了提交——啪,跳回登录页。JWT 过期了,刷新令牌没接上,所有数据白填。这篇文章讲 Blazor 里怎么正确实现 JWT 刷新令牌,让用户无感续期。

先说为什么 Blazor 里这个问题特别恶心。

在普通 SPA(React/Vue)里,JWT 存在 localStorage,请求拦截器统一加 header,401 时自动刷新——一套 Axios 拦截器搞定。但 Blazor 不一样:Blazor Server 跑在服务端,Blazor WebAssembly 跑在浏览器里,两种模式下 token 的存储位置、刷新时机、信号传递方式完全不同。

而且 Blazor 的组件状态是活的——用户在页面上操作时,组件不会重新渲染,你没法靠"页面刷新"来触发重新认证。必须在 HTTP 层静默处理。

01 为什么 JWT 过期会"踢人"

JWT 有过期时间(exp claim)。过期后,API 服务器验证失败返回 401。如果你的应用不做刷新,401 的处理就是跳登录页。

问题是——JWT 的过期时间通常很短(15-30 分钟),这是安全设计。长过期时间 = token 被盗后攻击窗口大。所以业界做法是短 access token + 长 refresh token:

Access 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 Server:DelegatingHandler 拦截

Blazor Server 跑在服务端,HttpClient 可以套 DelegatingHandler——这是 .NET 原生的 HTTP 管道机制,所有请求经过 handler 链。

核心思路:自定义一个AuthTokenHandler,在 SendAsync 里自动加 token,401 时自动刷新。

public class AuthTokenHandler : DelegatingHandler { private readonly TokenService _tokenService; public AuthTokenHandler(TokenService tokenService) { _tokenService = tokenService; } protected override async Task<HttpResponseMessage> 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 Task<HttpResponseMessage> 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 Task<bool> 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?expired=1"); return false; } var tokens = await resp.Content .ReadFromJsonAsync<TokenResponse>(); _accessToken = tokens.AccessToken; _refreshToken = tokens.RefreshToken; return true; } finally { _refreshLock.Release(); } }

03 Blazor WebAssembly:自定义 HttpMessageHandler

WASM 模式下思路一样,但实现有区别——token 存在浏览器里,而且没有SemaphoreSlim的跨组件同步问题(因为 WASM 是单线程的)。

存储选择:

// Program.cs — 注册带 token 拦截的 HttpClient builder.Services.AddScoped<AuthTokenHandler>(); builder.Services.AddScoped(sp => { var handler = sp.GetRequiredService<AuthTokenHandler>(); 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.AddSingleton<ITokenStorage, 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 默认不做dangerouslySetInnerHTML,XSS 攻击面比传统 SPA 小。但如果你的 WASM 应用加载了第三方 JS 模块,仍有风险。折中方案:access token 存内存,refresh token 存 localStorage,JS 模块拿不到内存里的 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 Task<string> 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 过期,用户正下单就被踢 - 码录集

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

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

立即咨询