老实说JWTJSON Web Token在这几年的 .NET 项目里几乎是绕不开的东西。无论是做前后端分离的接口鉴权、小程序登录态还是给第三方系统开放 APIToken 验证基本成了默认选项。我以前在 .NET Framework 时代用 Session 用惯了刚转到 .Net Core 那会儿也觉得 JWT 有点绕等真正把机制吃透之后才发现这东西的简单是建立在理解几个关键点之上的。这篇就来聊聊我在实际项目里用 JwtBearer 做 Token 验证的完整过程包括签发的代码怎么写、校验的链路到底发生了什么以及那些文档里不会明说、但实战里一定会撞上的坑。1. 为什么我最终选了 JWT 而不是 Session先说一个我在某电商后端项目里的经历。当时系统要做小程序端和管理后台的登录打通用的还是传统的 Session 方案用户登录后把状态存在服务端内存里前端拿一个 SessionId 去换数据。最初几天还好等用户量上来问题就来了——服务端扩容之后Session 没法在多个实例之间共享要么做 Redis 集中存储要么就得处理用户明明登录了请求却被路由到另一台机器上导致掉线的尴尬情况。后来换成了 JWT 方案核心思路完全颠倒过来服务端不再保存任何登录状态用户登录成功之后我们把用户 ID、角色、过期时间等信息签进一个加密的字符串里发给他客户端后续请求只要把这个字符串放在请求头里带过来服务端验一下签名、看一下过期时间就能确定身份和权限。换句话说Session 是服务端记住你是谁JWT 是你自己证明你是谁。这种无状态的设计在分布式场景下收益非常明显服务端不需要存 Session扩容、重启、负载均衡都不用担心状态丢失多端适配简单小程序、App、网页、第三方系统都能用同一个 Token跨系统共享容易只要签发密钥一致多个服务都能验同一个 Token当然JWT 也不是没有代价。最典型的一条Token 一旦签发在过期之前你很难主动让它失效除非自己再维护一份黑名单或者引入短过期配合刷新机制。这一点后面单独讲但在选型的时候一定要心里有数。我当时的结论是对于开放的 API 鉴权、前后端分离项目以及对扩展性有要求的系统JWT 是远比 Session 省心的方案如果项目是纯服务端渲染、不需要横向扩展那 Session 也完全够用没必要为了技术时髦而硬上。2. 核心准备包引用、配置项与中间件注册上面说了那么多概念到了动手阶段其实非常快。我会基于 .NET 6 / .NET 8 的 Web API 模板来演示如果你是 .NET Core 3.1 版本步骤也基本一致只是 Program.cs 的写法略有差别。2.1 安装必要的 NuGet 包在项目里通过 NuGet 安装Microsoft.AspNetCore.Authentication.JwtBearer这个包就是官方提供的 JWT 认证中间件。它做的事情简单说就是从请求的 Authorization 头里把 Bearer xxxxx 这段提取出来然后帮你完成解码、验签、检查过期时间最后把里面携带的用户信息填充到当前请求的身份上下文里。如果懒得走 NuGet 管理器也可以直接在命令行里装dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer2.2 在配置文件里定义密钥与签发信息打开 appsettings.json加入一个存放 JWT 相关配置的节点。这里我强烈建议把密钥单独放不要和业务配置混在一起也不要用固定的字符串写死在代码里。{ JwtSettings: { Issuer: your-service-name, Audience: your-client-app, SigningKey: 这是一个至少32位的超长密钥字符串建议用随机生成的值, ExpireMinutes: 120 } }Issuer 是签发方标识Audience 是接收方标识这两个字段会在后面签发和校验时用到。SigningKey 是对称加密密钥用于给 Token 做签名。ExpireMinutes 是 Token 的有效时长我习惯默认设 120 分钟实际项目里根据业务场景调整后面讲刷新 Token 的时候会说。2.3 注册认证与授权服务在 Program.cs 里完成服务的注册和中间件的接入using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); var jwtSettings builder.Configuration.GetSection(JwtSettings); var signingKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings[SigningKey])); builder.Services.AddAuthentication(options { options.DefaultAuthenticateScheme JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidateAudience true, ValidateLifetime true, ValidateIssuerSigningKey true, ValidIssuer jwtSettings[Issuer], ValidAudience jwtSettings[Audience], IssuerSigningKey signingKey, ClockSkew TimeSpan.FromMinutes(1) }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();这一段是整个鉴权链路的地基。要注意UseAuthentication和UseAuthorization的顺序不能反前者先解析 Token 并建立身份后者再判断你有没有权限访问某个接口。很多刚开始接触的同学把顺序写反结果发现带 Token 的请求依然 401就是这个原因。3. 登录接口签发 Token从用户校验到编码输出准备工作做好之后接下来就是写一个真正的登录接口。假设我们的用户校验逻辑是简单的账号密码比对为了演示方便这里用模拟数据代替数据库查询。实际项目中你只需要把用户存在且密码正确这一步替换成自己的登录验证逻辑即可。using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; [ApiController] [Route(api/[controller])] public class AuthController : ControllerBase { private readonly IConfiguration _configuration; public AuthController(IConfiguration configuration) { _configuration configuration; } [HttpPost(login)] [AllowAnonymous] public IActionResult Login([FromBody] LoginRequest request) { // 这里替换成实际的项目校验逻辑比如查数据库或调用认证服务 if (request.Username ! admin || request.Password ! 123456) { return Unauthorized(new { message 用户名或密码错误 }); } var token GenerateToken(10001, admin, Admin); return Ok(new { token }); } private string GenerateToken(string userId, string userName, string role) { var jwtSettings _configuration.GetSection(JwtSettings); var securityKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings[SigningKey])); var credentials new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var claims new[] { new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Name, userName), new Claim(ClaimTypes.Role, role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var token new JwtSecurityToken( issuer: jwtSettings[Issuer], audience: jwtSettings[Audience], claims: claims, expires: DateTime.Now.AddMinutes(Convert.ToDouble(jwtSettings[ExpireMinutes])), signingCredentials: credentials ); return new JwtSecurityTokenHandler().WriteToken(token); } } public class LoginRequest { public string Username { get; set; } public string Password { get; set; } }这里有一个很容易忽视但非常关键的点Claims 的命名直接影响后面在代码里怎么读取用户身份。ClaimTypes.NameIdentifier最终在 Token 里会以nameid的形式出现ClaimTypes.Name会变成unique_nameClaimTypes.Role会变成role。如果你在另一个项目中拿着这个 Token 来解析对不上 claim 类型是常有的事。微软这一套 claim 类型映射在跨系统对接时经常造成混乱所以很多人干脆直接用自定义的字符串作为 claim 名称比如 uid、rid读取的时候用FindFirst(uid)反而更直观、更不容易踩坑。签发完成后前端拿到的 Token 是一个很长的、由三段字符串用点号分隔的字符串。它的内部结构大概是这样的部分内容说明Header{alg:HS256,typ:JWT}说明签名算法和类型Payload{sub:10001,role:Admin,exp:1699999999,...}携带用户身份和过期时间等信息Signature基于前两部分和密钥计算出来的签名保证 Token 没有被篡改你可以在一些在线的 JWT 解析工具上直接粘贴这个 Token解码之后看到里面的具体内容。但要注意Payload 里存的是明文只是 Base64 编码不加密敏感数据千万别往里面塞比如手机号、身份证号、密码这些都是高危行为。4. 验证链路内部到底发生了什么很多教程在讲完签发之后就收尾了但我觉得跳过验证过程的讲解等于只教了一半。你在浏览器里打开一个受保护的接口请求头带上Authorization: Bearer eyJhbGciOiJIUzI1NiIs...这个过程后端到底做了什么我梳理一下中间件的处理顺序提取 Token从请求的 Authorization 头中取出以Bearer开头的部分去掉前缀剩下的就是 Token。解码与结构检查把 Token 分成 Header、Payload、Signature 三段检查格式是否合法。签名验证用我们配置的密钥对 Signature 进行重算如果重算出来的结果和 Token 携带的签名不一致说明 Token 被改过直接拒绝。过期时间检查看 Payload 中的exp字段如果当前时间已经超过过期时间则拒绝。Issuer 和 Audience 校验检查 token 里的签发方和接收方是否和TokenValidationParameters里配置的一致防止 Token 被用于其他系统。构建身份上下文所有校验通过后把 Payload 中的 claims 填充到HttpContext.User中控制器里通过[Authorize]和User.Identity就能直接使用。这个流程里最值得展开讲的是签名验证这一步。我们用的是HmacSha256对称算法密钥只有一个那就是配置里的 SigningKey。签名本质上是对 Header 和 Payload 拼接后的内容做一次哈希计算并加密。因为密钥只有服务端知道所以客户端如果擅自修改了 Payload 里的角色字段服务端重新计算出的签名就对不上Token 自然作废。这就保证了Token 里的内容是可信的、未被篡改的。关于配置项我再补充两个实际经验的说明ValidateLifetime true配合ClockSkew一起理解。ClockSkew 的默认值是 5 分钟意思是即使 Token 在exp时间之后被请求只要不超过 5 分钟依然会放行。这个机制本意是容忍不同服务器之间的时间误差但很多开发者不知道这一点在联调时发现过期了竟然还能访问排查了半天其实是默认的 5 分钟容差在作祟。我习惯显式将其设置为 1 分钟甚至直接归零避免线上出现难以解释的幽灵请求。JwtSecurityTokenHandler默认的 Token 校验也是走TokenValidationParameters所以当你在登录接口里手动解析 Token 时也要注意这些参数的一致性。5. 实战中最容易踩的几个坑附排查链路这部分我想重点展开讲因为我在真实项目里见过太多代码看起来没问题但接口就是 401的案例。逐个说。5.1 401 而不是 403先查认证没过的原因最常见的现象Token 明明带了接口还是返回 401。这种情况下我通常按顺序检查请求头是不是写成Authorization: token xxx必须是Authorization: Bearer xxx大小写不敏感但 Bearer 和 Token 之间必须有一个空格。配置里的密钥和签发时的密钥是否一致。这个在配置文件被多人修改、环境变量覆盖时特别容易出问题。有一个小技巧在登录接口里临时输出一下JwtSettings:SigningKey跟 Startup 里注册中间件时读到的值做对比就能快速定位。Audience 或者 Issuer 是否匹配。签发 Token 时从配置 A 读取的 Audience验证时从配置 B 读取一旦不一致就是 401。我自己遇到的一个真实例子是某次部署到测试环境后所有带 Token 的请求全部 401。排查到最后发现测试环境的 appsettings.json 里 Audience 被另一个同事改成了别的名字而签发 Token 的登录服务用的还是原来的值。所以当 401 大面积出现时先别怀疑代码逻辑先把两边的四元组Issuer、Audience、SigningKey、过期配置逐个比对一遍。5.2 获取不到 User 中的声明Token 验证已经通过了接口也能进但你在代码里写User.FindFirst(ClaimTypes.Role)却返回 null。这个问题的根子在于声明名称的映射前文提到过。HmacSha256 下ClaimTypes.NameIdentifier会映射为nameidClaimTypes.Name会映射为unique_name但读取的时候系统默认还会做一次反向映射。处理办法有两种注册中间件时在TokenValidationParameters的NameClaimType和RoleClaimType上显式指定options.TokenValidationParameters.NameClaimType unique_name; options.TokenValidationParameters.RoleClaimType role;或者在签发 Token 时全部使用自定义 claim 名称读取时直接User.FindFirst(uid)?.Value。我倾向于第二种清晰且不容易被框架的默认行为干扰。5.3 跨域场景下 Authorization 头丢失如果你做的是前后端分离项目前端在调用接口时带了 Token但在浏览器里发现请求头里根本没有 Authorization。这个往往是 CORS 预检请求拦截导致的不是后端鉴权的问题。需要在配置 CORS 时允许请求头builder.Services.AddCors(options { options.AddPolicy(AllowSpecificOrigin, policy { policy.WithOrigins(http://localhost:3000) .AllowAnyHeader() .AllowAnyMethod(); }); });严格来说这是 CORS 配置问题但它在 JWT 鉴权场景里出现频率极高所以一并提醒。5.4 过期时间字段的时区与格式问题签发 Token 时用DateTime.UtcNow还是DateTime.Now在跨时区部署或者对接第三方系统时会造成不小的困扰。我的答案是统一用 UTC。expires: DateTime.UtcNow.AddMinutes(120)因为 JWT 规范里exp是一个 Unix 时间戳秒级跟时区无关。但如果你用DateTime.Now生成到了另一个时区的服务器上做验证加上 ClockSkew 容差之后时间判定就可能偏差。统一 UTC 可以规避绝大多数时区相关的边界问题。5.5 控制台输出中文乱码这个问题严格来说属于基建但我见过太多人卡在这里Token 里的用户昵称是中文在控制台打印日志时变成一串问号。这是因为 Windows 控制台默认编码不是 UTF-8。在 Program.cs 里加一行Console.OutputEncoding System.Text.Encoding.UTF8;或者在项目文件里不加任何处理直接把日志输出到文件系统。中文乱码不会影响 Token 的签发和验证但会影响排查效率尤其是当你需要把 Token 里的 claims dump 出来检查时。5.6 Token 丢失业务信息最后一条是关于设计层面的提醒。某些初学者喜欢把业务数据塞进 Token比如购物车内容、用户昵称、头像 URL美其名曰减少数据库查询。真到了线上你会发现 Token 越来越大、请求头越来越臃肿而且一旦业务数据变更旧 Token 没法强制刷新只能等过期。Token 里只放稳定且必要的身份信息其他数据一律走接口查询。6. 从能跑到好用过期策略、刷新与授权粒度能用 Token 保护接口只完成了 20%。接下来要面对的问题是Token 过期了怎么办、用户长时间不操作怎么办、以及不同角色怎么限制接口访问。6.1 短过期 刷新令牌是主流解法前面我提到过JWT 在过期前很难主动失效。如果 Token 的有效期设得很长比如 7 天用户手机上就一直躺着一个长期有效的令牌一旦泄露等于把钥匙给了别人。我的做法是配两个令牌访问令牌Access Token有效期短比如 1~2 小时用于正常请求接口刷新令牌Refresh Token有效期长比如 7 天或 30 天用于过期后换取新的访问令牌客户端在收到接口返回 401 时自动携带 Refresh Token 去请求一个刷新接口换到新的 Access Token 之后重放原来的请求。[HttpPost(refresh)] public IActionResult Refresh([FromBody] RefreshRequest request) { // 步骤1校验旧的 RefreshToken 是否有效且未过期 // 步骤2校验该 RefreshToken 是否在服务端的有效列表中 // 步骤3生成新的 Access Token 和新 Refresh Token可选 // 步骤4返回给客户端 }刷新令牌的使用需要配套做一块存储比如 Redis 里维护一个userId - RefreshToken的映射保证它能被吊销。这样就解决了 JWT无法主动失效的问题访问令牌虽然短命但即使泄露了也只能在短时间内使用Refresh Token 一旦发现异常服务端随时可以从存储中删除用户下次刷新立刻失效。6.2 基于角色的授权别偷懒全写死在控制器或者 Action 上使用角色限制[Authorize(Roles Admin)] [HttpGet(users)] public IActionResult GetUsers() { return Ok(new[] { user1, user2 }); }调用这个接口时用户必须持有角色为 Admin 的 Token 才能访问否则返回 403。你可以在登录时把用户的多个角色都写入 claims也可以只写一个主角色具体看业务需要。我建议把角色维度独立出来不要在 Token 里塞是否是超级管理员是否拥有某模块权限这种判断结果因为权限是动态变化的而 Token 是静态的。权限判断放在接口层面做比依赖 Token 内容更稳。6.3 接口要不要全部纳入鉴权答案不是全部也不是个别。我的习惯是登录、刷新 Token、注册、验证码这类接口用[AllowAnonymous]放行其余接口默认全部要求认证在全局注册认证中间件不做逐个 Controller 打[Authorize]标签这种容易遗漏的事builder.Services.AddControllers(options { options.Filters.Add(new AuthorizeFilter()); });这样可以避免开发新接口时忘记加[Authorize]导致接口裸奔。真实项目里这类安全事故十有八九是漏加标签造成的而不是 JWT 机制本身的问题。7. 我做 JWT 验证时的一套完整检查清单最后分享一份我自己在每次给项目接 JWT 时都会过一遍的检查清单。它不能替代安全审计但能筛掉绝大多数低级问题。检查项检查内容密钥强度SigningKey 是否随机生成是否达到 32 字节以上是否存放在安全位置Token 数据是否只存必要身份信息是否误塞了密码、手机号等敏感数据过期时间是否使用 UTC是否设置了合理的有效期ClockSkew 是否显式控制配置一致性Issuer、Audience、SigningKey 在签发和验证时是否完全一致中间件顺序UseAuthentication 是否在 UseAuthorization 之前CORS 配置是否允许 Authorization 请求头预检请求是否被正确处理刷新机制是否配置了 Refresh Token 的存储和吊销路径接口覆盖是否所有需要鉴权的接口默认纳入了认证匿名接口是否刻意放行这份清单来自对好几个线上事故的复盘。比如有一次我们发现某个内部接口可以通过改动 Host 头绕过鉴权就是因为在配置里漏配了 Host 白名单跟 JWT 本身无关。再比如某业务方反馈我们的接口偶尔会 401最终定位是网关层对请求头的透传做了截断处理。类似的问题很多学会按照链路一层层排查比掌握一堆高级特性更重要。对我来说JWT 已经从框架功能变成了基础设施。一旦你掌握了签发、验证、中间件配置和刷新机制这四件事后续无论是做多端登录、开放平台 API还是做微服务间的服务身份校验都能平移过去。这篇就写到这里希望你在自己的项目里也能顺利跑通这一整套流程。如果你在接入过程中遇到其他没提到的问题多打印几行日志两三分钟就能定位。
阅读完成 · 觉得有帮助?