☰
C# JWT认证实战:从原理到ASP.NET Core集成与避坑指南
2026/10/15 4:24:01 网站建设 项目流程

1. 从Session到JWT:C#服务端认证方式的演进逻辑

在C#后端项目里,我经常被问到同一个问题:“JWT到底怎么用?网上代码一搜一大堆,但看着都差不多,也没什么积累感。”这个问题问得多了,我发现大家真正困惑的不是那几行API调用,而是为什么要用JWT、它取代的是什么东西、它在整个请求链路里处于什么位置。想把这层想明白了,代码反而是水到渠成的事。

先聊一段背景。早年写ASP.NET Web API,最常用的登录状态方案是Session,服务端生成一个SessionId,扔进Cookie发给浏览器,同时把用户数据存在服务端内存或分布式缓存里。这个方案在单体应用、单服务器部署时非常顺手。但后来大家普遍碰到两类痛处:一是服务端存储压力,用户量上来之后,Session数据的存储和回收本身就成了一项维护工作;二是多实例部署或多端接入时,同一个SessionId要能被多个服务器或App识别,就得把Session抽出来存Redis,或者做粘性会话,链路立刻变得复杂了。

Token方案就是为了绕开这些问题出现的。服务端不存用户状态,而是把用户身份信息做一次签名,交给客户端保存,下一次请求客户端原样带回来,服务端只验签名、看时间、读内容,校验通过就认为请求合法。这种思路也常被叫作无状态认证,它把“记录用户登录状态”的责任从服务端转移到了Token本身。

JWT(JSON Web Token)是这种Token方案里使用最广的实现标准,本身不是微软的东西,而是一套开放的RFC规范。C#生态里微软官方提供的类库对这个规范做了完整落地,所以在ASP.NET Core里用它非常顺手。这篇文章我会从基础原理讲到实际编码,再重点说几个我在真实项目里踩过的坑,适合正在做.NET后端、要把登录和接口鉴权梳理清楚的开发者参考。

2. JWT三段结构拆解:Header、Payload与Signature

JWT的形式是一串很长的字符串,用小数点分成三段,像这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImFkbWluIn0.signature部分。很多第一次接触的人以为这是一段密文,其实它只是三段经过Base64Url编码的普通文本,可以直接解码看内容。这也是理解JWT最关键的一步——JWT的Body不是加密的,只是编码,任何拿到Token的人都能看到里面的所有信息。

第一段叫Header,里面声明了Token的签名算法和类型,最常见的写法是:

{ "alg": "HS256", "typ": "JWT" }

alg字段是整个Token里最重要的元信息,它决定了后面签名部分是用什么算法算出来的。C#里常见的取值有HS256(HMAC-SHA256)、RS256(RSA-SHA256),还有ES256(ECDSA)。很多人选算法时不假思索,看到教程里写HS256就跟着写,如果项目有多个服务做验签,用对称密钥还是非对称密钥的差别会直接决定安全边界,后面我会专门展开。

第二段叫Payload,承载的是Token的Claims,也就是“想声明的事实”。标准里预留了一批字段名字,比如:

  • sub(Subject):主体,一般存用户ID
  • exp(Expiration Time):过期时间,标准要求必须是Unix时间戳
  • nbf(Not Before):生效时间,早于这个时间Token不可用
  • iat(Issued At):签发时间
  • iss(Issuer):签发方
  • aud(Audience):接收方

除了这些标准字段,业务自定义的信息也可以直接放这里,比如角色、权限码、部门ID。但这里有一条红线:任何敏感信息都不允许放进去。因为Payload只是Base64Url编码,不是加密,谁拿到Token解码就能看到。密码、身份证号、手机号这些一律排除。如果有人跟你说“那我把Payload用非对称加密一下不就行了?”——可以,但那就不是JWT了,是JWE,属于另一套标准,复杂度也完全不一样。实际项目里99%的认证需求用JWT的签名机制就够了,不必上JWE。

第三段叫Signature,是把前两段的编码结果拼在一起,用Header里声明的算法和密钥算出来的签名。它的作用是防止Token被篡改。如果有人改动了Payload里的任何一个字符,签名校验就会失败。你可以把它类比成一个防伪标签:内容谁都看得见,但你想改内容而不留下痕迹,做不到。

