Cookie 是 Web 开发中最基础也最容易被误解的机制之一。很多开发者会用 Request.Cookies["name"] 读值、用 Response.Cookies.Append(...) 写值,却说不清三件事:为什么请求侧读不到 Cookie 的属性?敏感数据放进 Cookie 该如何加密?加密服务在一次请求中究竟是何时、被谁创建并调用的?
这三个问题分别对应 HTTP 协议层的不对称设计 、ASP.NET Core 的数据保护(Data Protection)机制 和依赖注入(DI,Dependency Injection)与请求管线的执行时序。本文将这三层串联起来,给出一条从协议到代码、从启动到单次请求的完整脉络。文中所有 API 行为均基于 ASP.NET Core 10.0 官方文档。
第一部分:Request.Cookies 的类型与契约
在 ASP.NET Core 中,读取客户端 Cookie 的标准入口是 HttpRequest.Cookies:
csharp
// Namespace: Microsoft.AspNetCore.Http
// Assembly: Microsoft.AspNetCore.Http.Abstractions.dll
public abstract IRequestCookieCollection Cookies { get; set; }
返回类型 IRequestCookieCollection(位于 Microsoft.AspNetCore.Http,程序集 Microsoft.AspNetCore.Http.Features.dll)继承自 IEnumerable<KeyValuePair<string, string>>:
csharp
public interface IRequestCookieCollection
: IEnumerable<KeyValuePair<string, string>>
{
int Count { get; }
ICollection<string> Keys { get; }
string this[string key] { get; }
bool ContainsKey(string key);
bool TryGetValue(string key, out string? value);
}
| 成员 | 返回类型 | 语义 |
|---|---|---|
Count |
int |
Cookie 数量 |
Keys |
ICollection<string> |
所有 Cookie 名称 |
this[string key] |
string |
按名称取值 |
ContainsKey(string) |
bool |
是否存在指定名称 |
TryGetValue(string, out string) |
bool |
尝试取值 |
关键行为:索引器永不抛异常
这是从 ASP.NET Framework 迁移时最容易踩的坑。官方迁移文档明确给出:
csharp
IRequestCookieCollection cookies = httpContext.Request.Cookies;
string unknownCookieValue = cookies["unknownCookie"]; // 返回 null(不抛异常)
string knownCookieValue = cookies["cookie1name"]; // 返回实际值
对不存在的键,索引器返回 null 而绝不抛 KeyNotFoundException ------这与普通 Dictionary 完全相反。原因在于 Cookie 是不可信的客户端输入,缺失是常态,框架不应让缺失走异常路径。
因此生产代码应优先用 TryGetValue,以明确区分「Cookie 不存在」与「Cookie 存在但值为空」:
csharp
// 推荐
if (request.Cookies.TryGetValue("session_id", out var sessionId))
{
// sessionId 来自客户端,仍需校验
}
// 不推荐:无法区分「不存在」与「空值」
var sessionId = request.Cookies["session_id"];
第二部分:HTTP 协议层的根本不对称
要真正理解 Request.Cookies 为什么只有键值对,必须回到 HTTP 协议本身。请求侧与响应侧的 Cookie 是严重不对称 的,这条原理可以一句话概括:Set-Cookie 不能合并,Cookie 必须合并。
请求方向(浏览器 → 服务器):单个 Cookie 头
浏览器把适用于当前请求的所有 Cookie 合并进一个 Cookie 请求头,用 ; 分隔,且只有 name=value,不带任何属性:
Cookie: session_id=abc123; theme=dark; lang=zh-CN
响应方向(服务器 → 浏览器):每个 Cookie 一个 Set-Cookie 头
服务器下发 N 个 Cookie 就产生 N 行 Set-Cookie,每行携带完整属性:
Set-Cookie: session_id=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
Set-Cookie: theme=dark; Path=/; Max-Age=2592000
Set-Cookie: lang=zh-CN; Path=/; Domain=.example.com
为什么这样设计
Set-Cookie 之所以一个头一行,是因为每个 Cookie 的属性集独立且不可共享 ------session_id 要 HttpOnly、theme 不要,它们的 Expires、Path、Domain 也各不相同。塞进一个头里就无法表达「属性归属于哪个 Cookie」。事实上 Set-Cookie 是 HTTP 规范中少数明确禁止用逗号合并成单行的响应头。
而请求方向能合并,是因为浏览器回传时根本不需要属性 :HttpOnly、Secure、SameSite、Expires 这些属性是浏览器用来决定「要不要发、能不能被 JS 读、何时删除」的本地规则 ;一旦决定发送,对服务器有用的就只剩 name=value。
#mermaid-svg-zJeZdtI6rbTRuw3T{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zJeZdtI6rbTRuw3T .error-icon{fill:#552222;}#mermaid-svg-zJeZdtI6rbTRuw3T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zJeZdtI6rbTRuw3T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zJeZdtI6rbTRuw3T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zJeZdtI6rbTRuw3T .marker.cross{stroke:#333333;}#mermaid-svg-zJeZdtI6rbTRuw3T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zJeZdtI6rbTRuw3T p{margin:0;}#mermaid-svg-zJeZdtI6rbTRuw3T .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster-label text{fill:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster-label span{color:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster-label span p{background-color:transparent;}#mermaid-svg-zJeZdtI6rbTRuw3T .label text,#mermaid-svg-zJeZdtI6rbTRuw3T span{fill:#333;color:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T .node rect,#mermaid-svg-zJeZdtI6rbTRuw3T .node circle,#mermaid-svg-zJeZdtI6rbTRuw3T .node ellipse,#mermaid-svg-zJeZdtI6rbTRuw3T .node polygon,#mermaid-svg-zJeZdtI6rbTRuw3T .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zJeZdtI6rbTRuw3T .rough-node .label text,#mermaid-svg-zJeZdtI6rbTRuw3T .node .label text,#mermaid-svg-zJeZdtI6rbTRuw3T .image-shape .label,#mermaid-svg-zJeZdtI6rbTRuw3T .icon-shape .label{text-anchor:middle;}#mermaid-svg-zJeZdtI6rbTRuw3T .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zJeZdtI6rbTRuw3T .rough-node .label,#mermaid-svg-zJeZdtI6rbTRuw3T .node .label,#mermaid-svg-zJeZdtI6rbTRuw3T .image-shape .label,#mermaid-svg-zJeZdtI6rbTRuw3T .icon-shape .label{text-align:center;}#mermaid-svg-zJeZdtI6rbTRuw3T .node.clickable{cursor:pointer;}#mermaid-svg-zJeZdtI6rbTRuw3T .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zJeZdtI6rbTRuw3T .arrowheadPath{fill:#333333;}#mermaid-svg-zJeZdtI6rbTRuw3T .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zJeZdtI6rbTRuw3T .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zJeZdtI6rbTRuw3T .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zJeZdtI6rbTRuw3T .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zJeZdtI6rbTRuw3T .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zJeZdtI6rbTRuw3T .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster text{fill:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T .cluster span{color:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zJeZdtI6rbTRuw3T .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zJeZdtI6rbTRuw3T rect.text{fill:none;stroke-width:0;}#mermaid-svg-zJeZdtI6rbTRuw3T .icon-shape,#mermaid-svg-zJeZdtI6rbTRuw3T .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zJeZdtI6rbTRuw3T .icon-shape p,#mermaid-svg-zJeZdtI6rbTRuw3T .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zJeZdtI6rbTRuw3T .icon-shape .label rect,#mermaid-svg-zJeZdtI6rbTRuw3T .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zJeZdtI6rbTRuw3T .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zJeZdtI6rbTRuw3T .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zJeZdtI6rbTRuw3T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 浏览器存储后剥离属性
请求:N 个 Cookie 到 1 行 Cookie(仅键值对,必须合并)
Cookie: session_id=abc; theme=dark; lang=zh-CN
响应:N 个 Cookie 到 N 行 Set-Cookie(含属性,禁止合并)
Set-Cookie: session_id=abc; HttpOnly; Secure; SameSite=Lax
Set-Cookie: theme=dark; Max-Age=2592000
Set-Cookie: lang=zh-CN; Domain=.example.com
这就解释了一个高频困惑:「我设置了 HttpOnly,为什么 Request.Cookies 读不到这个属性?」 ------因为属性只存在于 Set-Cookie 中,浏览器回传时根本不携带,请求头里本就只有键值对。
第三部分:底层机制------IRequestCookiesFeature 与惰性解析
HttpRequest.Cookies 并非请求一进来就立即解析。ASP.NET Core 采用特性集合(HttpContext.Features) 这一可插拔架构,Cookie 解析封装在 IRequestCookiesFeature 中:
csharp
// Namespace: Microsoft.AspNetCore.Http.Features
public interface IRequestCookiesFeature
{
IRequestCookieCollection Cookies { get; set; }
}
默认实现 RequestCookiesFeature(位于 Microsoft.AspNetCore.Http.dll)的工作流程:
- 从
IHttpRequestFeature拿到原始请求头集合; - 惰性 读取
Cookie请求头的原始字符串; - 仅在首次访问
Cookies属性时解析原始字符串,生成RequestCookieCollection; - 缓存解析结果并记录所基于的原始头值,若后续原始头未变则复用缓存。
这种「按特性、按需解析」带来两点实际价值:
- 零成本抽象:从不读取 Cookie 的请求不付出任何解析开销。
- 可替换性 :中间件可替换
HttpContext.Features.Get<IRequestCookiesFeature>(),自定义 Cookie 来源,这是测试桩、网关改写等场景的底层抓手。
#mermaid-svg-Q50piB1y3xm3datu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Q50piB1y3xm3datu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Q50piB1y3xm3datu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Q50piB1y3xm3datu .error-icon{fill:#552222;}#mermaid-svg-Q50piB1y3xm3datu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Q50piB1y3xm3datu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Q50piB1y3xm3datu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Q50piB1y3xm3datu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Q50piB1y3xm3datu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Q50piB1y3xm3datu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Q50piB1y3xm3datu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Q50piB1y3xm3datu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Q50piB1y3xm3datu .marker.cross{stroke:#333333;}#mermaid-svg-Q50piB1y3xm3datu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Q50piB1y3xm3datu p{margin:0;}#mermaid-svg-Q50piB1y3xm3datu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Q50piB1y3xm3datu .cluster-label text{fill:#333;}#mermaid-svg-Q50piB1y3xm3datu .cluster-label span{color:#333;}#mermaid-svg-Q50piB1y3xm3datu .cluster-label span p{background-color:transparent;}#mermaid-svg-Q50piB1y3xm3datu .label text,#mermaid-svg-Q50piB1y3xm3datu span{fill:#333;color:#333;}#mermaid-svg-Q50piB1y3xm3datu .node rect,#mermaid-svg-Q50piB1y3xm3datu .node circle,#mermaid-svg-Q50piB1y3xm3datu .node ellipse,#mermaid-svg-Q50piB1y3xm3datu .node polygon,#mermaid-svg-Q50piB1y3xm3datu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Q50piB1y3xm3datu .rough-node .label text,#mermaid-svg-Q50piB1y3xm3datu .node .label text,#mermaid-svg-Q50piB1y3xm3datu .image-shape .label,#mermaid-svg-Q50piB1y3xm3datu .icon-shape .label{text-anchor:middle;}#mermaid-svg-Q50piB1y3xm3datu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Q50piB1y3xm3datu .rough-node .label,#mermaid-svg-Q50piB1y3xm3datu .node .label,#mermaid-svg-Q50piB1y3xm3datu .image-shape .label,#mermaid-svg-Q50piB1y3xm3datu .icon-shape .label{text-align:center;}#mermaid-svg-Q50piB1y3xm3datu .node.clickable{cursor:pointer;}#mermaid-svg-Q50piB1y3xm3datu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Q50piB1y3xm3datu .arrowheadPath{fill:#333333;}#mermaid-svg-Q50piB1y3xm3datu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Q50piB1y3xm3datu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Q50piB1y3xm3datu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Q50piB1y3xm3datu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Q50piB1y3xm3datu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Q50piB1y3xm3datu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Q50piB1y3xm3datu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Q50piB1y3xm3datu .cluster text{fill:#333;}#mermaid-svg-Q50piB1y3xm3datu .cluster span{color:#333;}#mermaid-svg-Q50piB1y3xm3datu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Q50piB1y3xm3datu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Q50piB1y3xm3datu rect.text{fill:none;stroke-width:0;}#mermaid-svg-Q50piB1y3xm3datu .icon-shape,#mermaid-svg-Q50piB1y3xm3datu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Q50piB1y3xm3datu .icon-shape p,#mermaid-svg-Q50piB1y3xm3datu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Q50piB1y3xm3datu .icon-shape .label rect,#mermaid-svg-Q50piB1y3xm3datu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Q50piB1y3xm3datu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Q50piB1y3xm3datu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Q50piB1y3xm3datu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 浏览器请求
Cookie: a=1; b=2
IHttpRequestFeature
(原始请求头)
RequestCookiesFeature
(惰性解析+缓存)
RequestCookieCollection
(IRequestCookieCollection)
HttpRequest.Cookies
属性访问
业务代码
TryGetValue / 索引器
第四部分:读写对称对照与 SameSite
| 维度 | Request.Cookies(读) | Response.Cookies(写) |
|---|---|---|
| 接口 | IRequestCookieCollection |
IResponseCookies |
| 数据来源 | HTTP Cookie 请求头 |
生成 HTTP Set-Cookie 响应头 |
| 是否含属性 | 否,仅 name=value |
是,含 Path/Domain/Expires/HttpOnly/SameSite/Secure 等 |
| 主要操作 | 读取、枚举、判断存在 | Append(...)、Delete(...) |
| 选项类型 | 无 | CookieOptions |
写入示例:
csharp
HttpContext.Response.Cookies.Append("session_id", sessionValue, new CookieOptions
{
HttpOnly = true, // 阻止 JavaScript 读取
Secure = true, // 仅 HTTPS 传输
SameSite = SameSiteMode.Lax, // CSRF 防护
Path = "/",
Expires = DateTimeOffset.UtcNow.AddHours(1)
});
HttpContext.Response.Cookies.Delete("session_id"); // 下发一个已过期的同名 Cookie
SameSite 要点
SameSite 是用于缓解跨站请求伪造(CSRF,Cross-Site Request Forgery)的 IETF 草案标准。2019 版与 2016 版不向后兼容 ,关键规则:未带 SameSite 的 Cookie 被浏览器默认按 SameSite=Lax 处理;跨站使用必须显式声明 SameSite=None;任何声明 SameSite=None 的 Cookie 必须同时标记 Secure。
在 ASP.NET Core 中,Response.Cookies.Append 的 CookieOptions.SameSite 默认值为 SameSiteMode.Unspecified(不输出该属性,交由浏览器决定)。这是 3.0 起的改变,目的是避免与客户端不一致的默认值冲突。各内置组件会覆盖此默认值:
| 组件 | Cookie | 默认 SameSite |
|---|---|---|
IAntiforgery |
AntiforgeryOptions.Cookie |
Strict |
| Cookie 认证 | CookieAuthenticationOptions.Cookie |
Lax |
| Session | SessionOptions.Cookie |
Lax |
| OIDC(OpenID Connect) | OpenIdConnectOptions.NonceCookie |
None |
Response.Cookies.Append |
CookieOptions |
Unspecified |
第五部分:敏感数据的加密存储(Data Protection)
Request.Cookies 的全部内容都是完全不可信的客户端输入 ------名、值、数量都可伪造。因此第一原则是:能不放进 Cookie 就不要放。 会话身份应直接用框架自带的 Cookie 认证(内部已用 Data Protection 加密 ticket),不要自己明文存 userId。
当确实需要在 Cookie 里携带一小段自定义敏感数据时,标准方案是 Data Protection API ,核心类型 IDataProtector。它不是单纯加密,而是同时提供机密性(加密)+ 完整性(防篡改),底层为 AES 加密配合 HMAC 验证,密钥由框架自动管理和轮换。
服务实现
csharp
public class SecureCookieService
{
private readonly IDataProtector _protector;
// purpose 字符串是密钥隔离边界:不同 purpose 的密文互不可解
public SecureCookieService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("MyApp.SecureCookie.v1");
}
public void Write(HttpResponse response, string name, string plaintext)
{
string encrypted = _protector.Protect(plaintext); // 加密 + 签名
response.Cookies.Append(name, encrypted, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromHours(1)
});
}
public string? TryRead(HttpRequest request, string name)
{
if (!request.Cookies.TryGetValue(name, out var encrypted))
return null;
try
{
return _protector.Unprotect(encrypted); // 验签 + 解密
}
catch (CryptographicException)
{
// 密文被篡改、密钥已轮换失效、或非本应用产生 → 当作无效处理
return null;
}
}
}
五个工程要点
- purpose 字符串是隔离边界。 不同 purpose 的 protector 互相解不开对方的密文。给每类用途一个稳定、带版本号的唯一 purpose;它本身不是机密,但绝不能随意改动,否则旧 Cookie 全部失效。
Unprotect必须包 try/catch。 密文被篡改、过期失效或由其他密钥产生时会抛CryptographicException------这正是防篡改能力的体现,应当作「无效 Cookie」处理,绝不让请求 500。- 多实例部署必须共享密钥环。 Data Protection 默认把密钥存本地,多副本各自密钥不同会导致 A 加密 B 解不开。生产环境需持久化到共享位置并配
SetApplicationName:
csharp
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\shared\keys\myapp"))
.SetApplicationName("MyApp");
// 生产中常配合 .ProtectKeysWithAzureKeyVault(...) 等保护密钥本身
- 需要自动过期用
ITimeLimitedDataProtector。 让密文自带过期时间,即便客户端绕过MaxAge,服务端解密也会因超时失败:
csharp
var tlProtector = _protector.ToTimeLimitedDataProtector();
string token = tlProtector.Protect(plaintext, TimeSpan.FromMinutes(20));
- 注意体积。 加密后是 Base64,明显变长;单 Cookie 应控制在 4KB 内。大块数据应存服务端,Cookie 只放加密后的标识。
方案选型
| 场景 | 推荐方案 |
|---|---|
| 用户登录态 / 身份 | Cookie 认证(AddAuthentication().AddCookie()),别自己造 |
| 少量自定义敏感数据 | IDataProtector.Protect/Unprotect |
| 较大或高敏感数据 | 服务端存储 + Cookie 只放加密 ID |
第六部分:SecureCookieService 的调用与执行时序
SecureCookieService 是普通服务类,让它运转起来的是 DI 容器和请求管线。
注册与注入
csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection(); // 注册 IDataProtectionProvider
builder.Services.AddScoped<SecureCookieService>(); // 每请求一个实例
var app = builder.Build();
// 写入:登录成功后加密写 Cookie
app.MapPost("/login", (HttpContext ctx, SecureCookieService cookies) =>
{
cookies.Write(ctx.Response, "user_token", "user-id=42;role=admin");
return Results.Ok("已登录");
});
// 读取:后续请求取出并解密
app.MapGet("/profile", (HttpContext ctx, SecureCookieService cookies) =>
{
var token = cookies.TryRead(ctx.Request, "user_token");
return token is null ? Results.Unauthorized() : Results.Ok($"令牌内容: {token}");
});
app.Run();
控制器场景则用构造函数注入。关键是:你从不写 new SecureCookieService(...) ------容器负责创建,并递归地 先把依赖 IDataProtectionProvider 准备好再注入。
单次请求执行时序
渲染错误: Mermaid 渲染失败: Parse error on line 20: ...r_token=密文; HttpOnly; Secure... DI-> -----------------------^ Expecting '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', got 'NEWLINE'
四个易错点:
- 构造时机是按需、每请求。 实例并非启动时建好,而是请求路由到需要它的端点、容器解析依赖时才创建;构造函数里的
CreateProtector每请求执行一次,开销很轻。 - 依赖递归解析。 容器看到构造函数要
IDataProtectionProvider,会先创建它再new出服务,所以你只声明参数即可。 Append不等于立刻写头。 它只是把 Cookie 登记到响应;真正的Set-Cookie头在响应头刷出前才统一生成。因此必须在写任何响应体之前调用Append,否则会抛异常或被忽略。TryRead失败是预期路径。 Cookie 不存在、密文被篡改、密钥轮换都会让Unprotect失败而返回null,走未授权分支,这是正常业务流而非错误。
写入与读取通常跨请求
Write 与 TryRead 几乎从不在同一请求里,典型生命周期:
请求 A (POST /login) → Write → 服务器下发 Set-Cookie → 浏览器存储
↓
请求 B (GET /profile) → 浏览器自动带 Cookie 头 → TryRead → 解密使用
请求 C, D, E ... → 同上,Cookie 未过期就一直带
这恰好呼应第二部分的不对称:A 请求服务器用 Set-Cookie(带属性)下发,B 请求浏览器用 Cookie(仅键值对)回传,TryRead 从 Request.Cookies 拿到的就是那串密文。
总结
把六个部分串成一条主线:
- 协议层 决定了
Request.Cookies只能是键值对------Cookie头必须合并、Set-Cookie头禁止合并,属性只活在响应侧。 - 类型契约 上,
IRequestCookieCollection的索引器对缺失键返回null而非抛异常,生产代码应优先TryGetValue。 - 运行机制 上,
IRequestCookiesFeature惰性解析并缓存,既零成本又可替换。 - 读写对称 上,属性与
CookieOptions(含HttpOnly/Secure/SameSite)只属于Response.Cookies。 - 安全存储 上,敏感数据用
IDataProtector加密+防篡改,注意 purpose 隔离、异常处理、多实例共享密钥环。 - 执行时序 上,加密服务由 DI 容器按请求创建、递归注入,
Append延迟写头,读写通常跨请求完成。
理解了这条从协议到代码的完整链路,Cookie 相关的绝大多数「灵异现象」------读不到属性、多实例解密失败、响应已开始后写 Cookie 报错------都会变成可预测、可解释的必然结果。