1. 为什么我从 Session 切到了 JWT:一次真实的架构选型记录
做 ASP.NET Core 开发的朋友应该都有过这种经历:项目一开始老老实实用 Session 存登录状态,等做到前后端分离、客户端从浏览器换成小程序和 App 之后,Session 开始变得束手束脚。我也一样,今年维护的一个老项目就是典型的 Web Forms 架构迁移到 ASP.NET Core 的场景,当时面临两个选择:继续沿用 Session 方案做跨域兼容,还是直接集成 JWT 认证授权。
先说结论:我选了 JWT,并且把整条认证链路从登录签发、接口校验、权限隔离到 token 续签全部重做了一遍。这个决定不是拍脑袋做的,原因主要有三个。
第一,客户端不再是单一浏览器。除了网页端,还有公众号 H5、小程序、第三方 API 对接方,Session 依赖 Cookie 的会话机制在非浏览器环境下要多写一堆兼容代码,而 JWT 本身就是无状态的,token 放在请求头里,客户端只需要在每次请求时带上Authorization: Bearer <token>即可。
第二,认证和授权天然分离。Session 方案通常只能告诉你"这个人是谁",权限判断要在每个接口里查一遍会话数据;而 JWT 可以在 token 的 claims 里直接携带角色、权限点、用户 ID、租户信息,后端在中间件层就能完成大部分授权判断,接口层代码干净很多。
第三,扩展性。JWT 的校验逻辑是纯算法的,不依赖服务端存储,这意味着一组校验代码可以在多个 API 服务之间复用,以后就算把单体应用拆成微服务,认证中心依然能无缝对接。
这篇文章会围绕我这次集成的完整过程来写,内容包括 JWT 的核心原理拆解、ASP.NET Core 8 里的具体集成代码、自定义授权策略的实现、token 过期与续签方案,以及安全加固方面的实战经验。如果你正在做前后端分离项目,或者在考虑怎么给 API 加一层统一的认证保护,这篇文章应该能给你省下不少踩坑的时间。
2. JWT 的三段式结构:Header、Payload、Signature 到底存了什么
在动手写代码之前,我建议先把 JWT 的结构吃透。很多人用 JWT 只停留在"调一个 GenerateToken 方法"的程度,出了问题无从下手,其实就是对 token 本身的构成不够清楚。
2.1 一个 JWT 长什么样
一个标准的 JWT 由三部分组成,用英文句点分隔,样式如下:
eyJhbGciOiJIUzI1NiIsImtpZCI6Im15LWtleS1pZCJ9.eyJzdWIiOiIxMDAxIiwibmFtZSI6ImFkbWluIiwicm9sZSI6IkFkbWluIiwiZXhwIjoxNzA1MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c第一部分是 Header,第二部分是 Payload,第三部分是 Signature。每段都是 Base64Url 编码后的 JSON,所以理论上你可以把任意一段复制出来解码看内容,这也是为什么不要在 JWT 的 payload 里存密码、身份证号这类敏感信息——它只是编码,不是加密。
2.2 Header:声明算法和密钥标识
Header 通常包含两个字段:
{ "alg": "HS256", "typ": "JWT", "kid": "my-key-id" }alg指明签名算法,常见的有 HS256(对称签名)、RS256(非对称签名)。kid是可选的密钥标识,当你有多个签名密钥轮换时,后端靠kid来快速定位用哪把密钥验签。这个字段我在后面的安全加固部分会专门展开讲,因为网上很多 JWT 漏洞分析都跟kid的处理不当有关。
2.3 Payload:携带业务声明
Payload 是 JWT 的"正文",可以放标准声明(Registered Claims)和自定义声明(Custom Claims)。标准声明里最常用的是这几个:
sub(Subject):用户主体标识,通常放用户 IDexp(Expiration Time):过期时间,时间戳格式iat(Issued At):签发时间nbf(Not Before):生效时间,早于该时间不可用iss(Issuer):签发者aud(Audience):受众,标识 token 给谁用
自定义声明则根据需要自由添加,比如用户角色、权限点、租户 ID、昵称、头像 URL 等。ASP.NET Core 里通过Claim对象来构造这些数据,后面代码部分会具体演示。
2.4 Signature:整个 token 的可信基础
签名是用 Header 里声明的算法,把Base64Url(Header) + "." + Base64Url(Payload)拼起来,再用密钥做哈希计算得到的。拿 HS256 来说,它的验证本质就是后端用同一个密钥重新计算一遍签名,再跟前端传来的签名比对——能对上,说明 token 在传输过程中没有被篡改;对不上,直接拒绝请求。
这里有个非常重要的认知:JWT 的签名保护的是"完整性",不是"机密性"。如果你需要让某些数据在 token 里不可被客户端直接看到,应该考虑加密方案,比如 JWE(JSON Web Encryption),或者干脆把敏感数据放服务端缓存,token 里只放一个索引。这点很多初学者会栽跟头。
3. ASP.NET Core 8 集成 JWT 的完整实操:从 NuGet 包到第一个受保护接口
接下来进入正题。我用的开发环境是 Visual Studio 2022 + .NET 8,项目类型是空的 Web API 模板。整个集成过程分五步走,每一步我都标注了需要注意的细节。
3.1 安装认证相关的 NuGet 包
在已有项目里执行以下命令,或者直接在 NuGet 包管理器里搜索安装:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer这个包会带上System.IdentityModel.Tokens.Jwt和Microsoft.IdentityModel.Tokens等依赖,前者用于生成 token,后者提供签名验证等底层能力。一个包就够了,不需要额外装别的。
3.2 配置 appsettings.json
我把 JWT 相关的配置单独放一个节点:
{ "Jwt": { "Issuer": "YourAppIssuer", "Audience": "YourAppAudience", "SecretKey": "your-256-bit-secret-key-here-change-it-in-production", "ExpireMinutes": 120, "RefreshTokenExpireDays": 7, "ClockSkewSeconds": 60 } }SecretKey这一个值的设置我多说一句:长度最少 32 个字符。因为 HS256 要求密钥至少 256 位,如果密钥太短,某些 JWT 库会直接抛异常,或者即便能运行,暴力破解的成本也会低很多。生产环境里应该把这个值放到环境变量、密钥管理服务(比如 Azure Key Vault)或者用户机密里,绝不要明文提交到代码仓库。
ClockSkewSeconds是用来容忍客户端和服务端时间偏差的,默认值是 5 分钟,但实际项目里一般不需要这么大的窗口,设置成 60 秒够用了。窗口越大,token 被重放利用的时间就越长。
3.3 注册 JWT 认证服务
在Program.cs里加上认证服务的注册。注意我这里的配置虽然看起来有点长,其实是把每一步都显式写出来了,方便后面安全策略的扩展:
using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); var jwtSettings = builder.Configuration.GetSection("Jwt"); var secretKey = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwtSettings["Issuer"], ValidateAudience = true, ValidAudience = jwtSettings["Audience"], ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(secretKey), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds( Convert.ToDouble(jwtSettings["ClockSkewSeconds"])) }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { // 支持从 query string 读取 token, // 主要用于 SignalR 等无法自定义 Header 的场景 var accessToken = context.Request.Query["access_token"]; if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) { context.Token = accessToken; } return Task.CompletedTask; }, OnChallenge = context => { // 认证失败时返回 401,这里可以自定义响应格式 return Task.CompletedTask; } }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();UseAuthentication和UseAuthorization的顺序不能反——必须先做身份认证,再做授权判断。这两个中间件要在MapControllers之前注册,否则走到了 Controller 层才发现用户未认证,链路就乱了。
3.4 编写 Token 生成服务
生成 token 的逻辑我封装成了一个独立的 service,一来方便复用,二来可以把 Claims 的构建集中管理。下面是核心代码:
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class JwtTokenService { private readonly IConfiguration _configuration; public JwtTokenService(IConfiguration configuration) { _configuration = configuration; } public string GenerateToken(LoginUser user) { var jwtSettings = _configuration.GetSection("Jwt"); var secretKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!)); var claims = new List<Claim> { new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(ClaimTypes.Name, user.UserName), new(ClaimTypes.Role, user.Role), new("department", user.Department), new("tenant_id", user.TenantId.ToString()) }; var credentials = new SigningCredentials( secretKey, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: jwtSettings["Issuer"], audience: jwtSettings["Audience"], claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes( Convert.ToDouble(jwtSettings["ExpireMinutes"])), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } }LoginUser是一个简单的 DTO,包含了用户 Id、用户名、角色、部门和租户 ID 这些字段。你需要根据自己的用户表结构调整 Claims 的组装逻辑。这里有个细节值得注意:JwtRegisteredClaimNames.Sub和ClaimTypes.NameIdentifier是不同的 Claim Type,如果你在 Controller 里用User.FindFirstValue(ClaimTypes.NameIdentifier)去取用户 ID,而生成时用的是JwtRegisteredClaimNames.Sub,会取到 null,因为两者映射的 URI 不一样。我建议在整个项目里统一用JwtRegisteredClaimNames.Sub或自己定义一个常量,避免这种低级但很隐蔽的坑。
3.5 写一个带登录接口的 Controller 验证效果
接着注册服务,在登录接口里调用 token service:
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; [ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly JwtTokenService _tokenService; public AuthController(JwtTokenService tokenService) { _tokenService = tokenService; } [HttpPost("login")] [AllowAnonymous] public IActionResult Login([FromBody] LoginRequest request) { // 这里应该调用用户服务验证用户名密码 // 真实项目中建议配合验证码,后续章节会讲 if (request.UserName != "admin" || request.Password != "123456") { return Unauthorized(new { message = "用户名或密码错误" }); } var user = new LoginUser { Id = 1, UserName = "admin", Role = "Admin", Department = "IT", TenantId = 100 }; var token = _tokenService.GenerateToken(user); return Ok(new { token, expiresIn = 7200 }); } [HttpGet("profile")] [Authorize] public IActionResult GetProfile() { var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub); var role = User.FindFirstValue(ClaimTypes.Role); return Ok(new { userId, role }); } }[AllowAnonymous]是登录接口必须加的,不然会出现"还没登录怎么登录"的死循环。其他接口默认加[Authorize],没有 token 或 token 无效的请求会被中间件拦截,返回 401。运行项目后用 Postman 或 Apifox 测一下:先 POSTapi/auth/login拿 token,再 GETapi/auth/profile并在请求头加Authorization: Bearer <token>,能正常返回用户信息就说明整个链路通了。
4. 授权不只是登录:基于自定义策略实现按钮级权限控制
很多团队做到上一步就收工了,反正 token 能生成、接口能保护住。但真实业务里往往需要更细的权限控制——比如"只有部门经理能审批报销单"、"只有租户管理员能修改租户配置"。这种需求靠[Authorize]标签远远不够,需要引入策略授权。
4.1 什么是策略,为什么要用它
策略(Policy)本质上是一组授权规则的集合,它在认证成功之后对用户身份做二次判断,决定"这个人能不能做这件事"。跟直接在代码里写if (role == "Admin")相比,策略的好处是规则集中管理、可以组合复用、支持基于断言和基于角色两种模式,而且能配合依赖注入做复杂的逻辑判断。
在 ASP.NET Core 里注册策略很简单:
builder.Services.AddAuthorization(options => { options.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin")); options.AddPolicy("DepartmentManager", policy => policy.RequireClaim("department", "IT") .RequireRole("Manager", "Admin")); options.AddPolicy("CanApproveExpense", policy => policy.RequireAssertion(context => context.User.HasClaim(c => c.Type == "permission" && c.Value.Contains("expense:approve")))); });然后在 Controller 或 Action 上声明策略即可:
[Authorize(Policy = "CanApproveExpense")] [HttpPost("approve")] public IActionResult ApproveExpense([FromBody] ExpenseRequest request) { // 只有包含 expense:approve 权限声明的用户才能进入这里 return Ok(); }4.2 自定义授权处理器:比角色更灵活的权限模型
角色 + 常规策略能覆盖大部分场景,但遇到复杂的权限判断——比如数据权限(只能看自己部门的数据)、多租户隔离(只能操作自己租户内的资源)——就得写自定义AuthorizationHandler。下面是一个租户隔离的例子:
public class TenantAuthorizationHandler : AuthorizationHandler<TenantRequirement> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, TenantRequirement requirement) { var tenantIdClaim = context.User.FindFirstValue("tenant_id"); if (string.IsNullOrEmpty(tenantIdClaim)) { return Task.CompletedTask; } // 当请求路径中包含 tenant/{tenantId} 时,校验两者是否一致 var httpContext = context.Resource as HttpContext; var routeTenantId = httpContext?.Request.RouteValues["tenantId"]?.ToString(); if (routeTenantId != null && routeTenantId == tenantIdClaim) { context.Succeed(requirement); } return Task.CompletedTask; } } public class TenantRequirement : IAuthorizationRequirement { }注册方式:
builder.Services.AddSingleton<IAuthorizationHandler, TenantAuthorizationHandler>(); builder.Services.AddAuthorization(options => { options.AddPolicy("TenantAccess", policy => policy.Requirements.Add(new TenantRequirement())); });这种基于 Handler 的方式是我比较推荐的,因为它在"认证通过"和"接口授权"之间插入了一层可注入的、可单元测试的逻辑层。另外建议在开发时开启AuthorizationHandlerContext.Fail()的日志记录,不然授权失败时查起来非常难受,你只知道 403 了,但不清楚是哪个 Handler 拒绝了请求。
4.3 HttpContext.User 里的 Claims 从哪里来
上面代码里用context.User.FindFirstValue("tenant_id")取声明,可能有人会疑惑:这个User对象为什么会有 token 里的 claims?
答案藏在 JWT 中间件的处理流程里。当请求带着 token 进来时,JwtBearerHandler会调用TokenValidationParameters里的验证逻辑,验证通过后把 token 的 payload 反序列化成ClaimsPrincipal,默认映射到System.Security.Claims.ClaimsIdentity上,然后赋值给HttpContext.User。后续无论在授权 Handler 还是 Controller 里访问User,拿到的都是这个从 token 解析出来的身份对象。
这里有个容易踩的坑是关于JwtSecurityTokenHandler.DefaultInboundClaimTypeMap的映射问题。默认情况下,sub会被映射成ClaimTypes.NameIdentifier,role会被映射成ClaimTypes.Role,name会被映射成ClaimTypes.Name。如果你在自定义 claims 里用了像department这种非标准名称,则不会触发映射,原样保留。而之前提过的sub取不到值的问题,根源就是映射机制在做"翻译"。为了减少混乱,不少人会直接关掉映射:
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();关掉之后,所有 claims 都保持 token 里的原始名称,不再自动替换成ClaimTypes那套 URI 风格。但要注意,这也意味着[Authorize(Roles = "Admin")]这种写法会失效,因为角色 claim 的名字不再被识别为ClaimTypes.Role。要么配套使用策略policy.RequireClaim("role", "Admin"),要么在生成 token 时继续用ClaimTypes.Role类型。我的建议是全项目统一一种风格,要么全用标准 ClaimTypes,要么全用自定义字符串,混用是最容易出问题的。
5. 登录接口的安全增强:验证码与登录限流
前面说过,真实项目不要把账号登录做成裸奔接口。结合搜索热词里的"SPA项目开发之jwt验证码实现",我把验证码和登录限流的整合方案也放进来。
5.1 图片验证码的接入方式
方案很多,比较轻量的是在服务端生成图片验证码,把验证码文本存到缓存,返回图片流给前端。我这里用的是一个简单的画图逻辑,没有依赖第三方 SDK:
[HttpPost("captcha")] [AllowAnonymous] public IActionResult GenerateCaptcha() { var code = GenerateRandomCode(6); var captchaId = Guid.NewGuid().ToString("N"); var cached = DistributedCache.SetStringAsync( $"captcha:{captchaId}", code, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }).GetAwaiter().GetResult(); var bitmap = DrawCaptchaImage(code); using var ms = new MemoryStream(); bitmap.Save(ms, ImageFormat.Png); return Ok(new { captchaId, image = Convert.ToBase64String(ms.ToArray()) }); }前端把验证码图片用<img src="data:image/png;base64,...">展示,登录时把captchaId和用户输入的验证码一起提交。后端校验签名时同时验证验证码:
[HttpPost("login")] [AllowAnonymous] public async Task<IActionResult> Login(LoginRequest request) { var cachedCode = await _cache.GetStringAsync($"captcha:{request.CaptchaId}"); if (cachedCode == null || !cachedCode.Equals(request.CaptchaCode, StringComparison.OrdinalIgnoreCase)) { return BadRequest(new { message = "验证码错误或已过期" }); } await _cache.RemoveAsync($"captcha:{request.CaptchaId}"); // 继续进行用户名密码验证... }注意:验证码必须一次性使用,验证通过后立刻删除。不然攻击者可以先用同一个验证码做批量尝试。
5.2 登录限流:不给暴力破解机会
验证码只能挡住最基础的脚本轰炸,还应该加登录接口的限流。ASP.NET Core 8 可以通过中间件或第三方库(比如 AspNetCore.RateLimit)实现,但最简单的方式是利用IPolicyRateLimiter。在Program.cs里配置:
builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("login_limit", context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", factory: _ => new FixedWindowRateLimiterOptions { AutoReplenishment = true, PermitLimit = 5, QueueLimit = 0, Window = TimeSpan.FromMinutes(1) })); }); app.UseRateLimiter();登录接口上标注:
[HttpPost("login")] [AllowAnonymous] [EnableRateLimiting("login_limit")] public IActionResult Login(LoginRequest request)这个配置的含义是:同一个 IP 地址在 1 分钟内最多调用 5 次登录接口,超过直接返回 429。理论上登录这种高风险接口,还应该配合账号维度的限流(比如一个账号连续失败 5 次锁定 15 分钟),这需要自己实现,因为涉及账号状态存储,不是简单的 IP 维度接口。我目前的方案是 IP 限流 + 验证码双管齐下,对于内部系统来说已经足够。
6. Token 过期怎么办:刷新令牌与续签方案的落地选择
JWT 的一个经典痛点就是过期处理。假如exp设置了 120 分钟,用户用着用着突然收到 401,必须重新登录,体验很差。做 token 续签是必然需求,但续签怎么做,里面有不少取舍。
6.1 前端简单暴力的方案:静默刷新
最简单的方案是前端在请求时判断 token 剩余有效时间,如果快过期了,先调一个固定接口换新 token。这个方案要求"换新 token"的接口本身是匿名的或弱认证的,所以后来我放弃了这种实现——安全上不太踏实,任何拿到过期 token 的人都可以去换新 token,等于延长了 token 的生命周期。
6.2 主流方案:Refresh Token(刷新令牌)
更可靠的方式是引入 Refresh Token。这是一种长期凭证(通常 7 天),专门用来换新的 Access Token。登录成功后,服务端返回两个值:
- Access Token:短期有效,用于访问受保护资源
- Refresh Token:长期有效,只用于调用刷新接口
刷新令牌的核心逻辑是:
[HttpPost("refresh")] [AllowAnonymous] public async Task<IActionResult> Refresh([FromBody] RefreshRequest request) { var storedToken = await _cache.GetStringAsync($"refresh_token:{request.RefreshToken}"); if (storedToken == null) { return Unauthorized(new { message = "刷新令牌无效或已过期" }); } // 解析旧的 access token,但不校验签名,只取里面的用户身份 var handler = new JwtSecurityTokenHandler(); var jwtToken = handler.ReadJwtToken(request.AccessToken); var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; if (userId == null) { return Unauthorized(); } // 生成新的 access token 和 refresh token var user = await _userService.GetUserById(int.Parse(userId)); var newAccessToken = _tokenService.GenerateToken(user); var newRefreshToken = Guid.NewGuid().ToString("N"); await _cache.RemoveAsync($"refresh_token:{request.RefreshToken}"); await _cache.SetStringAsync($"refresh_token:{newRefreshToken}", userId, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7) }); return Ok(new { accessToken = newAccessToken, refreshToken = newRefreshToken }); }Refresh Token 有几个关键设计原则:
- 必须轮换:每次刷新都生成新的 Refresh Token,旧的立即作废。
- 必须可撤销:服务端要存 Refresh Token 的状态(用缓存或数据库),这样才能在用户修改密码、被踢下线时立即失效。
- 必须绑定用户:刷新时要重新确认 Refresh Token 对应用户仍然存在且状态正常。
- 设置合理过期时间:Access Token 120 分钟、Refresh Token 7 天是我在内部系统常用的组合;面向 C 端的系统可考虑 Access Token 15 分钟、Refresh Token 30 天。
- 不要在客户端持久化存储 Access Token,而 Refresh Token 可以存在本地存储但需要做好安全策略,比如绑定指纹信息。
6.3 双 Token 方案的痛点与改进
双 Token 虽然比单 Token 安全不少,但它不是银弹。最大的痛点是刷新接口本身需要极高级别的防护——因为它接收一个长期有效的凭证,只要拿到 Refresh Token,基本等于拿到了账号的长期控制权。所以我做了两件事:
第一,给 Refresh Token 增加设备信息绑定。生成时把客户端的 User-Agent、IP 等信息做哈希后存入缓存,刷新时对比当前请求是否来自同一设备。一旦发现异常,直接拒绝刷新并作废该用户的全部 Refresh Token。
第二,加入令牌族(Token Family)概念。每次刷新生成的新 Refresh Token 同时记录自己"继承自哪个旧 Token"的父 ID。如果系统发现同一个父令牌对应的子令牌被不同设备使用,立刻标记整个令牌族为异常并全部吊销。这是防御刷新令牌盗用比较有效的手段,实现不复杂,一张表就能搞定。
从这里也能看出来,JWT 的"无状态"其实是相对的。一旦引入 Refresh Token,你依然需要服务端存储配合。但这是值得的——认证状态这个东西,完全无状态并不现实,关键是让无状态的部分(Access Token 校验)足够快,让有状态的部分(Refresh Token 管理)足够安全。
7. 安全加固清单:密钥管理、kid 处理与常见攻击面
看到搜索热词里有"jwt漏洞总结"和"jwt kid",这是很多人忽略但其实非常重要的部分。JWT 用的库是成熟的开源库,但配置不当照样漏洞百出。
7.1 密钥安全:生产环境不要用同一个密钥
不管项目规模多大,我强烈建议主密钥和 token 签名密钥分离。主密钥用于加密用户密码之类的敏感数据,token 签名密钥只用于 JWT 签名,两把钥匙不要共用一把。泄露任何一个,影响面可控。
密钥轮换也是必须考虑的。我的做法是在配置里支持多个密钥,通过kid标识当前使用哪一个:
{ "Jwt": { "SigningKeys": [ { "Kid": "2025-key-v1", "Value": "first-secret-key-..." }, { "Kid": "2026-key-v2", "Value": "second-secret-key-..." } ], "CurrentKeyId": "2026-key-v2" } }TokenValidationParameters 里配置一个 KeyResolver 来根据kid加载对应密钥:
options.TokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => { // 根据 kid 从配置或密钥管理服务中查找对应密钥 return new[] { GetSigningKey(kid) }; };这样旧 token 在密钥轮换后依然能验签成功(因为kid能指到旧密钥),新 token 则用新密钥签发,用户可以平滑过渡,不需要强制重新登录。这个细节很多项目不做,等到真的要轮换密钥时才发现所有线上 token 一夜之间全部失效。
7.2 kid 注入:一个隐蔽的高危点
kid是 JWT Header 里的可选字段,一些 JWT 库在解析时会把kid直接当文件路径或 SQL 参数使用,从而引发注入。虽然 ASP.NET Core 的 JwtBearer 包不会拿kid去查数据库,但如果你自己写了IssuerSigningKeyResolver,就要留意kid的外部输入属性——永远不要用kid去拼接文件路径、拼接 SQL、直接作为命令参数。安全做法是:先校验kid是否在白名单内,不是就拒绝。
7.3 算法混淆攻击:不要盲目信任 alg 字段
算法混淆攻击(Algorithm Confusion Attack)是 JWT 领域一个经典的攻击手法。攻击者把 cookie 里的 token 篡改为用alg: none的 token,如果后端没有对alg做白名单限制,某些旧版本库会直接跳过验签,攻击者就能伪造任意身份。
在 ASP.NET Core 里,JwtBearer默认会校验签名算法,但为了万无一失,建议显式指定允许的算法集合。同时要注意:alg: HS256和alg: RS256在思路上有本质区别,HS256 是对称算法,签发和验证用同一个密钥;RS256 是非对称算法,公钥公开、私钥保密。如果后端用的是 RS256,但攻击者把alg改成 HS256,把公钥当对称密钥来签名,就有可能绕过验证。这一点在团队用了多个签名算法时尤其要小心。最简单的防御就是固定只允许白名单里的算法:
options.TokenValidationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 };7.4 其他几个容易被忽视的点
- 端口和域名校验:
ValidAudience应该设置为 API 的实际域名或标识,防止 token 被用在其他服务上。 - 日志脱敏:不要在日志里打印完整 token。排查问题时打前几位和后几位就够了,不然日志系统一泄露,所有用户的登录态全暴露。
- 注销实现:JWT 没有服务端 session,无法真正"注销"。如果业务上需要强制下线(比如用户点退出登录、修改密码、账号被封),就需要引入"token 黑名单"机制,把注销的 token 的 jti(JWT ID)存到缓存直到过期。这也是"无状态"的代价,该交给存储的还是要交。
- HTTPS:所有 token 传输都应该是 HTTPS。虽然这是基础中的基础,但我见过不少内网系统用 HTTP 裸跑,token 在局域网里被嗅探的风险依然存在。
8. 回到项目本身:我踩过的最痛的一个坑
这节算是彩蛋,讲一个我在集成过程中真实踩过、排查了整整半天的坑,希望能帮你避开。
现象是:后端所有代码都按文档写好了,[Authorize]也加了,登录接口也能拿到 token,但带 token 请求受保护接口时,始终返回 401。用 Postman 测试也是同样的结果。
排查链路是这样的:
- 先确认 token 能不能正常解析。我写了一个测试接口,把 token 解码出来看 claims,发现
exp时间正常、签名算法是 HS256、claims 都在。 - 再确认
TokenValidationParameters的配置。打印出来看,Issuer、Audience、SecretKey 都对得上。 - 然后怀疑是
ClockSkew的问题——服务器时间和本地时间差太多?但查了日志,时间偏差只有几秒,不至于。 - 最后才发现,问题出在claim type 映射上。我在生成 token 时用了
JwtRegisteredClaimNames.Sub(标准的sub),默认的DefaultInboundClaimTypeMap会把sub映射成ClaimTypes.NameIdentifier。而在授权 Handler 里,我用ClaimTypes.Name去取用户名,这个声明根本不存在,导致授权断言失败。
说实话这个坑很隐蔽,因为从字符串上看sub和name都在 payload 里,但代码里用的是映射前的 raw claim type,还是映射后的 .NET claim type,两者的取值 API 完全不同。最终我的解决方案是全局统一:生成 token 时直接用ClaimTypes定义的常量作为 claim type,这样整个处理链路上就不需要关心映射转换了。
这个教训也让我养成了一个习惯:JWT 相关的代码先从解析后的 claims 入手做一次全量输出,再写授权逻辑。调试成本高不高,往往就差这一步。
9. 一些小工具和调试技巧
最后分享几个我常用的辅助手段。
第一,调试 token 内容用小工具。JWT 的官网(jwt.io)可以粘贴 token 直接解码 Header 和 Payload,但要留意:不要把生产环境的真实密钥粘贴进去,它只是个解码器,本地用可以,线上调试要慎重。我自己更喜欢在本地写一个临时调试接口或在 watch 窗口里直接看变量,尽量减少 token 内容在第三方网站的留存。
第二,Postman 的自动化测试脚本。在 Postman 的 Tests 标签里可以用pm.environment.set("token", jsonData.token)把登录拿到的 token 存到环境变量,然后所有请求的 Authorization 头都写成Bearer {{token}},这样联调效率会高很多。
第三,关于依赖注入作用域。JwtTokenService这种无状态服务注册成 Singleton 即可,它只依赖IConfiguration,没有数据库上下文,不需要每次请求都 new 一个。
第四,单元测试。至少应该覆盖三块逻辑:token 生成后能被TokenValidationParameters成功验签、过期 token 会被拒绝、claims 和生成时一致。这些测试很快就能写,但在改配置、升版本的时候能帮你兜底。
10. 最后说点个人体会
这轮集成做下来,我对 JWT 的感受是:它本身只是一套"自包含令牌"的规范,真正决定项目安全性和体验的,是你在它外面搭的那层机制——密钥怎么管、过期怎么续、权限怎么控、异常怎么拦。ASP.NET Core 8 在认证授权的框架设计上已经相当成熟,Authentication和Authorization两级中间件让 JWT 的接入成本降到了很低的水平,但框架给的是"能跑"的保障,"跑得好"还得自己动手。
如果你所在的项目还没有对外的接口层认证,从今天开始把 JWT 加上并不晚;如果你已经做好了,不妨对照这篇文章检查一遍密钥轮换、Refresh Token 轮换、算法白名单这些细节。这些都是真实项目里绕不开的部分,早处理早安心。