这里顺带解释一下Base64Url:标准Base64编码里包含+、/和末尾的=,这些字符放在URL里会有歧义,所以JWT规范把+换成-、/换成_,并且去掉末尾的=。C#的Base64UrlEncoder类专门处理这个转换,自己手写时不要直接用普通Base64的API。

签名计算流程在C#里的示意如下:

var header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; var payload = "{\"sub\":\"user123\",\"exp\":1735689600}"; var headerPart = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(header)); var payloadPart = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payload)); var signingInput = headerPart + "." + payloadPart; var key = Encoding.UTF8.GetBytes("这是一个足够长的密钥字符串"); using var hmac = new HMACSHA256(key); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)); var signaturePart = Base64UrlEncoder.Encode(hash); var token = signingInput + "." + signaturePart;

用类库之前能亲手把这个流程跑一遍,后续理解TokenValidationParameters那一堆配置项会轻松很多。因为所有校验逻辑,本质就是在帮你做“重新计算签名并对比”和“检查exp、nbf时间”这些事。

3. C#生态里的JWT类库选型对比

C#里处理JWT的库不少,但真正值得关注的就这么几个。我在不同项目里试过好几种,最终沉淀下来的结论非常简单:如果是ASP.NET Core项目,直接选微软官方那一套;如果不是ASP.NET Core环境,再看轻量替代方案。

先上一张对比表,后面逐行解释:

类库包名核心定位适用场景
JWT处理底层库System.IdentityModel.Tokens.Jwt生成、解析、验证JWT Token,不依赖ASP.NET Core任何.NET环境下的Token操作
认证中间件Microsoft.AspNetCore.Authentication.JwtBearer把JWT接入ASP.NET Core的认证与授权管线,自动完成请求拦截和身份装配基于ASP.NET Core的Web API、Microservice
轻量库jose-jwt实现JOSE系列标准,API简单,体积小非ASP.NET Core运行时、工具类项目、桌面端
完全自研无自己做Base64Url、签名、校验学习用途,或安全要求极高时完全掌控细节

我重点说说微软官方这套的使用分工。Microsoft.AspNetCore.Authentication.JwtBearer是一个中间件,它做的事情是在请求管道里读取Authorization请求头的Bearer xxx,调用JwtSecurityTokenHandler验证Token,验证通过后把Claims转换成一个ClaimsPrincipal塞进HttpContext.User。这样你的Controller或Minimal API里直接用[Authorize]特性、User.Identity.Name、User.IsInRole(...)就行,非常顺手。

System.IdentityModel.Tokens.Jwt是底层的Token处理器,不依赖Web层,但绝大多数时候你不会直接调用它,因为JwtBearer中间件已经把活干完了。只有在做Token生成、或者要在非HTTP环境(比如一个后台任务、一个命令行工具)里解析Token时,才需要直接用它。

jose-jwt那个库,我只在一些没有ASP.NET Core的轻量场景用过,比如一个控制台工具要自己签发Token给别的系统,或者项目跑在旧版.NET Framework上又不想迁移。它的API很简洁,但功能也比微软官方少不少,比如复杂的Claim类型映射、多种校验策略配置,都要自己手动处理。我的建议是:默认选JwtBearer + System.IdentityModel.Tokens.Jwt的组合,用到发现确实不合适再换,没必要一上来为了“轻量”做技术选型。

我之前维护过一个给多个外部系统做单点登录的项目,验签方有好几个,有的用C#,有的用Java,还有用Python的。这种场景下必须用非对称签名RS256,因为签发的密钥只有一个,不能发给外部系统,它们各自拿公钥就能验签。而微软这套类库对RS256、HS256、ES256都支持得很好,密钥格式上JSON Web Key、X.509证书也都兼容。这时候就不需要考虑jose-jwt了,统一一套库省事得多。

4. ASP.NET Core里跑通JWT完整链路

这个章节就是动手环节了,我用一个最常见的对称密钥场景做Demo:一个Web API项目,登录接口校验用户密码后签发Token,其他接口带Token访问,验证通过才能拿到数据。

4.1 引入包和服务注册

假设项目已经建好,我用的框架是ASP.NET Core 6及以上。需要引入的NuGet包就两个:

  • Microsoft.AspNetCore.Authentication.JwtBearer
  • System.IdentityModel.Tokens.Jwt

