你是不是还在用 localStorage 存 Token,然后每个请求手动塞 Authorization 头?页面里万一被注入了 XSS 脚本,整个 Token 直接裸奔。
换成 HttpOnly 的 Cookie 呢?浏览器自动携带,脚本压根读不到,XSS 攻击者直接扑空。前端不用在每个请求里手动塞 Token 了,拦截器里那些 Token 注入、刷新、过期跳转的逻辑统统可以删掉。
这篇不聊虚的,直接带你过一遍 .NET 6+ 里 Cookie 认证的安全配置、登录/退出逻辑,以及那些最容易踩的坑。代码贴好,拿去就能落地。
本文定位:
本文使用的是 .NET 自带的、由 Data Protection 安全加密的 Cookie 认证票据(Ticket),而非独立的 JWT Bearer 中间件。相比 JWT,这套方案更加开箱即用,省去了自己生成、签名、验证 JWT 的繁琐逻辑,同时利用 HttpOnly 标记防止 XSS 窃取凭证。
文中代码均基于 ASP.NET Core 自带的 CookieAuthenticationHandler,不涉及独立的 JWT Bearer 中间件。
Cookie 认证是什么
在 ASP.NET Core 的语境下,基于 Cookie 的身份认证,本质上是一种认证方案(Authentication Scheme)——每次请求时,Cookie 认证中间件从 Cookie 中读取加密票据,解密并还原出 ClaimsPrincipal 对象,赋值给 HttpContext.User,使后续业务逻辑能够识别当前请求对应的用户身份。
官方文档的定义很直接:这是一种"由 Cookie 构造用户身份"的认证方案。背后做的事情大致是:用户登录成功后,服务器将用户标识(Claims)序列化、加密后写入 Cookie;后续请求中,Cookie 认证中间件从 Cookie 中还原出 ClaimsPrincipal,赋值给 HttpContext.User。
官方文档:
https://learn.microsoft.com/zh-cn/aspnet/core/security/authentication/cookie
核心机制:
1. 客户端承载的认证票据: HTTP 本身是无状态的,Cookie 在客户端存储加密的认证票据,使每次请求都能携带身份信息,从而让服务端能够"识别"请求来源。ASP.NET Core 的 Cookie 认证中间件封装了 Cookie 的读写、加密、过期管理等底层细节,开发者只需关注业务逻辑。
2. 声明式身份标识(Claims-based Identity): 用户信息以 Claim 的形式存储在 Cookie 中,框架自动完成序列化/反序列化。认证只管"你是谁",授权再判断"你能做什么"。
3. 内置安全防护机制: Cookie 认证默认启用 Data Protection API 进行加密,防止篡改。同时提供 HttpOnly、Secure、SameSite 等安全属性的配置入口。
4. 与 ASP.NET Core 生态深度集成: Cookie 认证与 [Authorize] 特性、授权策略、会话管理等机制无缝协作,在传统 MVC 和 Razor Pages 项目中几乎是开箱即用的标准方案。
需要明确的是:Cookie 认证和 Session 是两回事。
- Cookie 认证:认证票据存储在 客户端(Cookie 中),服务端不保存会话状态。
- Session:会话数据存储在 服务端(内存/Redis),客户端只存 Session ID。
两者可以独立使用,也可以组合使用(Cookie 认证 + Session 存储额外数据)。本文讨论的是 Cookie 认证本身,不涉及 Session。
怎么引入
需要安装的包
在 .NET 6 及以上 版本中,Microsoft.AspNetCore.Authentication.Cookies 通常已经包含在 Web SDK 的共享框架中,一般不需要单独安装。如果项目模板较旧或需要显式引用,执行以下命令:
Program.cs 完整配置
以下是一个完整的 Cookie 认证配置,包含安全属性设置,并根据环境动态决定 SecurePolicy:
各安全属性的作用:
HttpOnly | true | |
SecurePolicy | SameAsRequest,生产:Always | |
SameSite | SameSiteMode.Lax | |
ExpireTimeSpan | ||
SlidingExpiration |
关于 SecurePolicy 动态配置的说明:
生产环境强制使用 CookieSecurePolicy.Always,确保 Cookie 仅通过 HTTPS 传输,防止中间人攻击。
本地开发时,通过 builder.Environment.IsDevelopment() 自动切换为 CookieSecurePolicy.SameAsRequest,避免本地 HTTP 调试时 Cookie 无法写入。读者无需手动修改配置文件,直接复制代码即可在本地和生产环境正常运行。
关于 SameSite=Lax 的说明:
SameSite=Lax 可降低跨站请求携带 Cookie 的概率,对 GET 请求的 CSRF 有一定防护作用,但不能完全替代 CSRF Token。涉及状态变更的请求(POST/PUT/DELETE),仍建议配合 [ValidateAntiForgeryToken] 使用。
关于 OnRedirectToLogin 的说明:
在前后端分离项目中,如果未认证,框架默认会尝试 302 重定向到登录页。前端 Ajax 请求遇到 302 会导致 CORS 错误或无法正确处理。重写 OnRedirectToLogin 直接返回 401,前端可以统一在响应拦截器中处理。
关于 CORS 配置中 WithOrigins 的说明:
本地开发时,前端域名通常是 http://localhost:3000 或 http://localhost:5173。上方的代码示例中已将本地开发域名一并列出,读者可根据实际端口调整。
⚠️ CORS 高频踩坑点:WithOrigins 中的 URL 结尾千万不能加斜杠 /
这是 .NET CORS 中间件最常见的错误之一:
如果 URL 末尾加了斜杠,.NET 的 CORS 中间件在比对请求来源时会识别失败,浏览器报 CORS 错误。复制代码时务必检查。
验证是否接入成功
1. 在任意 Controller 中给 Action 加上 [Authorize] 特性,未登录时访问该地址应返回 401(前后端分离模式)或跳转到配置的 LoginPath。
2. 登录成功后检查浏览器开发者工具的 Application/Cookies 面板,应能看到名为 .MyApp.Auth(或自定义名称)的 Cookie。
快速上手
以下示例均基于 .NET 6+,假设已按上一节完成配置。
示例一:登录接口(返回 JSON,适配前后端分离)
用户提交账号密码,验证通过后调用 SignInAsync 写入认证 Cookie,并返回 JSON 状态,由前端控制页面跳转。
预期输出: 登录成功后,浏览器收到一个带有 HttpOnly、Secure(生产环境)、SameSite=Lax 标志的认证 Cookie,后续请求自动携带。
验证步骤: 在受保护的 Action 上加 [Authorize],未登录访问返回 401;登录后再次访问可正常进入。
示例二:前端代码前后对比
改造前(localStorage 存 Token,axios 手动塞 Header):
改造后(HttpOnly Cookie,同域场景):
跨域场景必须配置:
如果前端和后端不同域,浏览器默认不会携带 Cookie。需要在 axios 中开启 withCredentials:
跨域场景注意事项:
开启 withCredentials: true 后,前端所有跨域请求都会携带 Cookie。此时后端 CORS 必须配置具体域名(不能用 *),并显式设置 .AllowCredentials()。
如果后端未正确配置,浏览器会报:
同时,开启后如果请求返回 401,浏览器不会在控制台显示完整的错误信息(跨域安全策略),排查时需要结合后端日志。
如果项目中有几十甚至上百个 API 调用,省下来的代码量和维护成本相当可观。更重要的是,HttpOnly 标记让脚本无法读取 Cookie,XSS 攻击者拿不到认证凭证。
示例三:退出登录——必须写,不能忘
很多项目只做了登录,退出时只清前端页面、不处理后端 Cookie,导致 Cookie 依然有效,存在安全隐患。正确的做法是在后端调用 SignOutAsync 清除认证 Cookie。
为什么必须写退出接口:
- SignOutAsync 会向浏览器发送删除 Cookie 的响应头(Set-Cookie 过期时间为过去时间),通知浏览器清除对应的 Cookie。
- 如果不调用 SignOutAsync,即使前端清除了页面状态,Cookie 仍然有效,攻击者可能利用已过期的会话进行重放攻击。
- 必须加上 [Authorize],防止匿名用户盲调该接口。
- 不需要额外调用 Response.Cookies.Delete,两者重复且可能因 Path、Domain 不一致导致删除失败。
- 千万不要遍历 Request.Cookies.Keys 全量删除——同一个域名下可能还有前端框架的用户行为追踪 Cookie、主题配置、多语言标识、第三方统计脚本 Cookie 等,一并删除会导致页面其他功能异常。
示例四:Cookie 大小控制——避免"登录失败但没报错"
ASP.NET Core 的 Cookie 认证默认会将整个 ClaimsPrincipal 序列化进 Cookie。当 Claim 过多时,Cookie 可能超过浏览器限制(通常单个 Cookie 约 4KB),导致静默认证失败。
问题代码(容易导致 Cookie 过大):
改进方案——精简 Claim:
监控 Cookie 大小的方法: 在浏览器开发者工具的 Application → Cookies 中查看 Cookie 的 Value 长度。如果接近 4000 字符,就需要精简。
常见报错与修复
报错 1:401 Unauthorized 而非跳转登录页
触发条件:
在前后端分离项目中,未登录时前端 Ajax 请求收到 401,而非预期的重定向。 修复方式:
在 AddCookie配置中重写OnRedirectToLogin,直接返回 401(见 Program.cs 配置)。
报错 2:跨域请求中 Cookie 未携带
触发条件:
前端和后端不同域,前端未配置 withCredentials: true或后端未配置.AllowCredentials()。修复方式:
前端(axios):
后端(.NET):
报错 3:本地开发 HTTP 环境下 Cookie 写不进去
触发条件:
SecurePolicy设置为 Always,但本地是 HTTP。修复方式:
使用 builder.Environment.IsDevelopment()动态切换为SameAsRequest(见 Program.cs 配置)。
报错 4:分布式部署下用户反复被踢下线
触发条件:
多实例部署,各实例 DataProtection 密钥不同,A 节点加密的 Cookie 在 B 节点无法解密。 修复方式:
集中存储 DataProtection 密钥(见下文"实战建议")。
报错 5:登录成功后 Axios 解析 HTML 报错
触发条件:
前后端分离项目中,登录接口返回了 Redirect或View()。修复方式:
登录接口统一返回 Ok(new { success = true }),由前端控制跳转(见示例一)。
报错 6:CORS 报错 "The 'Access-Control-Allow-Origin' header..."
触发条件:
WithOrigins中的 URL 末尾加了斜杠 /。修复方式:
去掉 URL 末尾的斜杠, WithOrigins("https://your-frontend.com")而非WithOrigins("https://your-frontend.com/")。
适用场景
分布式场景:
Cookie 认证本身是服务端无状态的(解密和验证在每次请求中进行),但解密依赖 Data Protection 的密钥。在分布式部署(多实例)时,需要确保所有实例使用相同的密钥环(Key Ring),否则会出现"在某台机器登录,另一台机器解密失败"的问题。可以通过将密钥存储在 Redis 或 Azure Key Vault 中解决。
单体应用(含 Razor Pages/MVC):
这是 Cookie 认证最经典的适用场景。前后端同域、页面跳转为主、天然支持浏览器自动携带,几乎不需要额外配置就能跑起来,是 .NET 6+ 单体项目的首选认证方案。
前置条件与风险提示:
- 生产环境建议启用 HTTPS(配合 SecurePolicy = Always + HSTS),否则 Cookie 在明文传输中可能被截获。
- 建议开启 HttpOnly,否则 XSS 漏洞可直接窃取认证票据。
- 需根据业务合规要求(如 GDPR)配置 Cookie 同意策略。
- 纯 API 服务(无浏览器前端):不需要 Cookie 认证,应考虑 JWT Bearer 或 API Key。
- 移动端 App:没有 Cookie 存储机制,不应依赖 Cookie 认证。
实战建议
坑位一:分布式部署下认证票据解密失败(P0 级别)
ASP.NET Core 的 Cookie 认证底层使用 Data Protection API 对票据进行加密和解密。在单机部署时,密钥默认存储在本地用户目录;在多实例部署时,不同实例的密钥不同,导致 A 节点加密的 Cookie 在 B 节点无法解密,用户会被反复踢下线。
触发条件:
项目从单机扩展到多实例部署,未同步 Data Protection 密钥。 错误表现:
用户在 A 实例登录后,请求被负载均衡转发到 B 实例,B 实例无法解密 Cookie,返回 401 并重定向到登录页,用户反复被踢下线。 修复策略:
使用 PersistKeysToFileSystem将密钥存储在共享目录,或使用PersistKeysToAzureBlobStorage/PersistKeysToStackExchangeRedis集中存储。
坑位二:退出接口未加 [Authorize] 导致安全漏洞
触发条件:
退出登录接口没有 [Authorize]特性。错误表现:
匿名用户或爬虫可无限制地盲调 Logout 接口,空耗服务器资源。 修复策略:
退出接口加上 [Authorize]特性(见示例三)。
坑位三:前后端分离下登录接口返回 Redirect 导致前端报错
触发条件:
在前后端分离项目中,登录接口返回了 Redirect或View()。错误表现:
Axios 尝试解析 HTML 页面,触发跨域重定向错误或 JSON 解析失败。 修复策略:
登录接口统一返回 Ok(new { success = true }),由前端控制页面跳转(见示例一)。
坑位四:Cookie 体积超限导致静默失败
触发条件:
将用户菜单树、全部权限点、用户资料等大量数据一股脑塞进 Claim。 错误表现:
登录时一切正常,但部分请求(特别是 POST)出现 400 错误或直接无响应,浏览器控制台报 431 Request Header Fields Too Large或 Cookie 被静默截断。修复策略:
Claim 中只保留必要的身份标识(UserId、UserName、Role)。其他数据在登录成功后通过服务端缓存存储,以 UserId 为 Key 按需加载。
坑位五:SameSite=Lax 不能完全替代 CSRF Token
触发条件:
仅依赖 SameSite=Lax防护 CSRF,但攻击者通过 POST 表单或其他方式跨站提交请求。错误表现:
敏感操作(如修改密码、转账)被 CSRF 攻击。 修复策略:
SameSite=Lax可降低跨站请求携带 Cookie 的概率,对 GET 请求的 CSRF 有一定防护作用,但不能完全替代 CSRF Token。涉及状态变更的请求(POST/PUT/DELETE),仍建议配合 [ValidateAntiForgeryToken]使用。
坑位六:CORS WithOrigins URL 末尾多了斜杠
触发条件:
WithOrigins("https://your-frontend.com/")末尾带了斜杠。 错误表现:
浏览器报 CORS 错误, Access-Control-Allow-Origin不匹配。修复策略:
去掉 URL 末尾的斜杠。
选型建议
更适合 Cookie 认证的场景:
- 传统 MVC / Razor Pages 项目,页面跳转为主,且前后端同域部署。
- 团队期望框架自动处理 Cookie 读写、加密、过期轮转,不希望手动维护 Token 的存储和携带逻辑。
- 并发量中等(日均百万级请求以内),且对认证延迟敏感度不高的业务系统(如后台管理、企业 OA)。
- 希望前端代码尽量精简,不想在每个请求里手动塞 Token。
不太适合的场景:
- 纯 API 服务(无浏览器前端):不需要 Cookie 认证,应考虑 JWT Bearer 或 API Key。
- 移动端 App:没有 Cookie 存储机制,不应依赖 Cookie 认证。
- 需要极致的前后端分离(前端独立部署到 CDN,后端 API 在另一域名),跨域携带 Cookie 需要额外配置 SameSite=None; Secure。
dotnet new mvc --auth Individual |
总结收尾
回到开头的问题:localStorage 存 Token 确实省了几行前端代码,但 XSS 面前它脆得像张纸。换成 HttpOnly 的 Cookie 认证,攻击者脚本拿不到票据,安全感瞬间拉满。前端不需要在每个请求里手动塞 Token 了,代码量肉眼可见地减少。
但这不等于无脑配置一把梭——Cookie 安全属性要逐个确认,生产环境 SecurePolicy 必须根据环境动态配置,退出接口要加 [Authorize],分布式部署下 DataProtection 密钥的同步也得提前规划好。CORS 配置时 WithOrigins 的 URL 末尾千万别加斜杠。
可直接执行的三条落地建议: 1. 确认认证 Cookie 开启了 HttpOnly + SecurePolicy 根据环境正确配置(开发 SameAsRequest / 生产 Always),配合 UseHsts()。
2. 检查退出接口是否添加了 [Authorize] 并调用了 SignOutAsync,不要额外手动 Delete,更不要用 foreach 遍历全删。
3. 若项目已上线且有多实例,排查 DataProtection 密钥是否集中存储(Redis / Azure Key Vault / 共享目录)。