Blazor Server(.NET6)中JWT令牌的安全存储方案问询
Blazor Server (.NET 6) 身份认证安全优化指南
问题背景
基于.NET 6开发Blazor Server应用时,在身份认证环节面临以下困惑:
- 当前实现了1分钟有效期的Access Token和14天有效期的Refresh Token;
- Access Token过期时,通过验证数据库中Refresh Token的有效性来刷新令牌;
- 目前将两个令牌存储在
localStorage中,不确定该方式的安全性; - 纠结于
localStorage、安全Cookie、内存存储三种方案,希望了解如何避免CSRF、XSS攻击,以及是否需要分开存储两个令牌; - 已实现
CustomAuthenticationStateProvider类,功能正常但不确定安全性是否达标。
核心安全分析与方案建议
1. 令牌存储方案对比与安全风险
localStorage存储
- 优势:持久化存储、跨标签页共享,无需服务器端额外处理;
- 风险:极易遭受XSS攻击——前端JavaScript可直接读取
localStorage内容,一旦页面被注入恶意脚本,令牌会被窃取,进而导致用户会话被劫持。
安全Cookie存储
- 配置要点:设置
HttpOnly=true(禁止JavaScript访问)、Secure=true(仅HTTPS传输)、SameSite=Strict/Lax(防止CSRF攻击),并指定过期时间; - 优势:从根源上防范XSS攻击(JS无法读取),
SameSite属性可有效阻断CSRF请求; - 适配Blazor Server:Blazor Server依赖SignalR连接,默认会自动携带Cookie,无需额外配置即可实现会话绑定。
内存存储(服务器端)
- 实现方式:通过Scoped生命周期的服务存储令牌(每个用户会话对应一个实例);
- 优势:完全隔离前端JavaScript,彻底避免XSS风险;
- 劣势:页面刷新或标签页关闭后令牌丢失,用户需重新登录,体验较差。
2. 令牌分离存储策略(强制推荐)
必须分开存储两个令牌,针对不同令牌的风险等级采取不同方案:
- Refresh Token:有效期长、风险高,必须存储在HttpOnly Secure Cookie中,禁止前端任何方式访问;
- Access Token:有效期短、风险低,可存储在服务器端Scoped内存中(无需暴露给前端),若需调用外部API需携带令牌,可临时存储在内存或短生命周期的非HttpOnly Cookie中(不建议存
localStorage)。
3. 防CSRF/XSS的关键措施
- XSS防护:
- 核心:不让敏感令牌(尤其是Refresh Token)暴露给前端JavaScript;
- 辅助:启用CSP(内容安全策略)、过滤用户输入、定期更新依赖库避免漏洞。
- CSRF防护:
- 核心:使用
SameSite=Strict的Cookie; - 辅助:Refresh Token刷新接口验证用户Agent、IP地址(你的代码已实现此逻辑,值得保留),API接口启用AntiForgery验证。
- 核心:使用
CustomAuthenticationStateProvider代码安全优化
你的现有功能逻辑可行,但存在以下安全隐患,需针对性修改:
现有问题
- 将Refresh Token存储在
localStorage并写入ClaimsPrincipal,存在XSS泄露风险; - Claims中包含敏感令牌内容,不必要且增加泄露风险;
- 依赖
localStorage存储会话信息,未利用Cookie的安全特性。
优化后的代码示例
public class CustomAuthenticationStateProvider : AuthenticationStateProvider { private readonly ITokenService _tokenService; private readonly IHttpContextAccessor _httpContextAccessor; private readonly ISessionTokenStore _sessionTokenStore; // 自定义Scoped服务,存储Access Token private ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity()); public CustomAuthenticationStateProvider(ITokenService tokenService, IHttpContextAccessor httpContextAccessor, ISessionTokenStore sessionTokenStore) { _tokenService = tokenService; _httpContextAccessor = httpContextAccessor; _sessionTokenStore = sessionTokenStore; } public override async Task<AuthenticationState> GetAuthenticationStateAsync() { try { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) return new AuthenticationState(_anonymous); // 从HttpOnly Cookie获取Refresh Token var refreshToken = httpContext.Request.Cookies["RefreshToken"]; if (string.IsNullOrEmpty(refreshToken)) return new AuthenticationState(_anonymous); // 从Scoped存储获取Access Token var accessToken = await _sessionTokenStore.GetAccessTokenAsync(); bool isAccessTokenValid = !string.IsNullOrEmpty(accessToken) && _tokenService.ValidateToken(accessToken); if (!isAccessTokenValid) { // 验证Refresh Token的有效性(含数据库校验) if (!_tokenService.ValidateToken(refreshToken)) { await ClearAuthenticationState(); return new AuthenticationState(_anonymous); } var refreshTokenValidInDb = await _tokenService.CheckIfRefreshTokenIsValid(refreshToken); if (!refreshTokenValidInDb) { await ClearAuthenticationState(); return new AuthenticationState(_anonymous); } // 刷新令牌 var userAgent = GetUserAgent(httpContext); var ipAddress = GetIpAddress(httpContext); var (newAccessToken, newRefreshToken) = await _tokenService.RefreshAuthTokens(refreshToken, userAgent, ipAddress); // 更新Scoped存储的Access Token await _sessionTokenStore.SetAccessTokenAsync(newAccessToken); // 更新HttpOnly Cookie的Refresh Token httpContext.Response.Cookies.Append("RefreshToken", newRefreshToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddDays(14), Path = "/" }); } // 从数据库或令牌解析用户信息(此处假设从令牌解析,或从数据库查询) var userClaims = await _tokenService.GetUserClaimsFromToken(accessToken ?? await _sessionTokenStore.GetAccessTokenAsync()); var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(userClaims, "Auth")); return new AuthenticationState(claimsPrincipal); } catch { await ClearAuthenticationState(); return new AuthenticationState(_anonymous); } } public async Task UpdateAuthenticationState(UserSession userSession) { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) return; ClaimsPrincipal claimsPrincipal; if (userSession != null) { // 存储Access Token到Scoped服务 await _sessionTokenStore.SetAccessTokenAsync(userSession.AuthToken); // 设置Refresh Token到HttpOnly Cookie httpContext.Response.Cookies.Append("RefreshToken", userSession.RefreshToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = DateTimeOffset.UtcNow.AddDays(14), Path = "/" }); // 构建Claims(仅包含用户标识信息,不包含令牌) var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, userSession.Id.ToString()), new Claim(ClaimTypes.Name, userSession.Name), new Claim("useragent", userSession.UserAgent), new Claim("ipv4", userSession.IPv4) }; claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Auth")); } else { await ClearAuthenticationState(); claimsPrincipal = _anonymous; } NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(claimsPrincipal))); } private async Task ClearAuthenticationState() { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) return; await _sessionTokenStore.ClearAccessTokenAsync(); httpContext.Response.Cookies.Delete("RefreshToken", new CookieOptions { Path = "/" }); } private string GetIpAddress(HttpContext httpContext) { var ipNullable = httpContext.Connection.RemoteIpAddress; return ipNullable?.ToString() ?? string.Empty; } private string GetUserAgent(HttpContext httpContext) { return httpContext.Request.Headers["User-Agent"].ToString(); } } // 自定义Scoped服务,用于存储Access Token public interface ISessionTokenStore { Task SetAccessTokenAsync(string accessToken); Task<string?> GetAccessTokenAsync(); Task ClearAccessTokenAsync(); } public class SessionTokenStore : ISessionTokenStore { private string? _accessToken; public Task SetAccessTokenAsync(string accessToken) { _accessToken = accessToken; return Task.CompletedTask; } public Task<string?> GetAccessTokenAsync() { return Task.FromResult(_accessToken); } public Task ClearAccessTokenAsync() { _accessToken = null; return Task.CompletedTask; } }
优化说明
- 新增
ISessionTokenStoreScoped服务,专门存储Access Token,避免暴露给前端; - 将Refresh Token存储转移到HttpOnly Secure Cookie,彻底阻断XSS窃取路径;
- 移除Claims中的敏感令牌内容,仅保留用户标识信息;
- 统一清理会话的逻辑到
ClearAuthenticationState方法,避免冗余; - 保留原有的用户Agent、IP验证逻辑,强化Refresh Token的安全性。
总结
最优安全方案为:
- Refresh Token:存储在HttpOnly + Secure + SameSite=Strict的Cookie中,配合数据库校验实现会话管理;
- Access Token:存储在服务器端Scoped内存中,仅在需要调用API时临时传递;
- 始终遵循“最小权限”原则,不暴露敏感令牌给前端,同时启用XSS/CSRF防护措施。
内容的提问来源于stack exchange,提问作者Pietrek
相关产品推荐
相关产品推荐