第二个包通常是第一个包的依赖,会一起装进来,但显式引用更方便后续写生成Token的代码时直接使用命名空间。

然后在Program.cs里注册认证服务:

using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { var config = builder.Configuration; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = config["Jwt:Issuer"], ValidateAudience = true, ValidAudience = config["Jwt:Audience"], ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(1), ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(config["Jwt:SecretKey"])) }; }); builder.Services.AddAuthorization(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();

注意顺序:UseAuthentication必须写在UseAuthorization之前。UseAuthentication负责把请求里的Token“读出来并解析成用户身份”,UseAuthorization负责根据身份信息做授权判断。顺序反了,后面的授权环节拿不到身份,结果就是明明带了Token也全部401。这个顺序问题我在几个新人的代码里都见过,排查起来其实很费劲,因为报错信息不会直接告诉你顺序有问题。

appsettings.json里对应的配置我也给出来:

{ "Jwt": { "Issuer": "my-issuer", "Audience": "my-audience", "SecretKey": "至少32位的随机字符串,推荐用GUID拼随机数生成" } }

密钥这一项值得多说两句。HS256的密钥长度要求是至少256位,也就是32字节。如果你随便写一个短字符串,比如"123456",运行起来可能不报错,但那是类库帮你做了填充或者算法兼容处理,安全性上等于没锁门。推荐用Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))生成一个32字节随机串存到配置里。另外,用了配置项之后记得在部署环境里用环境变量覆盖,不要把正式密钥提交到代码仓库。

4.2 登录接口与Token生成

接下来是签发Token的逻辑。这里我封装成一个简单服务,代码可以直接抄:

