
简介这一示例是基于.NET6平台开发WebApi时使用JWT进行用户鉴权的完整源码包面向C#后端开发者和学习者旨在解决接口身份验证与授权保护问题。压缩包共68个文件大小仅1.43MB包含11个C#核心源码、14个JSON配置文件、18个DLL依赖库以及解决方案文件、PDB调试文件和EXE可执行文件文件结构清晰便于直接运行和二次修改。已有4500余人学习下载。项目按照认证服务、认证操作、认证模型分层组织完整示范了从引入System.IdentityModel.Tokens.Jwt和Microsoft.IdentityModel.Tokens包到使用JwtSecurityTokenHandler生成包含签发者、受众和过期时间的JWT令牌再到通过[Authorize]特性限制接口访问并在Swagger界面中配置JWT认证并进行在线调试的整个流程。开发者对照此代码即可掌握JWT鉴权主流实现方式也可在此基础上继续扩展自定义角色权限、令牌刷新等功能适合作为WebApi安全开发的实用模板。1. 不用Session改用JWT.NET6 WebApi用户鉴权的第一选择前后端分离的项目做到用户登录时几乎都会碰到同一个问题Session和Cookie在跨域、移动端、多端场景下都不好用服务端还要维护会话状态。于是JWT成了目前WebApi用户鉴权的主流方案.NET6平台配合JWT实现Token签发、中间件校验、Swagger调试已经是后端开发绕不开的基本功。你需要在十分钟内跑通一个能用的鉴权链路并且知道哪些配置是安全底线、哪些参数一改就会让整个鉴权形同虚设。这里我会把最小可运行的工程、签发Token的完整代码、鉴权中间件参数逐一拆开讲明白最后给出可复现的测试命令和五个真实反馈最多的坑。2. 从空目录到NuGet依赖用.NET6搭建可发布的最小WebApi2.1 创建项目并把鉴权代码拆到独立目录创建一个不带HTTPS的.NET6 WebApi项目避免本地调试时因为自签名证书增加干扰项。命令如下dotnet new webapi -n JwtDemo --no-https cd JwtDemo dotnet run模板自带一个WeatherForecast示例接口访问/swagger能看到默认的Swagger页面说明基础工程已经通了。我习惯紧接着就把目录结构整理一下为后面的鉴权代码提前划好边界JwtDemo/ ├── Controllers/ # 只写接收请求、返回响应的逻辑 ├── Services/ # Token生成、刷新、校验等业务实现 ├── Models/ # 登录请求、用户实体 ├── Program.cs # 服务注册和中间件管线 └── appsettings.json # JWT配置段.NET6的WebApplication.CreateBuilder风格会把所有注册逻辑集中在Program.cs里如果所有代码都堆在里面接口一多就会失控。提前把TokenService放到Services目录是后续扩展刷新Token、接入Redis时最省事的方式。这一步没有任何高深内容但目录边界直接决定了你后面维护的成本。2.2 安装JWT认证相关的NuGet包JWT在.NET6里不是内置的需要引入认证中间件包。在项目根目录执行dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package System.IdentityModel.Tokens.Jwt这两行的作用不一样。JwtBearer是核心负责在ASP.NET Core请求管线里读取Authorization: Bearer xxx头、解析Token、生成ClaimsPrincipal并建立HttpContext.UserSystem.IdentityModel.Tokens.Jwt提供JwtSecurityToken和JwtSecurityTokenHandler用于代码里主动创建和序列化Token。需要注意Microsoft.AspNetCore.Authentication.JwtBearer会把System.IdentityModel.Tokens.Jwt作为依赖自动带进来。我显式安装第二个包是为了写TokenService时不依赖隐式传递引用避免以后升级.NET版本时因为传递依赖不一致而莫名其妙丢类。安装时版本号直接用当前解决方案匹配的6.0.x即可不要混用.NET 8或.NET 9的包版本否则运行阶段可能因为程序集版本不匹配直接启动失败。2.3 appsettings.json里的JWT配置段JWT的密钥、签发者、受众、过期时间都属于环境相关配置不要硬编码到C#代码里。我一般在appsettings.json里单独开一个Jwt节点{ Jwt: { Issuer: JwtDemo.Auth, Audience: JwtDemo.Client, SecretKey: JwtDemoSecretKey-At-Least-32-Bytes-Long, ExpiresMinutes: 120, RefreshExpiresMinutes: 10080 } }参数对应的含义如下参数作用取值建议Issuer签发者标识Token里会带与后端校验配置完全一致大小写敏感Audience接收方标识表示这个Token给谁用前后端约定改了就401SecretKey签名密钥安全核心至少32字节不要用英文单词拼接ExpiresMinutesAccessToken过期分钟数建议30到120不要设7天RefreshExpiresMinutes后续续签用的RefreshToken过期时间7天或14天视业务而定SecretKey这里只是演示生产环境千万不要提交到代码仓库。常见做法是用环境变量或User Secrets覆盖比如Jwt__SecretKey。很多人密钥长度不够结果运行时抛SecurityTokenInvalidSigningKeyException原因就是HS256算法要求密钥至少256位也就是32字节。这一项属于改一个字母就会导致全线鉴权失败的敏感配置发布前一定要逐项核对。2.4 发布webapi项目dotnet publish的两种形态开发完成后要发布WebApi项目命令本身并不复杂dotnet publish -c Release -o ./publish这个命令产出的是框架依赖模式目标机器需要安装对应的.NET 6 Runtime发布目录里只有你的DLL、配置文件、静态资源。如果想部署到一台没有Runtime的干净服务器改成自包含模式dotnet publish -c Release -r win-x64 --self-contained true -o ./publish自包含会把整个运行时打进发布目录缺点是体积大优点是目标机器零依赖。IIS部署时还要注意一点进程外托管模式下appsettings.json和.dll必须在站点指向的物理路径下别只复制exe到服务器那是Windows桌面程序的习惯。发布目录里的appsettings.json是实际生效的配置很多“开发环境正常、服务器401”的故障一查就是服务器上这份配置里的Issuer或SecretKey跟签发Token时不一致。3. 签发TokenHeader、Payload、Signature各司其职密钥才是命门3.1 JWT的三段结构验签到底在验什么一个JWT字符串长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6ImFkbWluIn0.8ZzU7X0B3vZYn3W1hZ8T3m用.拆开正好三段Header、Payload、Signature。Header是{alg:HS256,typ:JWT}告诉服务端这个Token用什么算法签的Payload里是你塞进去的Claims也就是用户的身份信息Signature则是把前两段用Base64Url编码后拼起来再用SecretKey做HMAC-SHA256计算出的结果。理解了这三段就明白了一个容易混淆的点JWT里的内容不是加密的。base64解码后任何拿到Token的人都能直接看到sub、name、role这些字段。所以敏感数据、手机号、身份证号、真实姓名都不能往Payload里塞。JWT防的不是别人读取内容而是防别人篡改内容——只要Signature校验不过服务端就直接拒绝。这也是为什么密钥管理比Claims设计更关键。3.2 设计Claims尽量短够用就行Claims的设计直接影响Token体积和安全性。我一般只放四样东西sub 用户唯一标识比如数据库主键 jti Token的唯一ID用来做续签和吊销 name 用户显示名方便中间件直接取User.Identity.Name role 角色名用于[Authorize(RolesAdmin)]这类授权每次HTTP请求都会把整个JWT放在Authorization头里Token越长网络开销越大。很多网关对请求头大小有8KB左右的限制Claims塞多了轻则性能下降重则请求直接报413或400。手机号、邮箱这类信息不要进Token需要时拿sub去查库或查缓存。另外exp不需要手动塞设置过期时间时自动生成iat签发时间也可以带上方便排查问题但同样别放业务数据。3.3 编写TokenService生成Token的核心代码创建一个Services/TokenService.cs集中负责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 GenerateToken(AppUser user) { var jwtSection _config.GetSection(Jwt); var claims new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(ClaimTypes.Name, user.UserName), new Claim(ClaimTypes.Role, user.Role) }; var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(jwtSection[SecretKey])); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: jwtSection[Issuer], audience: jwtSection[Audience], claims: claims, expires: DateTime.UtcNow.AddMinutes( Convert.ToDouble(jwtSection[ExpiresMinutes])), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }这段代码有几个关键点要说明。new Claim(ClaimTypes.Name, user.UserName)在写入Token时会被JwtSecurityTokenHandler默认的OutboundClaimTypeMap映射成unique_nameClaimTypes.Role会映射成role所以最后生成的JWT里实际字段名是unique_name和role不是一长串URI。这一点直接关系到后面鉴权中间件里NameClaimType和RoleClaimType的配置我会在第4章专门对齐。SymmetricSecurityKey用的是同一个SecretKey做签名和验签属于对称加密实现简单适合绝大多数单体WebApi。.NET6里HmacSha256是最常用的选择不需要为了“更安全”去碰RSA非对称签名那通常留给多服务间互相调用时用。过期时间用UTC时间避免服务器时区不一致导致Token提前过期或延长有效。配套的登录接口在Controllers/AuthController.cs里演示项目用固定用户写死真实项目换成数据库查询[ApiController] [Route(api/auth)] public class AuthController : ControllerBase { private readonly TokenService _tokenService; public AuthController(TokenService tokenService) { _tokenService tokenService; } [HttpPost(login)] [AllowAnonymous] public IActionResult Login(LoginRequest req) { if (req.UserName ! admin || req.Password ! 123456) return Unauthorized(new { msg 用户名或密码错误 }); var user new AppUser { Id 1, UserName req.UserName, Role Admin }; return Ok(new { token _tokenService.GenerateToken(user), expiresAt DateTime.UtcNow.AddMinutes(120) }); } }密码校验这里只是演示。实际项目中密码不能明文比对ASP.NET Core Identity自带的PasswordHasher是常见做法或用BCrypt。登录接口一定要加[AllowAnonymous]否则连登录都被拦下来形成死锁。3.4 过期时间与安全底线Token过期时间是最容易被拍脑袋设置的地方。我见过不少项目直接把ExpiresMinutes设为10080也就是7天理由是“用户不想老登录”。这等于把用户凭证的暴露窗口拉长到了一周一旦Token被中间人截获攻击者能在一周内持续冒充用户。AccessToken控制在30到120分钟是相对稳妥的区间。用户体验问题用第6章的续签机制解决而不是用一刀切的超长过期时间。密钥更要重视最少32字节不要用jwtSecret这种单词用随机生成的十六进制或GUID拼接字符串并且定期轮换。轮换时旧密钥要保留一个过渡期否则正在使用的Token会集体失效。4. 让鉴权管线接管请求AddAuthentication到UseAuthorization的完整顺序4.1 Program.cs里注册JwtBearer服务Token签发出来只是第一步真正让[Authorize]生效的是认证中间件。在Program.cs里按下面顺序注册using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.MapInboundClaims false; options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:SecretKey])), ValidateLifetime true, ClockSkew TimeSpan.FromSeconds(30), NameClaimType unique_name, RoleClaimType role }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();这一段集中了绝大多数鉴权配置每个参数都有实际后果。ValidateIssuer和ValidateAudience开着之后任何Issuer或Audience对不上的Token都会被拒防止别人拿着在其它服务签发的Token打到你这来。ValidateIssuerSigningKey是最容易漏的一项它默认是false不显式设为true等于没验签改签名的Token也能通过。NameClaimType unique_name和RoleClaimType role是配合MapInboundClaims false设置的。关闭MapInboundClaims后JWT里的unique_name和role不会再被自动映射成ClaimTypes.Name和ClaimTypes.Role我们需要在TokenValidationParameters里手动告诉中间件“名字从哪个Claim取、角色从哪个Claim取”。如果这三行不配套会出现User.Identity.Name为空的诡异情况。ClockSkew是给Token过期留的缓冲默认5分钟也就是过期后5分钟之内依然有效。对敏感系统来说这个窗口太大我习惯压到30秒。它解决的是多服务器时钟不同步的问题不是让你把Token有效期变相加长的。4.2 Controller上使用[Authorize]控制权限认证和授权是两个动作。认证解决“你是谁”授权解决“你能干什么”。在Controller或Action上直接标注即可[Authorize] [ApiController] [Route(api/order)] public class OrderController : ControllerBase { [HttpGet] public IActionResult GetOrders() { var userId User.FindFirst(sub)?.Value; return Ok(new { userId, orders new[] { 订单A, 订单B } }); } [Authorize(Roles Admin)] [HttpDelete({id})] public IActionResult DeleteOrder(int id) { return Ok(new { deleted id }); } }[Authorize]不带参数表示只要登录就能访问[Authorize(Roles Admin)]要求Token的roleClaim值是Admin。这里拿到的User对象是认证中间件在解析Token成功后填充到HttpContext.User里的不需要你自己去解码Token。建议在Action里获取当前用户ID时统一用User.FindFirst(sub)?.Value不要依赖User.Identity.Name因为后者受NameClaimType影响配置一变就取不到。4.3 自定义401和403响应体JWT鉴权失败时默认返回的401响应体是空的前端拿不到任何提示信息排查问题只能看响应状态码。统一处理一下响应体接口联调时能省不少沟通成本options.Events new JwtBearerEvents { OnChallenge context { context.HandleResponse(); context.Response.StatusCode StatusCodes.Status401Unauthorized; context.Response.ContentType application/json; return context.Response.WriteAsync({\code\:401,\msg\:\未登录或Token无效\}); }, OnForbidden context { context.Response.StatusCode StatusCodes.Status403Forbidden; context.Response.ContentType application/json; return context.Response.WriteAsync({\code\:403,\msg\:\权限不足\}); } };OnChallenge在Token缺失、Token无效、Token过期时都会触发OnForbidden只在使用[Authorize(Roles...)]但角色不匹配时触发。注意在OnChallenge里先调用context.HandleResponse()否则默认逻辑还会继续写响应可能跟你自定义的内容叠加造成响应头异常。4.4 SPA项目联调时的CORS配置坑前端Vue或React项目跑在localhost:5173后端跑在localhost:5000时必须先配CORS再加鉴权否则浏览器预检请求永远到不了你的接口。在注册JwtBearer之前加上builder.Services.AddCors(options { options.AddPolicy(spa, policy policy.WithOrigins(http://localhost:5173) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials()); }); app.UseCors(spa);两个容易忽略的点AllowCredentials()允许携带Cookie但如果你的鉴权只靠JWT的Authorization头而不依赖Cookie这一项其实可以不开前端请求如果设置的是Authorization头则AllowAnyHeader()必须包含它。SPA项目尤其是配合验证码登录时验证码通常依赖于服务端临时存储这里CORS配置不对验证码接口和登录接口会一起挂掉容易被误判成JWT代码有问题。UseCors必须放在UseAuthentication之前执行顺序错了CORS策略不会生效。5. 测试源码与避坑清单Swagger、curl、5个真实翻车点5.1 让Swagger带上Token调试Swagger默认不带认证按钮需要手动配置SecurityDefinition。在AddSwaggerGen里加上builder.Services.AddSwaggerGen(c { c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Name Authorization, Type SecuritySchemeType.Http, Scheme bearer, BearerFormat JWT, In ParameterLocation.Header }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, Array.Emptystring() } }); });配置完成后重启项目Swagger页面右上角会出现一个Authorize按钮。调试流程是先调/api/auth/login拿Token把返回的token值整串复制到Authorize弹窗里然后再去请求/api/order这类带[Authorize]的接口。注意弹窗里不要手敲字符串前缀Bearer只要在UI当前流程下Scheme已设置为bearerSwagger会自动帮你加上。如果接口返回401先用响应头和Code值判断是Token没带上还是Token校验失败再去查第5.3节的坑。5.2 用curl模拟三种异常Token命令行可以快速验证鉴权链路是否真的在工作。先登录拿Tokencurl -s -X POST http://localhost:5000/api/auth/login \ -H Content-Type: application/json \ -d {userName:admin,password:123456}得到JSON里的token后复制出来分别跑三个请求# 不带Token访问受保护接口 curl -i http://localhost:5000/api/order # 带正常Token访问 curl -i http://localhost:5000/api/order \ -H Authorization: Bearer 上一步拿到的token # 故意篡改一个字符模拟伪造Token curl -i http://localhost:5000/api/order \ -H Authorization: Bearer 把token最后一个字符改成x第一种和第三种都应该返回401第二种返回200。第三种是验证签名校验是否生效的最直接手段如果篡改后的Token还能返回200说明ValidateIssuerSigningKey没配true或者IssuerSigningKey配的密钥不对存在严重的越权漏洞。响应头里能看到WWW-Authenticate: Bearer这是JwtBearer中间件在告诉客户端“这里需要Bearer认证”。5.3 JWT漏洞总结三个必须堵上的缺口把搜到的“jwt漏洞总结”类内容结合到本项目里真实的攻击面就三个。第一个是algnone攻击。攻击者把Header里的算法改成none去掉签名部分伪造一个只有Payload的Token。.NET的JwtSecurityTokenHandler在默认配置下会直接拒绝这种Token但如果有人为了“兼容多算法”自定义了TokenValidationParameters里的算法验证逻辑或者用的是某些低版本中间件就可能放行。保持默认行为不要自行实现宽松的算法白名单。第二个是弱密钥爆破。JWT的HS256密钥长度不够攻击者拿到一个真实Token后可以离线跑字典破解出SecretKey后就能随意签发任意身份的Token。网上大量泄露的JWT密钥库就是针对这种场景的。对策只有一个密钥随机生成、长度32字节起步、定期轮换。第三个是Token重放。JWT无服务端状态Token一旦被截获在过期前可以反复使用。没有完美的API层对策能做的是全链路HTTPS、缩短AccessToken有效期、对敏感操作加入额外的二次校验、需要吊销时用jti做服务端黑名单。5.4 五个常见坑现象、原因、解决坑一[Authorize(Roles Admin)]一直返回403但Token里明明有角色。现象调登录接口拿到的Token解出来能看到role是Admin但请求带Token访问时报403。原因MapInboundClaims为false时JWT里的roleClaim没有被当成角色RoleClaimType没有正确指向role。解决在TokenValidationParameters里显式设置RoleClaimType role并将NameClaimType unique_name保持和签发端映射一致。坑二本地跑得好好的发布到服务器后所有接口都401。现象dotnet run本地访问正常dotnet publish部署后同样的Token全部401。原因服务器上appsettings.json里的Jwt:SecretKey、Jwt:Issuer跟本地不一致或者环境变量覆盖了配置导致签名校验失败。解决在服务器上用临时接口或日志打印当前生效的配置值确认SecretKey、Issuer、Audience三个值和签发Token时完全一样。另一个常见来源是反向代理没有转发Authorization头需要检查Nginx或IIS ARR的转发配置。坑三把Token签名部分改乱请求还是200。现象Token后半段随便改动字符接口依然正常返回。原因TokenValidationParameters.ValidateIssuerSigningKey没有设置为true它默认是false中间件根本没验签。解决在配置里加上ValidateIssuerSigningKey true并配置IssuerSigningKey。这个坑不踩一次很多人根本不会注意到默认值这么危险。坑四Token显示过期了但请求还能通过。现象查exp已经过了当前时间但接口没返回401。原因ClockSkew默认给5分钟宽限过期后5分钟内依然视为有效。解决把ClockSkew改成TimeSpan.FromSeconds(30)或TimeSpan.Zero同时确认服务器时间同步正常。坑五Swagger点了Authorize并填入Token后请求还是401。现象Swagger UI的Authorize弹窗输入Token后调用接口返回401抓请求头发现没有Authorization。原因AddSecurityRequirement没有配置或者OpenApiSecurityScheme的Reference.Id跟AddSecurityDefinition里的Bearer不一致。解决按5.1节的完整代码配置SecurityDefinition和SecurityRequirement必须成对出现。6. JWT到期续签落地滑动过期和刷新Token的取舍JWT一旦签发过期时间就写死在Token里了服务端没法单独延长某个Token的寿命。常见的续签方案有两种。第一种是滑动过期每次请求到达时检查剩余有效期如果低于某个阈值就签发一个新Token并返回给前端。实现简单但意味着每次请求都可能产生新Token且旧Token在过期前依然有效吊销和重放窗口难控制适合内部低安全系统。第二种是RefreshToken方案也是我推荐的方式AccessToken短命30分钟到2小时RefreshToken长命且只走专门接口7天或14天。刷新接口的典型实现是先校验RefreshToken本身的签名和有效期再从里面取出用户ID[HttpPost(refresh)] [AllowAnonymous] public IActionResult Refresh(RefreshRequest req) { var principal _tokenService.ValidateToken(req.RefreshToken); if (principal null) return Unauthorized(); var userId principal.FindFirst(sub)?.Value; if (string.IsNullOrEmpty(userId)) return Unauthorized(); var user _userStore.GetById(userId); if (user null) return Unauthorized(); var newToken _tokenService.GenerateToken(user); return Ok(new { token newToken, expiresAt DateTime.UtcNow.AddMinutes(120) }); }ValidateToken方法里解析RefreshToken时要用ValidateLifetime false的临时参数因为RefreshToken过期后接口要区分“签名有效但过期”和“签名无效”两种情况。签名无效直接拒绝签名有效但过期则主动引导前端重新登录。RefreshToken本身也需要保存常见做法是存数据库或Redis存储时只存哈希值刷新成功后就地轮换防止旧RefreshToken被反复使用。最后再分享一个我调试JWT时一直在用的习惯遇到Token相关的问题先别急着改代码把Token的Payload解出来看一眼。写一个几行的Base64Url解码函数或者直接在调试器里执行下面这段static string DecodeBase64Url(string s) { s s.Replace(-, ).Replace(_, /); switch (s.Length % 4) { case 2: s ; break; case 3: s ; break; } return Encoding.UTF8.GetString(Convert.FromBase64String(s)); } var parts token.Split(.); Console.WriteLine(DecodeBase64Url(parts[1]));我见过太多次“为什么代码一样但别人能过我不能过”的排查最后都是Issuer或Audience差一个字母这种低级问题。所以后来每换一个环境我都会先把这个Payload解出来核对iss、aud、exp确认无误后再去查代码。一口气把第4章的配置和第5章的坑全部走一遍你的JWT鉴权基本就能稳了。希望帮到你。本文还有配套的精品资源点击获取