
做C#后端开发身份认证和授权是绕不开的坎。JWT授权这套组合基本是前后端分离、微服务架构下的默认选项。很多人用过JWT却也只在“会调用”的层面——知道怎么生成Token、怎么加个[Authorize]特性但一旦遇到401和403分不清、权限粒度不够、Token过期不生效这类问题时就开始抓瞎。这篇博文就从实操出发把JWT的结构原理、在C#里如何签发和校验、如何用角色/策略/自定义Handler做授权逐层讲透把我在真实项目里踩过的坑一并整理出来。适合正在用.NET写Web API、想从“能用”进阶到“用得明白”的开发者。1. 先把方案选型和设计思路说清楚1.1 为什么是JWT而不是Session很多从传统Web开发过来的同学对令牌认证的第一反应是“麻烦不如Session那套省心”。Session机制是服务器端在内存或Redis里存会话标识客户端拿着一个随机ID来换数据服务器要维护这份状态。这在单体应用里完全没问题但一旦要做前后端分离、API给移动端用、多个服务节点负载均衡Session的短板就出来了要么做会话共享要么做粘性会话要么额外引入分布式缓存。每台服务器都得知道会话状态的存在这个“有状态”的设计在分布式环境下会变成维护负担。JWT的思路完全反过来把“你是谁、有哪些权限”这种身份信息编码进一个自包含的字符串里服务器不存会话状态只靠验签来确认Token可信。这就像把身份证直接带在身上检查人员只看证件本身不需要打电话回户籍科核实。只要签名校验通过里面写的身份我就认。这套无状态机制天然适合API网关、微服务、CDN后置这类场景也符合移动端“拿着Token到处跑”的诉求。当然JWT不是银弹。它带来的显著代价是Token签发之后在有效期内很难主动让它失效。Session可以随手删JWT要么等过期要么靠额外维护黑名单。所以选型时要看清楚场景纯API服务、前后端分离、多端接入选JWT很合适如果对会话实时可控要求极高需要服务端主动踢人那还是考虑传统Session配合Redis或者用双Token方案配合短时有效值来缓解。我个人的建议是一般业务系统、开放平台、移动端后台JWT完全够用涉及敏感操作、要求即时吊销能力可以在JWT基础上加黑名单或Token版本号。成熟方案很多关键不是选最炫的而是清楚它的边界。1.2 Token里到底该放什么JWT的解码门槛极低任何人都能在线把payload里的内容读出来。很多人误以为Token里写了数据就安全这是大错特错。JWT的签名只保证数据没有被篡改不保证内容不能被看。所以设计claims时有一条铁律不放入敏感数据。密码、身份证号、手机号、银行卡这类一律别进Token哪怕你觉得编码后别人看不懂也无济于事因为Base64Url解码就是明文。我在项目里通常只放这几个字段sub用户唯一标识推荐用户ID或GUID不要放用户名用户名可能变。name显示名方便前端直接读取不用再调一次用户信息接口。role角色列表多个角色可以用数组或者逗号分隔。这是做角色授权的基础。iss签发者例如api.example.com用于标识Token是哪个服务签发的。aud受众指明这个Token给谁用的通常是API的标识防止Token被别的服务乱用。exp过期时间戳标准字段必需。iat签发时间可选方便排查问题。放role还是不放role业界有争论。放进去的好处是授权时不用查库性能好坏处是角色变更后旧Token在有效期内仍保留旧角色出现“权限未同步”。如果你对权限实时性要求高就只放用户ID授权时从数据库读角色和权限。我的折中方案是把角色放进去但把Token有效期设短一些比如1小时同时要求前端刷新Token。这样每次刷新都会拿最新角色既保性能又基本实时。一句话总结Token里的claim是给授权用的“最小必要信息”多一条就多一分泄露风险少一条就多一次查库开销。需要在两者之间权衡。2. 核心原理签名、验签和授权链2.1 JWT三段式完全解剖一段标准的JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InVzZXIiLCJyb2xlIjoiQWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c。肉眼能看到两段和分节后的第一个点、第二个点。用工具拆开第一段是Header第二段是Payload第三段是Signature。Header里是类型和算法。类型固定JWT算法则根据签名方案来常见的是HS256、RS256。HS256是对称算法签发和验签用同一个密钥像家里大门钥匙一人有钥匙全家能开适合单一服务签发并校验。RS256是非对称算法签发用私钥验签用公钥私钥永远留在认证服务内部公钥可以公开给其他服务适合微服务架构认证中心用私钥签业务服务用公钥验。Signature的生成过程把前两段内容做签名。以HS256为例伪代码就是data base64url(header) . base64url(payload) signature HMACSHA256(data, secret)Base64Url和普通Base64有区别它把换成-把/换成_去掉末尾的因为URL里用普通Base64会因特殊字符出现兼容问题。所以JWT可以安全地放在URL、Header、Cookie里。验签时服务器用同样的算法和密钥重新对前两段计算签名如果计算结果和第3段一致说明Token没被篡改然后还要检查exp是否过期、iss和aud是否匹配。每一步都有对应的框架配置后面我会讲具体的参数。2.2 认证和授权两个环节别混淆很多人把认证和授权混为一谈其实这是两条独立但顺序固定的链路。认证Authentication解决“你是谁”的问题你拿出一张Token服务器验签通过后认为你是那个用户。授权Authorization解决“你能干什么”的问题系统拿到你的身份后根据角色、策略判断你能否访问某个接口。在ASP.NET Core的中间件管道里认证中间件会把Token转换成一个ClaimsPrincipal也就是基于声明Claim的身份主体里面包含你Token里的那些claims。授权中间件再基于这个主体做判断比如检查你是不是Admin角色或者满足某个策略。顺序是认证中间件在前授权中间件在后。中间件一旦顺序反了授权时会发现当前用户是空的结果就是所有接口全部401或403这是非常典型的配置错误。我举个生活化的例子。你进公司大门要出示工牌门卫验过是真的这是认证。到了财务室行政要求只有财务总监才能进去这是授权。门卫和行政是两拨人各司其职。代码里UseAuthentication和UseAuthorization就是这两个角色的注册位置中间不要放其他可能短路请求的中间件否则会出现“你已经证明你是谁了但没人帮你验证能不能进”的诡异问题。3. C#实操签发Token和配置认证中间件3.1 准备工作依赖包和基础环境在.NET 8/9环境下我采用最小API或者Controller项目都可以这里以Controller为主演示因为大家更熟悉。需要引入这几个包System.IdentityModel.Tokens.Jwt负责创建和解析JWT里面有JwtSecurityToken、JwtSecurityTokenHandler等核心类型。Microsoft.AspNetCore.Authentication.JwtBearer提供Bearer认证中间件让认证管道能自动校验Authorization请求头里的Bearer token。Microsoft.AspNetCore.Authorization授权框架通常在AspNetCore框架中自带不用额外装。如果用的是较新的.NET统一包Microsoft.AspNetCore.App授权和JwtBearer其实都作为框架引用的一部分你只需要单独确认System.IdentityModel.Tokens.Jwt是否引入。创建项目时如果勾选了“启用OpenAPI”依赖会更完整。密钥准备是重中之重。HS256签名要求密钥字节长度至少为256位32字节。你拿一个短的字符串比如mykey去用运行时会抛异常或者签名校验失败我还真遇到过直接把123456当密钥的人也是心大。实务上建议把密钥放在配置中心或环境变量里不要写死在代码中。开发环境我用一个64字符以上的随机字符串例如dev-only-jwt-signing-key-with-at-least-64-chars-length这样能满足长度要求。生产环境务必用更强的随机密钥并定期轮换。3.2 手工签发Token的完整实现下面这段代码在登录接口中生成Token我用Controller的写法演示。它接受用户名和密码实际项目中应校验密码校验通过后生成Token并返回。[HttpPost(login)] public IActionResult Login([FromBody] LoginRequest request) { // 实际项目中要校验用户名密码例如通过UserManager if (request.Username ! admin || request.Password ! 123456) return Unauthorized(); var claims new ListClaim { new Claim(JwtRegisteredClaimNames.Sub, 用户ID-10001), new Claim(JwtRegisteredClaimNames.Name, request.Username), new Claim(ClaimTypes.Role, Admin), new Claim(department, 技术部) }; var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config[Jwt:SecretKey])); var credentials new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: config[Jwt:Issuer], audience: config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddHours(1), signingCredentials: credentials ); var tokenString new JwtSecurityTokenHandler().WriteToken(token); return Ok(new { token tokenString, expiresIn 3600 }); }这里有几个容易踩的细节expires一定要用DateTime.UtcNow不要用本地时间。如果服务器时区不是UTC用DateTime.Now会带来前后端时间基准不一致导致Token短命或者提前报过期。JwtRegisteredClaimNames.Sub、Name这些是标准claim名写出来的Key是sub、name。也可以直接用ClaimTypes.NameIdentifier但要注意.NET会把它映射成http://schemas.../nameidentifier见下一节。ClaimTypes.Role很特殊由认证框架映射为role后面的授权特性[Authorize(Roles Admin)]靠它识别。自定义claim像department随意起但要避免和JWT标准字段重名防止解析时互相覆盖。返回给前端时我习惯返回{ token, expiresIn }而不是只返回token方便前端提前预判刷新点。还有项目用exp是Unix时间戳前端可以自己解码但给个expiresIn更省事。3.3 配置JwtBearer服务与中间件在Program.cs里注册认证服务代码如下var builder WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(Bearer) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:SecretKey])), ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();注意顺序AddAuthentication后要接AddJwtBearerAddAuthorization可以紧随其后。中间件UseAuthentication必须出现在UseAuthorization之前同时要在MapControllers之前。如果你把UseAuthentication放到MapControllers后面路由已经开始处理请求但还没认证最后接口拿不到用户身份会莫名其妙报错。TokenValidationParameters里的校验开关建议全部打开尤其ValidateLifetime。我见过有人没开ValidateLifetimeToken早过期了还能正常访问查了半天才发现这里默认值不是true。ClockSkew是时钟偏移容忍量默认5分钟意思是即使Token过期了5分钟内还能通过校验。如果不希望有过期缓冲可以直接设成TimeSpan.Zero但对于客户端和服务器时间存在轻微偏差的场景设个30秒比较稳妥。AddJwtBearer里还有一个经常被忽略但非常实用的配置options.Events.OnTokenValidated。你可以在每次Token校验通过后在这里追加逻辑比如检查用户是否被禁用、是否在黑名单中、刷新最后活跃时间。它是在授权前拦截的最后一道关卡比在控制器里写判断干净得多。3.4 用特性保护接口的基础用法认证中间件只管解析Token不会自动禁止未认证请求。真正起拦截作用的是[Authorize]特性或者授权策略。用法分三档// 登录这些公开接口不需要认证 [AllowAnonymous] [HttpGet(public)] public IActionResult Public() Ok(公开数据); // 登录用户即可访问只要提供了有效Token [Authorize] [HttpGet(profile)] public IActionResult Profile() Ok(个人资料); // 指定角色才能访问 [Authorize(Roles Admin)] [HttpGet(admin)] public IActionResult Admin() Ok(管理员数据);[Authorize]表示“必须有认证用户”但不管具体身份。如果Token校验失败接口返回401没有权限则返回403。[AllowAnonymous]则是在控制器上有[Authorize]时单独放开某个方法用。注意[AllowAnonymous]优先级高但如果你在控制器配了[Authorize]某个方法配了[AllowAnonymous]这个方法会被跳过认证所有请求都能进。敏感接口别图省事这么搞容易裸奔。在多角色场景下[Authorize(Roles Admin,Manager)]表示具备Admin或Manager任意一个角色即可逗号是“或”的关系。多个[Authorize(Roles Admin)]堆叠在一起是“且”的关系所有特性里的条件都必须满足。这个区分很多新手搞不清从字面上看逗号很容易误以为是“且”其实不是。4. 授权进阶从角色到策略再到资源4.1 基于角色的授权最直接但别滥用角色授权是绝大多数项目的起步方案。用户登录后把角色放claim里接口上用角色名硬编码判断。它的优点是简单直观缺点也很明显角色是粗粒度的一个“管理员”可能什么都写得死后面想只允许某个管理员操作某个功能角色授权就无能为力了。例如有一个接口只允许运营角色访问但在一种异常情况下需要临时让客服角色也能访问你不能直接改角色名只能往角色列表里加“客服”。角色越加越多权限边界慢慢模糊。所以角色授权适合权限模型简单、角色数量少、职责边界清晰的项目。一旦出现“谁能访问”和“用户角色”不完全对应时就该考虑基于声明或基于策略的授权了。实际开发中角色claim里放多个角色是常见的比如用户既是Admin又是Editor。在生成Token时ClaimTypes.Role可以Add多个claims.Add(new Claim(ClaimTypes.Role, Admin)); claims.Add(new Claim(ClaimTypes.Role, Editor));框架在解析时会把多个相同类型的claim归为一组授权时要判断是否包含指定值。这种设计比把所有角色拼成一个字符串再Split要规范得多。4.2 基于策略授权把判断逻辑集中起来策略授权是对角色授权的自然升级。它把多个判断条件封装成一个策略控制器上只需要[Authorize(Policy IsAdmin)]具体规则在注册服务时定义。好处是规则修改时不用去每一个控制器改策略本身还能复用。注册策略的典型写法builder.Services.AddAuthorization(options { options.AddPolicy(IsAdmin, policy policy.RequireRole(Admin)); options.AddPolicy(TechDepartment, policy policy.RequireClaim(department, 技术部)); options.AddPolicy(AdultOnly, policy policy.RequireAssertion(ctx ctx.User.HasClaim(c c.Type age int.Parse(c.Value) 18))); });策略可以是角色、声明、甚至一段自定义断言。比如RequireAssertion里的逻辑可以写进隐藏条件只要有claim就能判断。但这种断言写在注册代码里一旦复杂了可读性会下降。更推荐用自定义Requirement和Handler来承载复杂逻辑。策略的优势还在于可以组合多个IAuthorizationRequirementoptions.AddPolicy(ManagerOfTech, policy { policy.RequireRole(Manager); policy.RequireClaim(department, 技术部); });默认多个Requirement之间是“且”关系。这样你能把“角色部门某个权限点”组合成一个策略基本满足中小型业务95%的场景。4.3 自定义授权Handler实现资源级权限有时候权限不只是“角色是什么”而是要判断“这个资源属不属于你”。比如编辑功能文档只能作者本人或管理员能改。这种判断发生在运行时参数是具体的资源ID没法用静态的角色或声明写死。这时就要上自定义Requirement和Handler了。先定义Requirementpublic class IsOwnerRequirement : IAuthorizationRequirement { }然后实现Handlerpublic class IsOwnerHandler : AuthorizationHandlerIsOwnerRequirement { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IDocumentRepository _repo; public IsOwnerHandler(IHttpContextAccessor httpContextAccessor, IDocumentRepository repo) { _httpContextAccessor httpContextAccessor; _repo repo; } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, IsOwnerRequirement requirement) { var userId context.User.FindFirstValue(ClaimTypes.NameIdentifier); var httpContext _httpContextAccessor.HttpContext; var documentId httpContext.Request.RouteValues[id]?.ToString(); if (string.IsNullOrEmpty(documentId) || string.IsNullOrEmpty(userId)) return Task.CompletedTask; var doc _repo.GetById(int.Parse(documentId)); if (doc null) return Task.CompletedTask; // 如果文档属于当前用户或者用户是管理员则满足要求 if (doc.OwnerId userId || context.User.IsInRole(Admin)) context.Succeed(requirement); return Task.CompletedTask; } }Handler里没调用context.Fail()表示不满足时不主动失败而是“不成功”。这样在组合多个Handler时其他条件还有机会通过。如果你确定该条件一旦失败就整体失败才显式调用Fail。这是AuthorizationHandler的一个微妙设计用错会导致其他权限被“误杀”。注册Handler和策略builder.Services.AddSingletonIAuthorizationHandler, IsOwnerHandler(); builder.Services.AddAuthorization(options { options.AddPolicy(CanEditDocument, policy policy.Requirements.Add(new IsOwnerRequirement())); });控制器里这样用[Authorize(Policy CanEditDocument)] [HttpPut(documents/{id})] public IActionResult Update(int id) { // 走到这里说明已满足策略 return Ok(); }访问/documents/123时框架会从路由拿id然后查库判断归属。这种资源级权限是角色授权很难优雅实现的自定义Handler才是正解。4.4 在代码里动态检查IAuthorizationService[Authorize]特性适合在路由匹配后、方法执行前做静态拦截。但有些场景没法用特性比如在同一个方法内要根据某个运行时条件决定是否有权限继续下一步。这时要用IAuthorizationService.AuthorizeAsync手动做授权判断。用法示例public class DocumentController : ControllerBase { private readonly IAuthorizationService _authService; public DocumentController(IAuthorizationService authService) { _authService authService; } [HttpDelete(documents/{id})] public async TaskIActionResult Delete(int id) { var doc _repo.GetById(id); if (doc null) return NotFound(); var result await _authService.AuthorizeAsync(User, doc, CanDeleteDocument); if (!result.Succeeded) return Forbid(); _repo.Delete(id); return NoContent(); } }这里AuthorizeAsync的第二个参数是resource第三个是策略名。自定义Handler可以从AuthorizationHandlerContext.Resource拿到这个对象直接判断资源属性比从路由取值更优雅。对于复杂业务比如一个接口里既有新增又有删除或者要根据请求体内容判断权限动态检查是必须的。5. 实际项目中踩过的坑和排查清单5.1 401和403分不清先看中间件和特性HTTP状态码401表示“未认证”意思是服务器不知道你是谁。403表示“已认证但没权限”。如果你发现某个接口调不通先判断返回的是哪个码。返回401的常见原因请求头没有带Authorization: Bearer token。Token签名不合法密钥换了或错了。Token过期了或ValidateLifetime被开启而Token确实过期。ValidateAudience不匹配。返回403的常见原因认证通过但[Authorize(Roles Admin)]里的角色不满足。自定义Handler没有调用context.Succeed()。策略本身没注册或控制器引用的策略名错了。中间件顺序错误导致授权时拿不到用户主体。实际排查时可以在AddJwtBearer的事件里设置OnChallenge和OnForbidden打日志看进入逻辑的时机。比如options.Events new JwtBearerEvents { OnChallenge context { // 未认证时进入 return Task.CompletedTask; }, OnForbidden context { // 认证通过但授权失败时进入 return Task.CompletedTask; } };这样你能快速判断是认证挂了还是授权挂了不用在接口里到处打日志。5.2 Token过期总是不生效时钟偏移在作怪很多人配好了ValidateLifetime true但测试时发现Token明明过期了还能用于是怀疑框架有bug。其实多半是默认的ClockSkew太宽容。默认值5分钟意味着过期4分钟依然算有效。如果你希望严格过期把ClockSkew设成TimeSpan.Zero或30秒。还有一个常见坑是签发Token时用DateTime.Now.AddHours(1)而校验时框架用UTC时间做比较。本地服务器在东八区时DateTime.Now比UTC早8小时导致Token实际有效时长变成“1小时8小时”当然看着像没错但换到别的时区服务器就会出现诡异行为。永远都用DateTime.UtcNow做时间基准同时检查服务器系统时间是否准确。如果服务器时间偏差过大用NTP同步一下否则任何时间戳校验都会出问题。5.3 密钥长度和算法不匹配HS256需要密钥至少32字节这是算法本身的要求。如果你用SecurityAlgorithms.HmacSha256签名但密钥只有十几个字符运行时会在签名时抛SecurityKeyException或者创建Token时静默失败。解决方法是正式密钥用随机生成器生成至少32字节。开发环境写死也要写够长度别省事。RS256算法则完全相反签发时用私钥验签时用公钥。如果你的架构是多服务认证服务用私钥签业务服务用公钥验一定要保证业务服务只拿到公钥。如果业务服务误配了私钥后果是任何持有私钥的人都可以签发任意Token安全性直接崩穿。5.4 Claim映射导致角色失效的坑这是一个很隐蔽的坑。JWT里存的claim标准名如sub、name在ASP.NET Core中默认有一个JwtSecurityTokenHandler.DefaultInboundClaimTypeMap它会把一些标准claim映射成.NET内部的URI形式。比如sub可能被映射到http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifierrole可能映射到另一个URI。这会导致你用ClaimTypes.Role在Token里设置角色后框架解析时key变了[Authorize(Roles Admin)]判断不到角色。解决办法是关闭映射JwtSecurityTokenHandler.DefaultMapInboundClaims false;在程序启动最早的地方加这一行让JWT claim保持原样。或者在AddJwtBearer里设置options.MapInboundClaims false;关闭后再注册TokenValidationParameters这样sub就是subrole就是role不会出现查claim查不到的问题。但注意关闭映射后如果你在其他地方用ClaimTypes.NameIdentifier去查用户ID是查不到的因为key不再是那个长URI了。要么统一改用JwtRegisteredClaimNames.Sub要么在创建Token时自定义一个固定的claim名如uid所有代码统一用它。5.5 多环境下的Issuer和Audience不一致项目分开发、测试、生产环境配置文件里Jwt:Issuer和Jwt:Audience很容易漏改。有时候前端连的是测试环境后端却读取生产配置结果Token签发和校验的房子都不一样表现为在生产能登录到测试环境就一直401。排查时用在线工具解码Token看iss和aud字段值再和本环境appsettings.json里的配置对比。如果前后端分离还要注意前端发起请求时使用的API地址对应哪个环境。我见过一个项目前端配置没更新把测试环境的Token发到生产环境API生产验签不认直接401前端代码却看着没问题。这种问题最难查靠日志看签发环境和校验环境的配置值能快速定位。另外Audience可以理解成“这张票发给谁”。一个认证服务签发多个业务API的Token时要在签发时指定不同的aud。业务API校验时ValidAudience必须与之一致。如果只配了ValidateIssuer true而漏了ValidateAudience被滥用的Token可能跑进其他服务。安全防线每一项都尽量打开。5.6 调试技巧一劳永逸的定位方法调试JWT相关接口我建议准备三件套第一JWT解码工具。把Token贴进去就能看到Header、Payload、签名是否通过。有些工具还支持直接输入密钥验证签名是否有效排查签名不对时特别快。第二Postman或Apidog。设置全局Header把Token存成环境变量避免每个请求手贴。在Collection Variable里更新Token过期后重新登录一次自动替换效率高很多。第三日志。在TokenValidationParameters的生命周期里OnTokenValidated和OnAuthenticationFailed常常被我用来记日志。OnAuthenticationFailed会给出异常原因比如“Signature validation failed”或者“Lifetime validation failed”基本一眼就能定位问题。生产环境不要明文打Token但可以打异常消息和过期时间。如果怀疑是授权策略问题还可以在授权中间件里注册一个AuthorizationMiddleware的日志或者直接断点调试AuthorizationHandler。我通常会写一个小端点返回当前用户的claims列表[Authorize] [HttpGet(debug-claims)] public IActionResult DebugClaims() { var claims User.Claims.Select(c ${c.Type}: {c.Value}).ToArray(); return Ok(claims); }这个端点不要留在生产环境但调试期超好用。它把所有解析后的claim显示出来你能立刻看出自己设的role是否被映射成了别的东西。最后分享一点个人经验在实际项目里折腾JWT授权这么久我的体会是JWT本身不复杂复杂的是“信任边界”。你决定把哪些信息放进去、用哪种算法、把校验开关打开几个、授权策略做到多细这些设计比调用代码更重要。安全性的短板往往不在Token的技术实现而在配置的不严谨。比如密钥写死在代码里、claims里塞了手机号、关闭了校验Audience这些才是真正需要警惕的地方。另外如果团队刚起步先按“短过期Token角色授权”跑起来不要一上来就设计几十个策略和自定义Handler。等真正遇到“角色不够用”的痛点再升级到策略再升级到资源级授权。过度设计会让授权代码比业务代码还复杂反而更不好维护。如果后续要做单点登录、第三方登录、刷新Token建议直接研究一下标准协议把授权码模式、刷新令牌、JWKS动态密钥等都纳入规划。JWT是手段不是目的真正目的永远是让系统和数据在可控的信任边界内可靠运行。