using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class TokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) { _config = config; } public string CreateToken(User user) { var claims = new List<Claim> { new Claim(JwtRegisteredClaimNames.Sub, user.Id), new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName), new Claim("role", string.Join(",", user.Roles)), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var key = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"])); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var now = DateTime.UtcNow; var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, notBefore: now, expires: now.AddMinutes(30), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } }

几个细节说明一下。

第一,签发时间用的是DateTime.UtcNow,不要用DateTime.Now。JWT里的时间字段最终会换算成Unix时间戳,而Unix时间戳基于UTC。如果机器本地时区不是UTC,用DateTime.Now算出来的过期时间会和实际偏差几个小时,表现就是Token要么提前失效要么迟迟不失效,排查半天还以为是时钟问题。

第二,Jti这个Claim我建议每次都生成一个新的Guid放进去,它的作用是给Token一个唯一ID。后面做单点登录控制、Token作废、审计日志都可以拿它做关联键。暂时用不上也没关系,预留这个字段几乎零成本,需要时再回头加就麻烦了。

第三,role这个Claim的命名。我这里直接写的是字符串"role",没有用ClaimTypes.Role。原因和微软的Claim映射机制有关,我放到第5章详细拆,这里先提示一下:**如果你用了库默认的映射规则,role会被改写成http://schemas.microsoft.com/ws/2008/06/identity/claims/role这种长URI,而且多个角色放一个Claim里还会导致角色解析的怪问题。**很多人写的时候没注意,结果用[Authorize(Roles = "admin")]时发现权限控制完全不生效。

登录Controller的写法就常规了:

[ApiController] [Route("api/auth")] public class AuthController : ControllerBase { private readonly TokenService _tokenService; public AuthController(TokenService tokenService) { _tokenService = tokenService; } [HttpPost("login")] public IActionResult Login(LoginRequest request) { // 这里做真实的用户校验,从数据库或外部认证服务查询 var user = FakeUserStore.Verify(request.UserName, request.Password); if (user == null) return Unauthorized(new { message = "用户名或密码错误" }); var token = _tokenService.CreateToken(user); return Ok(new { token, expiresIn = 1800 }); } }

4.3 受保护接口与Swagger配置

生成Token之后,受保护接口只需要加一行[Authorize]:

[ApiController] [Route("api/orders")] public class OrdersController : ControllerBase { [HttpGet] [Authorize] public IActionResult GetOrders() { var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub); // 根据userId查询订单列表 return Ok(new { message = $"用户 {userId} 的订单列表" }); } }

如果加了自定义的Policy,比如要求角色是admin:

[HttpDelete("{id}")] [Authorize(Roles = "admin")] public IActionResult DeleteOrder(int id) { // 管理员才能执行删除 return NoContent(); }

Swagger里也要配一下Bearer认证,否则测试接口很不方便。在AddSwaggerGen里加一个安全定义:

builder.Services.AddSwaggerGen(options => { options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = ParameterLocation.Header, Description = "请输入Token,注意不要加Bearer前缀" }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, Array.Empty<string>() } }); });

配完之后,Swagger右上角会有一个Authorize按钮,填入Token之后,后面所有请求都会自动带上Authorization头,调试效率会高很多。

这整个链路跑通之后,你会发现C#里JWT的核心代码其实没几行。剩下的问题基本都集中在安全策略、Token生命周期管理和各种边界情况上,也就是下一章要聊的内容。

5. 实际项目中JWT最容易踩的六个坑

代码能跑通只是第一步。我在这里把真实项目里遇到过、以及帮别人排查过的JWT问题汇总一下,这六个坑覆盖了80%的“上线后JWT突然失效”类故障。

5.1 Claim类型映射被自动改写

这是第一位要说的,因为它非常隐蔽。微软的JwtSecurityTokenHandler在解析Token时,默认会做一层Claim映射(DefaultInboundClaimTypeMap),会把JWT里简短的Claim名映射成.NET传统的长URI格式。举个例子,JWT里你签的"role",经过默认映射会变成http://schemas.microsoft.com/ws/2008/06/identity/claims/role。而很多人在Controller里用User.FindFirst("role")去取值,取到的是null,因为那个Claim的真实名字已经被改写了。

解决方案有两个,我推荐第二种:

// 方案一:关掉整个映射 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

这个做法是开关掉映射,让解析结果保持JWT里的原始Claim名。但它的副作用是sub、unique_name这些标准Claim也不会映射成ClaimTypes.NameIdentifier、ClaimTypes.Name了,一些依赖默认行为的第三方组件可能受影响。

// 方案二:在服务注册时只清除映射,其余用标准处理 options.TokenValidationParameters = new TokenValidationParameters { // ... NameClaimType = "unique_name", RoleClaimType = "role" };

方案二的思想是保持映射打开,但告诉框架“用户名字段的来源是哪个Claim”、“角色字段的来源是哪个Claim”。这样你在HttpContext.User.Identity.Name里拿到的就是unique_name的值,User.IsInRole("admin")也能正确工作,这个方案在实际项目中验证过多次,兼容性最好。

5.2 签名算法不匹配导致验签失败

这个坑的场景是这样的:某天你发现所有Token都突然报401,查看日志发现异常是IDX10603: Algorithm mismatch。原因要么是签发时用HS256、验证时配置的是RS256,要么是应用重启后配置被覆盖成别的算法。

C#类库的校验逻辑是:**验证器使用你配置的IssuerSigningKey和算法,对Token的头部声明的算法重新计算签名,两边必须一致才算通过。**如果签发和验证的算法配置出自两个不同环境,最常见的原因是测试环境和正式环境的appsettings配置漂移了。

排查方法很简单,拿一个还不过期的Token,去jwt.io这类工具站点解码看Header里的alg字段,然后对照服务端配置的SecurityAlgorithms。不是在线的站点无法访问的话,也可以在本地用代码打印:

var handler = new JwtSecurityTokenHandler(); var jwt = handler.ReadJwtToken(tokenString); Console.WriteLine(jwt.Header.Alg);

另外注意HS256和RS256不只是算法名不同,密钥类型也不同。HS256用的是对称密钥(原始字节),RS256用的必须是非对称密钥对象(RSA公钥/私钥)。有些人在配置里把RSA公钥字符串直接塞给SymmetricSecurityKey,类库会直接抛出异常。

5.3 过期时间相关的时间陷阱

CSDN和博客园上这类问题能刷一页:Token明明设了7天有效,为什么第2天就失效了?除了前面提到的时区问题,还可能踩到这两个细节。

第一个是exp字段值的计算方式。JwtSecurityToken构造函数里expires参数只要你传DateTime.UtcNow.AddDays(7),类库会帮你转成Unix时间戳。但如果有人手动拼Payload字符串(像我第2章示意的那样),就必须自己用如下方式算:

var exp = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds();

这个值是一个long类型的秒数,不是毫秒。拼错单位会导致Token要么秒变过期、要么“永生”。注意不要用ToUnixTimeMilliseconds(),两种单位相差1000倍,验签时也是按秒处理的。

第二个是ClockSkew这个东西。微软的JwtBearer默认给了一个5分钟的“时钟偏移容忍”,意思是Token过期后5分钟内仍然会放行。这是为了抵消服务器之间时间不同步的误差,但在很多对安全性要求高的业务里,这个宽松度是不允许的。要收紧的话,在TokenValidationParameters里显式设置:

ClockSkew = TimeSpan.FromSeconds(30) // 或者更严格 ClockSkew = TimeSpan.Zero

我之前处理过一个故障,Cookie会话Token过期后用户还能访问接口长达5分钟,一开始大家以为是缓存问题,后来才发现是这个默认值。所以如果你做的系统对权限时效性敏感,建议显式设置ClockSkew,不要依赖默认值。

5.4 密钥轮换时没有预留过渡期

生产环境里每隔一段时间换一次签名密钥是标准操作,但C#的JWT类库默认不支持“同时校验旧密钥和新密钥”。假如你直接把IssuerSigningKey换成新密钥,所有用旧密钥签发的Token在换key的那一刻瞬间全部失效,所有用户都会被踢下线。这个场景在配置中心更新密钥后尤其常见。

规避办法有两种。第一种是临时新旧密钥并存,微软类库内置了IssuerSigningKeyResolver这个委托,可以在这里做密钥的线性扫描:

options.TokenValidationParameters.IssuerSigningKeyResolver = (token, securityToken, kid, parameters) => { var keys = new List<SymmetricSecurityKey> { new SymmetricSecurityKey(Encoding.UTF8.GetBytes(currentSecret)), new SymmetricSecurityKey(Encoding.UTF8.GetBytes(previousSecret)) }; // 也可以根据kid参数精确匹配 return keys; };

第二种是如果你用了类似配置中心的能力,在发布密钥前的几个小时先加一个“只验签、不签发新Token”的过渡逻辑,让旧Token自然过期。这个方案更稳,但需要业务上允许平滑过渡的时段。

密钥轮换是很多JWT踩坑帖不会提的点,但它在生产环境非常常用,建议早做准备。

5.5 无状态Token的“踢人下线”难题

JWT无状态这个特性,既是优点也是隐患。它带来的头号问题是:服务端没有存Token,怎么让某个用户立即失效?比如用户改了密码、管理员封了账号、或者发现Token被盗用。

几种常见做法,各有取舍:

  • 在业务层查一个version字段或tokenVersion,签进Payload,每次改密或封号时把这个版本号+1,校验时对比数据库里的当前版本。这个方案很常用,但每次请求都要查一次库,无状态带来的性能红利就没有了。
  • 用Redis维护一个黑名单,Token的jti是唯一键,用户需要失效时把jti塞进黑名单并设置过期时间等于Token剩余有效时间,校验时查一次Redis。适用于已签发的Token量不大且分布式缓存基础设施完善的场景。
  • 缩短Token的TTL,比如15分钟,再配合刷新Token机制,用户被踢后最多15分钟内自然失效。这也是很多系统的选择,简单粗暴但可接受。

这里没有银弹,设计时需要在“无状态性能”和“可控性”之间做取舍。我的经验是:对外API服务,短TTL加刷新机制;内部管理系统,可以用DB版本号方案,因为请求频率低,多查一次库几乎无感。

5.6 把大量业务数据塞进Payload

还有一种常见的反面用法,是把用户详情、权限列表、甚至订单摘要全塞进Payload里,导致Token动不动几KB长,每次请求都背着这么一个大包袱,HTTP头很快就逼近服务器对请求头大小的上限。

先说结论:**Payload里只放必要的最小集。**用户ID、角色、JTI就够了,其余信息能不放就不放。需要用户详情就去查缓存或数据库,不要为了省一次查询把整个对象塞进Token。Token变大不止影响带宽,还影响解析性能和日志可读性。而且因为Payload可读,塞了不该塞的东西就是妥妥的信息泄漏隐患。

6. 安全与性能:上线前要做的最后一轮检查

到这里,JWT的“会跑”已经解决了,接下来聊的是“跑得稳、跑得安全”的问题。这一章算是我自己上线前会过一遍的检查清单,按重要性排序。

6.1 HS256还是RS256,取决于信任边界

如果你的Token只在自己的服务集群内部签发和验证,所有服务共享同一个密钥,HS256完全够用,而且实现简单、验签速度快。但要注意,共享同一个密钥的双方其实都有“冒充对方签发Token”的能力,因为大家拿的是同一个对称密钥。所以只要密钥不泄露给第三方,HS256的风险就可控。

当验证方不止一个、且分属不同团队或不同公司时,比如统一登录中心给多个下游系统发Token,就必须用RS256。私钥只放在签发方,下游系统只拿公钥验签。这时候哪怕某个下游系统的公钥泄露,别人也只能验签、不能伪造,信任边界清晰。

C#里配置RS256的代码和HS256的主要区别在于SigningCredentials的构造参数:

using System.Security.Cryptography; var rsa = RSA.Create(2048); var privateKey = new RsaSecurityKey(rsa); var credentials = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256); var token = new JwtSecurityToken( issuer: "my-issuer", audience: "my-audience", claims: claims, notBefore: DateTime.UtcNow, expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: credentials);

验证方的IssuerSigningKey用对应的公钥即可。密钥对可以放在配置文件里,但更稳妥的做法是存到证书文件或密钥管理服务里,用X509SecurityKey读取。

6.2 Token有效期策略

有效期没有统一标准,完全看业务场景。我见过的一些典型配置供参考:

场景Access Token有效期刷新机制
内部管理系统8小时或1天不做或简单刷新
开放API平台15~30分钟Refresh Token 7天,可续期
移动端App1小时Refresh Token 30天,设备维度控制

接入第三方系统的,要特别留意它们对exp的容忍度。有些系统为了同步误差,要求在服务端体系内使用相对宽松的时间窗口。这类细节在对接文档里不会写,但实际联调时往往就是这些地方卡壳。

6.3 使用HTTPS承载Token

一个所有C#后端开发者都知道但偶尔会忽略的问题:JWT放在Authorization头里传输时,一旦走的不是HTTPS链路,Token在传输过程中就可能被截获。这个问题一旦发生,JWT本身写得再好也白搭。

所以原则上,凡是涉及Token的接口,全部强制HTTPS。如果公司内部网关已经用TLS终结,也务必确认从网关到应用服务器这一段没有明文暴露。开发环境可以用HTTP,生产环境必须强制,没有什么可商量的空间。

6.4 日志与监控:Token相关故障的排障前提

最后一个建议是给日志系统加Token相关字段。实际生产里排查401问题时,最无助的就是日志里只有“Unauthorized”,连哪个Token、哪个用户、哪个接口都查不到。

建议在JwtBearer的OnTokenValidated和OnAuthenticationFailed事件里补充结构化日志:

options.Events = new JwtBearerEvents { OnTokenValidated = context => { var jti = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti); var userId = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub); logger.LogInformation("Token验证成功, Jti={Jti}, UserId={UserId}", jti, userId); return Task.CompletedTask; }, OnAuthenticationFailed = context => { logger.LogWarning("Token验证失败, Error={Error}", context.Exception.Message); return Task.CompletedTask; } };

