- Published on
浏览器会话 Cookie 装不进原生 App:ASP.NET Core 分块 Cookie 与 SecurityStamp 的机制账
- Authors

- Name
- Jack Qin
Cookie 认证是为浏览器设计的。这句话听起来像废话,但它背后藏着一连串隐含前提:Cookie 容量有上限、凭证可以被服务端撤销、客户端有一个原子的、jar 级一致的 Cookie 存储。浏览器把这三条全都满足了,满足得如此自然,以至于我们几乎从不去想它们的存在。
而当一个 React Native App 用操作系统原生的 Cookie 存储去消费同一套 Cookie 认证时,这三条前提会同时出现裂缝。本文想拆解的,不是某一个 bug,而是 ASP.NET Core Cookie 认证里三个平时各自安静工作的机制——分块(chunking)、SecurityStamp 校验、客户端 Cookie 存储语义——在跨出浏览器这个抽象边界后,是如何彼此咬合、把一个无害的默认值放大成致命链路的。理解了这三者的机制账,你就能预判这类问题,而不是事后排查。
机制一:为什么会有"分块 Cookie"
HTTP 单个 Cookie 有大小上限。规范没有强制数字,但浏览器和服务器普遍把单个 Cookie 控制在 4KB 量级,超了就被默默丢弃。
这对一个 ASP.NET Core Identity 的认证票据来说是个真问题。票据里装的不只是一个 session id——它是一个自包含的加密信封:security stamp、name、email、role 等 claim,序列化之后交给 Data Protection 加密、再 Base64URL 编码,最后还要套上 __Host- 前缀。claim 越多,信封越大。一个真实票据冲到 4KB 以上一点都不稀奇。
ASP.NET Core 的应对是 ChunkingCookieManager(默认就启用,ChunkSize = 4090)。它的策略很直接:票据超过单块上限时,切成 cookie、cookieC1、cookieC2…… 多个分块写下去,读的时候再按编号重组还原。
这里有个容易被忽略的细节,恰恰是后面故事的关键:当分块数量发生变化时——比如这次票据需要 3 块、上次只要 2 块——ChunkingCookieManager 不仅会写入新分块,还会对不再需要的旧分块发出删除型 Set-Cookie(把它们的过期时间设成 epoch)。也就是说,"重写一次分块 Cookie"在协议层面是一组需要被当作整体来应用的 Set-Cookie 指令:有的在写、有的在删。浏览器把这组指令原子地落进它的 Cookie jar,毫无悬念。记住"原子"这个词,它是第三个机制的伏笔。
机制二:SecurityStamp 校验,以及 ValidationInterval 这个旋钮
第二个机制回答的是一个安全问题:一张已经签发出去的 Cookie,怎么在它自然过期之前作废?
Identity 的答案是 security stamp。每个用户在数据库里有一个 stamp 值,签发 Cookie 时把它印进票据。当用户改密码、或你显式调用 UpdateSecurityStampAsync,数据库里的 stamp 就变了,于是所有印着旧 stamp 的 Cookie 在下次校验时全部失效。这是"改密码会踢掉所有其他设备"能成立的底层机制。
但校验是有代价的——它意味着要把票据里的 stamp 和数据库里的 stamp 比对。如果每个请求都查一次库,认证就成了性能瓶颈。于是框架给了你一个旋钮:SecurityStampValidatorOptions.ValidationInterval。它的语义是"距上次校验超过这个间隔,才重新校验一次 stamp"。默认 30 分钟。这个旋钮本质上是在撤销时效和校验开销之间做权衡:调小,stamp 变更传播得更快,但每请求开销上升;调大,开销低,但一张被作废的 Cookie 最多可能多活一个 interval。
关键的因果链在这里:每当 stamp 校验真正发生、并判定票据需要刷新时,ShouldRenew 被置为 true,框架就会重新签发 Cookie——也就是再吐一组 Set-Cookie。正常情况下这每 30 分钟才发生一次,无人在意。
但如果有人把 ValidationInterval 设成了 TimeSpan.Zero 呢?语义变成"每个请求都校验",于是每个请求都 ShouldRenew,每个请求都重发整组分块 Cookie。在浏览器里,这顶多是一点带宽浪费——因为浏览器会原子地把每一组重发落进 jar,状态始终自洽。注意这个"在浏览器里无害"的结论,它马上要被第三个机制推翻。
机制三:原生 Cookie 存储不是浏览器 Cookie jar
浏览器的 Cookie jar 有两个我们习以为常、却很少明说的性质:它理解 Set-Cookie 指令组的原子性,并且维护 jar 级的一致性。一组"写 C1/C2、删 C3"的指令要么整体生效,要么不生效,中间不会出现"新 C1 配着旧 C3"的撕裂状态。
原生 App 这边用的是另一套东西。以 react-native-nitro-cookies 这类库为例,它本质上是操作系统 Cookie 存储的一层无状态镜像——底下是 iOS 的 NSHTTPCookieStorage 或 Android 的 CookieManager。这些原生存储是按单个 Cookie 的 name 逐条管理的,它们不知道 app-auth、app-authC1、app-authC2 在语义上属于同一个票据,更不会把一次响应里的多条 Set-Cookie(含删除型)当作一个需要原子应用的整体。
于是裂缝出现了:当服务端每个请求都重发这一整套多分块 Cookie,而分块数量又在变化(因而夹带删除型指令)时,原生镜像没有能力原子地整体替换它们。某一次替换可能写进了新的低位分块,却没能正确清掉残留的高位分块。残留分块堆积下来,下一个请求就带着一个重组后已经损坏的票据去访问服务端。服务端解密票据(Unprotect)失败——失败得极快,约 0.1ms 就返回 401。一个活跃会话,登录后几秒钟,凭空 401。
三个前提的交汇
把三段机制叠起来,故障就成了一种结构性的必然,而不是偶然的 bug:
ValidationInterval = TimeSpan.Zero(机制二)→ 每请求重发分块__Host-Cookie(机制一)→ 原生镜像无法原子替换多分块集合(机制三)→ 残留分块累积 → 下一请求票据重组损坏 → 服务端解密失败 → 活跃会话里 ~0.1ms 401。
值得玩味的是,三个机制单独看都没有错。分块是为了塞下大票据;每请求校验是为了即时撤销;原生镜像是移动端访问系统 Cookie 的标准方式。错的是它们交汇处那个没人重新审视过的隐含前提:TimeSpan.Zero 这个值是在"客户端是浏览器、Cookie jar 是原子的"的世界观下挑的。一旦客户端换成原生 App,这个前提失效,默认值就从"无害"翻转成"致命"。
这也解释了为什么这类问题在排查时格外迷惑人:症状(活跃会话掉登录)看起来像持久化问题或冷启动问题,会把人引向"延长 MaxAge""冷启动重试"这些正交的轴上去——它们各自可能都是对的修复,但都没碰到真正的故障轴:活跃会话里的分块重组竞态。当一个修复无效时,比起再加一个补丁,更快的问法是"它修的到底是哪根轴"。
修复,以及它暴露的设计判断
直接的修复只有一行——让 ValidationInterval 回到一个有限且足够大的值,让 Cookie 不再每请求重发:
services.Configure<SecurityStampValidatorOptions>(opt =>
{
// 必须是有限值。只要移动端还通过原生 Cookie 镜像消费分块 __Host- Cookie,
// TimeSpan.Zero 就是被禁止的——它会让每请求重发分块 Cookie,触发原生侧的重组竞态。
opt.ValidationInterval = TimeSpan.FromMinutes(30);
});
但比这一行更重要的,是它逼出来的几个设计判断:
撤销时效的权衡要写明,而不是靠调旋钮硬来。 有限 interval 意味着"作废一张已签发 Cookie"(改密码、UpdateSecurityStampAsync)最多 30 分钟才传播。如果业务真的需要近乎即时的撤销,正确的路径是换一条不会每请求重发分块 Cookie 的机制——比如服务端用 ITicketStore 把票据落到服务端存储、Cookie 里只留一个引用,或者移动端干脆改用 bearer token 认证——而不是把 interval 往零调。把 interval 调零去追求即时撤销,恰恰会重新踩中这条链。
有些撤销根本不该依赖这个 interval。 账号被禁用、权限/角色变更、显式登出——这些如果每请求从数据库实时解析(而非从 Cookie claim 里读),就天然即时生效,完全绕开 interval 的延迟。这反过来给了一条设计准则:让 Cookie claim 保持最小。往票据里塞 role、email 这类可变信息,不仅让授权变得依赖陈旧快照,还会撑大票据、增加分块数——在原生 App 场景下,更大的票据意味着更高的分块重组竞态概率。把可变的授权信息留在每请求的 DB 解析里,Cookie 只承载身份,是双赢。
跨抽象边界的契约要落成可执行的回归守卫。 这条"ValidationInterval 必须有限"的约束,靠注释是守不住的——下一个人完全可能为了别的需求又把它调回零。真正能拦住回归的是一个集成测试:登录后断言后续的 GET /users/me 返回 200 且不携带任何 Set-Cookie。这一条"不重发"的断言,就是这条机制账的守门人——谁让 churn 复活,它立刻红。
可迁移的那一层
抛开 .NET 和 RN 的具体 API,这个案例真正可迁移的认知是:
一个为某种运行环境设计的抽象,它的默认值里冻结着那个环境的隐含前提。 把抽象搬到新环境(浏览器 → 原生 App、单机 → 分布式、同步 → 并发)时,最危险的往往不是那些显眼的不兼容,而是这些看起来照常工作、实则前提已经失效的默认值。Cookie 认证能在 App 里"基本能跑",正是这种危险的伪装。
排查这类问题时,与其逐个机制找谁坏了,不如反过来问:这套机制当初是在什么前提下设计的,我现在还满足那些前提吗? 三个机制单独都对、组合起来出事,几乎总是因为某个前提在边界处悄悄塌了。