日志里不要打印完整Token字符串,Token等于用户的通行证,打印到日志里就意味着所有能看日志的人都能冒用。只记录Jti、用户ID、失败原因这些关键信息就够了。

最后说几句心里话

写到这里,JWT在C#里从原理、类库、编码到生产落地,整个过程应该已经比较清晰了。我个人的体会是,JWT的代码本身从来不复杂,真正让它显得难的是项目里各种真实约束——密钥放哪里、过期了怎么办、用户被踢了怎么生效、多系统之间怎么信任——这些才是每个项目不太一样、需要花时间设计的地方。

如果你正在做的项目规模不大,先用HS256配短过期时间跑起来,把认证链路打通,再慢慢把密钥管理、刷新机制做扎实,这个顺序是合理的。不要一开始就想着把所有最佳实践一次性堆上去,JWT这种方案,灵活度很高,但复杂度也会跟着上,想好再动,比我当初踩着一堆坑去补设计要舒服得多。

最后分享一个我自己常用的自检思路:拿到一个JWT集成需求时,先问自己三个问题——谁负责签发、谁负责验证、Token泄漏的后果是什么。这三个问题想清楚了,类库怎么选、密钥怎么管、有效期怎么定,基本就都有答案了。希望这篇能帮你在C#项目里少走几步弯路。

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

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

立即咨询