正在加载文章,请稍候…
Token 存 Cookie 还是 localStorage?核心分歧、安全边界与完整踩坑指南
内容提要
从凭证可读性、浏览器自动携带身份和会话架构三条主线,比较 Cookie、localStorage 与内存方案,详解 XSS、CSRF、BFF、CORS、SSO、刷新竞态、退出撤销及多账户等边界。
Token 存 Cookie 还是 localStorage?对可以控制后端的常规浏览器网站,优先考虑由服务端维护会话,浏览器只保存带 HttpOnly、Secure 和适当 SameSite 的会话 Cookie;涉及 OAuth 时,可以评估 BFF,让访问令牌和刷新令牌留在服务端。没有后端、必须由浏览器直接访问资源 API 的 SPA,则需要按 OAuth 公共客户端约束设计,优先减少令牌暴露时间与持久化。
通常不建议把长期有效的访问令牌、刷新令牌或会话 ID 放进 localStorage。它方便恢复登录、手动添加 Authorization 头,但同源恶意脚本一旦执行,就可能读走可重放的凭证。Cookie 只有配置了 HttpOnly 才具备相应的脚本读取限制,同时还需要完整的 CSRF、会话生命周期和跨站策略。
核心分歧集中在两条边界:JavaScript 能否读出凭证,以及浏览器会不会自动附带凭证。再往上看,还有一个更重要的架构选择:浏览器是否真的需要持有可直接访问资源服务器的令牌。
本文以浏览器 Web 应用为主,区分事实、架构建议与示例配置。示例没有在某个真实系统中完成安全验证,具体策略需要结合域名拓扑、身份平台、浏览器支持和产品风险确定。
一、先拆开四个经常混在一起的问题
| 问题 | 常见选项 | 它决定什么 |
|---|---|---|
| 凭证的形式 | 不透明会话 ID、JWT、其他 OAuth Token | 服务端如何验证,是否需要查会话状态 |
| 凭证存在哪里 | HttpOnly Cookie、可读 Cookie、内存、Web Storage | 脚本可见性、持久性和浏览器作用域 |
| 请求如何认证 | 浏览器自动携带 Cookie、应用显式添加 Authorization | 凭证何时附带,典型 CSRF 风险从哪里进入 |
| 令牌由谁管理 | 浏览器、BFF、独立业务后端 | 刷新、撤销、OAuth 客户端身份与信任边界 |
JWT 是一种令牌格式,Cookie 是一种浏览器存储与传输机制。JWT 可以放进 Cookie,也可以作为 Bearer Token 放进请求头;不透明随机字符串同样可以作为 Bearer 凭证。说“JWT 所以不用 Cookie”或“Cookie 所以服务端一定有 Session”,都没有把问题拆清。
会话 Cookie 自身也是敏感凭证。如果攻击者通过其他途径拿到它,仍可能重放。HttpOnly 降低的是页面脚本直接读走 Cookie 值的风险,无法让凭证本身失去价值。
访问令牌通常用于调用资源 API;刷新令牌用于获得新的访问令牌,通常需要更强的保护;ID Token 用于 OIDC 身份认证语义,不应随意当作业务 API 的 Access Token 使用。CSRF Token 则是请求来源防护的一部分,不能与登录凭证混为一谈。
二、存储方式对比:先看能力,再谈取舍
| 方式 | 页面 JavaScript 能否读取 | 请求自动附带 | 持久与作用域 | 主要边界 |
|---|---|---|---|---|
| HttpOnly Cookie | 不能通过 document.cookie 读取该值 | 满足 Cookie 与请求策略时自动附带 | 按 Cookie 属性与浏览器上下文 | 防凭证直接外带;需要处理 CSRF、域与跨站限制 |
| 普通 Cookie | 可以读取匹配作用域的值 | 满足条件时自动附带 | 按 Cookie 属性 | 同时保留脚本可读与自动附带的风险 |
| localStorage | 同源脚本可以读取 | 不会自动附带,通常由代码加请求头 | 按 origin 持久化,无内置过期 | XSS 可读走凭证;清理、过期和异常由应用处理 |
| sessionStorage | 同源当前页面会话中的脚本可读 | 不会自动附带 | 按 origin 与页面会话划分 | 缩短持久性,仍不能抵御同源恶意脚本 |
| JavaScript 内存 | 应用可使用,恶意脚本仍可能截获或调用相关能力 | 不会因存储本身自动附带 | 受页面与进程生命周期影响 | 减少落盘与跨重启暴露,仍要防主动脚本攻击 |
| IndexedDB / Worker 内部 | 取决于接口与隔离实现 | 不会因存储本身自动附带 | 按存储或 Worker 生命周期 | 不能仅凭更换容器就认为实现了安全凭证隔离 |
OWASP 提醒不要把会话标识放入 localStorage,因为页面 JavaScript 始终可以访问它;单次 XSS 就可能读取其中数据。同样要避免把可修改的本地存储内容视为可信授权依据。参见 OWASP:HTML5 Security。
把 Token 从 localStorage 换到 sessionStorage,会减少一部分长期留存,却没有消除脚本读取面。把值加密后写入 localStorage,如果解密密钥与解密能力也都交给同一个页面,就很难阻止已经控制页面脚本的攻击者。
内存比长期持久化更有利于缩短暴露窗口,但“只放闭包、Redux、Worker 或 Service Worker”也不是完整防 XSS 证明。恶意脚本可能截获请求、调用令牌接口或借用已有认证能力。设计隔离组件时,需要明确它允许谁请求什么,而不只是隐藏一个变量名。
本地存储还可能因浏览器策略、存储限制或用户清理而不可用。读写要处理异常,不能在 Cookie 被拒绝后悄悄把长期凭证降级写进 localStorage。sessionStorage 的标签页划分也有 opener 等细节;新窗口可能获得初始副本,需要按页面打开方式验证。
三、XSS 与 CSRF:两种威胁,不能互相抵消
1. localStorage 方案最关心:凭证被读走后在别处使用
场景 A:应用从 localStorage 读取 Access Token
→ 同源恶意脚本读取该值
→ 凭证被外带
→ 在有效期、受众与约束允许的范围内被重放
令牌过期越晚、权限越宽、撤销越弱,
被读走后的后果通常越难控制。第三方脚本如果直接加载并运行在你的页面里,通常使用页面的执行上下文。它不会因为 script 的下载地址属于另一家公司,就自动失去访问页面 localStorage 的能力。隔离在独立 origin 的 iframe 是另一种边界,不能与普通 script 标签混为一谈。
2. HttpOnly 方案依然要防:在已登录浏览器中冒用操作
场景 B:会话凭证在 HttpOnly Cookie
→ 恶意脚本无法直接读到该 Cookie 值
→ 仍可能向同源 API 发请求
→ 浏览器按规则携带 Cookie
→ 脚本可能读取同源响应并执行用户有权执行的操作HttpOnly 的收益很实在:减少从页面直接取出会话凭证、转移到其他设备长期使用的路径。但攻击者控制页面时,仍可能读业务数据、提交操作、伪造界面或窃取用户输入。
因此,不能因为“XSS 发生后两边都可能受害”就说 HttpOnly 没价值,也不能因为 Cookie 不可读就放松 XSS 防护。两种架构会改变攻击方式、持续时间与清理后的残余风险。
3. CSRF 关心:请求带了身份,是否来自用户真实意图
攻击者即使无法读取你的凭证,也可能诱使浏览器向目标网站发请求。Cookie 在符合条件时自动附带,使服务器需要额外判断请求来源与意图。攻击者读取不到响应,并不意味着转账、改邮箱等副作用没有发生。
如果 API 严格只接受应用显式添加的 Authorization Bearer,且没有同时接受 Cookie 等环境凭证,传统跨站请求通常无法凭空得到这个 Token,典型 CSRF 面会小得多。但登录、刷新、退出接口可能仍依赖 Cookie;前端也可能被攻击者控制的 URL 或消息驱动,带着自己的凭证发出不该发的请求。
OWASP 将这种由不可信输入驱动合法前端发送请求的情况列为 client-side CSRF。凭证来源选择不能替代端点、参数与消息来源验证。参见 OWASP:CSRF Prevention。
还要检查同一个接口是否既接受 Authorization,又在缺失该头时自动回退到 Cookie 认证。只要回退路径可以使用浏览器自动附带的身份,就需要按相应的 CSRF 威胁模型保护,不能只看前端平时发送的那条路径。
四、先分清 same-origin 与 same-site,再设置 Cookie
Origin 通常由协议、主机和端口共同定义;Site 的判断还涉及协议及可注册域,并依赖公共后缀规则。CORS、localStorage 与 SameSite 使用的边界并不相同。参见 MDN:Site。
| 两个地址 | 是否同源 | 通常是否同站 | 工程含义 |
|---|---|---|---|
| https://app.example.com 与 https://api.example.com | 否 | 是 | Fetch 属于跨源请求;不必仅因这一点设置 SameSite=None |
| https://app.example.com 与 https://api.example.net | 否 | 否 | 涉及跨站 Cookie 与第三方策略 |
| https://example.com:443 与 https://example.com:8443 | 否 | 是 | 端口影响 origin,但 Cookie 不按端口隔离 |
| http://example.com 与 https://example.com | 否 | 否,按 schemeful same-site | 本地 HTTP 与生产 HTTPS 行为不能直接类比 |
以 app.example.com 调用 api.example.com 为例,Cookie 可以只由 API 主机设置并保持 host-only。浏览器访问 API 时根据目标地址决定是否携带它,前端不需要读出该 Cookie,也不需要为了“共享”就把 Domain 扩到整个 example.com。
Cookie 没有端口隔离,这与 Web origin 的隔离方式不同。同一主机的不同端口不能被当作 Cookie 的独立安全域。参见 RFC 6265:Cookie 的安全边界。
同站也不代表所有子域都可信。一个被接管的同站子域,可能绕开仅依赖 SameSite 的来源假设。高风险应用应对不可信内容、上传内容和其他应用进行真正的 origin 与站点规划。
五、Cookie 的属性:每一个都解决不同问题
| 属性或规则 | 作用 | 常见误解 |
|---|---|---|
| HttpOnly | 阻止页面通过脚本 Cookie API 读取该 Cookie | 不阻止带 Cookie 的请求,也不消除 XSS |
| Secure | 要求通过安全传输发送,特殊本地开发行为另行验证 | 不意味着内容已加密落盘,亦不等于 HttpOnly |
| SameSite | 约束 Cookie 在站点上下文中的发送 | 不能当作所有来源与业务请求的完整验证 |
| 不设置 Domain | 创建 host-only Cookie | 跨子域 API 不一定需要共享父域 Cookie |
| Path | 限制匹配的请求路径 | 不构成同源应用之间可靠的安全隔离 |
| Max-Age / Expires | 控制浏览器保存期限 | 不能代替服务端过期、撤销与绝对会话期限 |
| __Host- 前缀 | 在支持的浏览器中要求 Secure、Path=/ 且没有 Domain | 不会自动补上 HttpOnly,属性仍需显式设置 |
HttpOnly 应由服务端通过 Set-Cookie 设置。前端先拿到 Token,再用 document.cookie 写一个普通 Cookie,既没有自动得到 HttpOnly,也已经让 Token 经过页面脚本与可能的调试记录。
Strict、Lax 与 None 怎么选
- Strict:限制较强,适合不需要跨站带入该 Cookie 的会话;外部链接进入或身份回调依赖现有 Cookie 时,需要专门设计和验证。
- Lax:允许特定顶层跨站安全方法导航携带 Cookie,常用于兼顾外链进入体验;不要让 GET 承担退出、删除或转账等状态变更。
- None:允许跨站使用,但必须结合 Secure;浏览器第三方 Cookie 策略仍可能拦截,不能靠它绕开隐私限制。
- 显式设置策略。某些浏览器对未设置 SameSite 的默认 Lax 存在短时宽松行为,不能把默认值当成与显式 Lax 完全等价。
MDN 说明了 Lax 的顶层导航与安全方法条件、默认 Lax 的部分两分钟 POST 例外,以及 None 对 Secure 的要求。具体支持需要按目标浏览器核对。参见 MDN:Set-Cookie。
新一些的 __Http-、__Host-Http- 前缀还会要求 HttpOnly,用于约束 Cookie 必须经 HTTP 设置。采用前缀时要验证目标浏览器,不能假定不支持该前缀规则的客户端也会执行相同检查。前缀是一层约束,完整属性仍应明确。
六、Cookie 认证下,CSRF 防护如何落地
- 状态变更采用合适的非安全方法;让 GET、HEAD、OPTIONS 不承担业务写入,退出也使用受保护的操作。
- 优先使用框架成熟的 CSRF 实现。常见方案包括与会话关联的 synchronizer token,或正确绑定会话的签名双重提交方案。
- 对受保护请求验证 CSRF Token,并按部署情况校验 Origin,必要时采用明确的 Referer 回退策略。
- 可用 Fetch Metadata 等信号做补充;是否信任 same-site 要考虑其他子域的可信度。
- 严格约束 CORS 来源和允许头部。依赖非简单自定义头部与预检防护时,要确保所有写入口都执行该约束,且不同时接受绕过它的简单请求格式。
- 登录、刷新、退出和账户绑定都纳入检查;OAuth/OIDC 回调使用协议要求的关联与校验机制,不机械套用普通 API 规则。
CSRF Token 可以是前端可读的独立值,认证 Cookie 则保持 HttpOnly。Synchronizer Token 通常由页面或受保护接口返回;双重提交方案中的可读 Token Cookie 也不等于登录 Cookie。XSS 可能读到 CSRF Token,因此它主要针对跨站伪造,不是 XSS 防护。
不要直接把“Cookie 值等于请求体值”的朴素双重提交当作万能解法,子域 Cookie 注入等条件可能破坏其假设。来源为空、Origin 为 null、代理后的目标地址与回调特例,都应有明确策略,不能简单一律放行。
CORS 的确可以参与基于预检与自定义头部的 CSRF 设计,但“接口没开放跨域,所以任何恶意请求都发不出去”仍是错误推论。简单请求可能到达服务器并产生副作用,只是响应不向攻击页面暴露。
MDN 对 CSRF 也强调 SameSite、来源信息、Token 与非简单请求约束之间的分工;Fetch Metadata 可提供额外的上下文信号,但仍需处理兼容与可信来源策略。参见 MDN:CSRF 与 Sec-Fetch-Site。
七、跨源请求:Cookie、credentials 与 CORS 要一起看
假设前端为 https://app.example.com,API 为 https://api.example.com。两者跨源、同站。除 Cookie 自身匹配外,Fetch 还需要合适的 credentials,服务器也需要正确响应 CORS。
// 运行在 https://app.example.com
// csrfToken 是按既定 CSRF 方案取得的独立值。
const response = await fetch(
"https://api.example.com/profile",
{
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ displayName: "新昵称" })
}
);
if (!response.ok) {
throw new Error("Profile update failed");
}HTTP 响应头示例:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
预检还需明确允许对应方法与头部,例如:
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type, X-CSRF-TokenFetch 的 credentials 默认是 same-origin;跨源携带 Cookie 通常需要 include。它还影响浏览器是否接受相关响应中的 Cookie,不能把它理解为只控制一次请求头。参见 MDN:Request.credentials。
带凭证的跨源响应需要具体允许的 Origin,不能用通配符代替。预检通常不携带用户凭证,认证中间件应正确处理 OPTIONS;不能为了让预检通过而放松真实业务请求的认证。参见 MDN:CORS。
- include 不会覆盖 SameSite、Secure、Domain、Path 或第三方 Cookie 策略;这些条件分别起作用。
- 不要直接反射任意 Origin,再同时允许 credentials;应维护可信来源规则,谨慎处理子域匹配与 null。
- 不要用 mode: no-cors 修复登录问题,它限制请求并给出不可读响应,不会建立正确的认证协议。
- 页面 JavaScript 读不到 Set-Cookie 响应头是正常限制,不能靠 Access-Control-Expose-Headers 把它变成可读凭证。
- 浏览器报告 CORS 失败时,部分请求可能已发生。排查时区分请求没发出、Cookie 没存、Cookie 没带和响应不可读。
八、三种常见架构:先决定令牌是否需要进入浏览器
方案 A:同源服务端会话,或完整 BFF
浏览器
└─ HttpOnly 会话 Cookie → 应用后端 / BFF
├─ 会话与授权校验
├─ 保存、刷新 OAuth Token
└─ 带 Access Token 调资源 API
前端接收用户状态与业务数据,
不接收可直接调用资源 API 的 OAuth Token。普通自有网站可以仅使用服务端会话,不必为了登录引入 OAuth 或 JWT。接入 OAuth 时,BFF 作为服务端组件完成授权与 API 代理,浏览器只持有与它关联的会话凭证。
这个方案减少了页面脚本直接获得 OAuth Token 的机会,适合有后端控制权、希望统一管理令牌的 Web 应用。代价是增加服务组件、代理链路与运维责任;BFF 需要限制可访问的上游、路径、方法和权限,避免成为任意代理或 SSRF 入口。
方案 B:后端管理刷新能力,前端持有短期 Access Token
浏览器 ── HttpOnly 会话 Cookie ──→ 令牌中介后端
浏览器 ←─ 短期 Access Token ────── 令牌中介后端
浏览器 ── Authorization: Bearer … → 资源 API
刷新令牌由后端管理;
Access Token 仍进入浏览器执行环境。这种模式可以减少浏览器对长期刷新凭证的接触,仍允许前端直连资源 API。但主动 XSS 可能调用中介端点取得新的 Access Token;刷新入口和 Cookie 会话仍然需要 CSRF 防护。
常见的“Access Token 放内存,Refresh Token 放 HttpOnly Cookie”也属于一类混合设计,需要先说清 Cookie 内究竟是原始 Refresh Token、受保护的状态还是会话引用,以及由哪个可信后端完成刷新。OAuth 授权服务器不会自动读取任意业务域 Cookie 并替你完成刷新。
RFC 10017 区分完整 BFF、Token-Mediating Backend 和浏览器直接 OAuth 等模式;其中是否把 Access Token 交给前端,会改变令牌窃取面。该 RFC 对 BFF 要求 Secure、HttpOnly,建议 Strict 与收窄 Cookie 范围,并要求适当的 CSRF 防护。参见 RFC 10017:Browser-Based Applications。
方案 C:没有后端的纯 SPA,直接使用 OAuth
纯静态部署、必须直连 API 的 SPA 是公共客户端,不能通过在前端包或构建变量里放 client_secret 把自己变成机密客户端。采用成熟 SDK 与 Authorization Code + PKCE,按身份平台支持情况处理刷新、恢复登录和会话过期。
OAuth 的安全建议包括保护授权码流程、使用 PKCE、限制重定向与令牌权限,并避免不安全的历史流程。PKCE 保护授权流程中的特定攻击路径,不提供“发生 XSS 后令牌不可用”的保证。参见 OWASP:OAuth2。
优先减少 Token 的持久存储和有效期;如果无法避免持久化,必须把可读存储遭同源脚本访问的风险纳入评审。不能把“没有 BFF”当成长期 Refresh Token 放 localStorage 已经安全的论据。确实需要可靠长期登录时,也要评估增加适当后端是否更符合整体成本。
九、一个同源会话示例:HttpOnly 凭证与可读 CSRF Token 分工
下面采用同源 Web/BFF 会话,示范 Strict 策略。它要求登录与回调设计能支持该策略;存在明确外链或跨站流程需求时,可以有依据地调整并加强相关防护。过期时间只是示例,不能作为所有业务的统一标准。
登录成功后,由服务端返回:
Set-Cookie: __Host-session=<opaque-random-session-id>; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=1800
Cache-Control: no-store
要求:
- 服务端生成不可预测的会话 ID,保存真实会话状态。
- 不设置 Domain。
- 服务端独立执行会话过期、授权与撤销。
- GET /api/session 返回用户摘要与独立 CSRF Token,
不返回会话 ID、Access Token 或 Refresh Token。前端可以通过 /api/session 恢复用户显示状态,无需读取 HttpOnly Cookie。登录态是 unknown、authenticated 或 anonymous 的状态流程,不能仅凭“本地有一个字符串”就认定服务端会话有效。
// 教学示例:CSRF Token 由同源会话接口按服务端方案签发。
// 省略了完整的 schema 校验、UI 状态与登录跳转。
async function updateProfile(displayName, csrfToken) {
const response = await fetch("/api/profile", {
method: "PATCH",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ displayName })
});
if (response.status === 401) {
throw new Error("Session expired");
}
if (response.status === 403) {
throw new Error("Request not permitted");
}
if (!response.ok) {
throw new Error("Update failed");
}
}服务器仍需验证会话、来源、CSRF Token、字段与资源权限。401、403 只是示例约定,实际要用明确错误码区分过期、无权与 CSRF 失败,避免前端把所有错误都当成“刷新一下”。
封装认证请求时,应只对明确可信的目标附带 Bearer 或 CSRF 头。不要让用户控制的完整 URL 进入自动加凭证的通用请求器;同样需要验证 postMessage 的 origin、source 和消息结构。
十、Access Token 与 Refresh Token:最容易出事故的是刷新流程
1. 过期、撤销和浏览器保存期限是三个维度
Access Token 应有与风险匹配的短期有效期、受众与权限范围;Refresh Token 或会话的生命周期应有绝对边界。Cookie Max-Age 只能控制浏览器保留,localStorage 本身也没有令牌过期机制,服务端仍必须独立验证。
前端解析 JWT 的 exp 可用于安排刷新与提示,但客户端时钟可能偏差,标签页可能休眠,定时器可能延后。以服务端校验为准,并在恢复页面、请求失败和身份变化时执行受控恢复。
RFC 9700 要求公共客户端的 Refresh Token 采用发送方约束或轮换机制,并讨论刷新令牌重放检测。轮换是授权服务与客户端共同完成的协议,不能只在前端定时换一个存储键。参见 RFC 9700:Refresh Token Protection。
2. 同页 single-flight,跨标签还要协调
同一页面:
多个请求发现需要刷新
→ 共享一个 refresh Promise
→ 刷新成功后按请求策略继续
→ 失败则统一结束认证状态,避免无限刷新
多个标签页或多个 BFF 实例:
还要协调同一会话 / Token family 的并发刷新,
不能只依赖当前页面里的一个布尔值。轮换后的旧 Refresh Token 再次使用,可能触发重放检测。多个正常标签页并发刷新、多个服务实例竞争刷新,也可能撞上这个条件。应结合 SDK、身份服务和后端原子状态设计协调与恢复,不能随意用无限宽限期掩盖问题。
刷新后自动重试也要看业务:读取请求通常更容易重放;创建订单、扣款等写请求必须考虑原请求是否已经执行、是否支持幂等键,流式或一次性请求体能否重用。不要把所有 401 都无条件“刷新后原样再发”。
网络超时还可能表示刷新已经成功,但轮换后的响应没有到达。此时盲目重用旧 Refresh Token 可能被判为重放;需要遵循身份服务定义的恢复或重新认证策略,明确可靠性与重放检测之间的取舍。
3. 退出与刷新之间的竞态
T1:旧会话开始刷新
T2:用户点击退出,界面清空认证状态
T3:旧刷新响应晚到,重新写入 Token 或 Set-Cookie
T4:页面看起来又登录了前端可用会话代次或 authEpoch 阻止旧异步结果回写,取消在途任务并清理缓存。服务端则要让会话撤销与刷新相互约束,防止已撤销会话继续获得有效令牌。
对于 HttpOnly Cookie,浏览器可能在页面 JavaScript 处理响应前就按响应头处理 Set-Cookie。前端丢弃旧 Promise 结果,不能证明浏览器没有写入 Cookie。因此必须依靠服务端撤销状态、版本或原子检查,让晚到的旧凭证无法恢复有效会话。
4. DPoP 有帮助,但不能代替页面安全
发送方约束 Token 可以降低“只偷到一个字符串就直接重放”的风险。若使用 DPoP 等机制,还要正确管理密钥、证明和服务端校验;页面被控制时,攻击者仍可能借用客户端签名或调用能力。把密钥设为不可导出,也不等于恶意脚本无法调用相关能力。
十一、退出登录:清掉浏览器值,还没有完成撤销
- 先对当前会话、刷新凭证或相应 Token family 执行服务端失效处理,明确仅退出本设备还是所有设备。
- 让服务端按正确的 Cookie 名、Domain 和 Path 清除认证 Cookie;分区 Cookie 还涉及对应分区上下文。
- 清理页面内存、业务缓存、用户数据和待执行任务,通知其他标签页状态变化;避免广播原始凭证。
- 拒绝旧会话异步响应回写,处理旧连接、后台页面和恢复后的再认证。
- 明确已经签发的 Access Token 是否仍可用至过期,是否需要服务端会话检查、撤销列表或其他立即失效策略。
服务端删除示例(匹配之前的 host-only Cookie):
Set-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0
Cache-Control: no-store
同时必须撤销服务端会话。
只返回删除头,不等于所有旧 Access Token 都立即失效。OWASP 要求会话过期和用户退出时在服务端使会话失效,并在登录及权限等级变化时更新会话标识,降低会话固定等风险。参见 OWASP:Session Management。
同名 Cookie 可能因不同 Domain 或 Path 并存,导致“明明删除了却还登录”。Path 也不能作为隔离不同安全等级应用的可靠防线。不要通过随意加大 Cookie 范围来掩盖清理错误。
JWT 的自包含验签并不自动提供立即撤销。删除 localStorage 或 Cookie 后,被复制出去的 JWT 仍可能有效;要立即停用,就需要额外服务端状态或受控验证机制。签名密钥轮换通常影响范围很大,不应当作单个用户退出的日常手段。
业务系统退出与身份提供方的单点会话退出也不同。清掉业务会话后,下一次登录跳转仍可能因 IdP 会话有效而迅速再次登录。产品需要区分“退出当前应用”“退出所有设备”和“退出统一身份会话”,并按身份平台支持的注销流程实现。
十二、SSO、OAuth 回调与第三方 Cookie:最容易误配的地方
1. SameSite=Strict 可能影响依赖既有 Cookie 的回调
外部身份提供方回到应用属于跨站导航;如果回调校验依赖某个 Strict Cookie,它可能没有随请求到达。Lax 对顶层 GET 回调通常更友好,但跨站 form_post 回调又有不同条件。不要看到回调失败,就把所有认证 Cookie 永久改成 None。
应按照协议、回调方法与身份 SDK 设计短期、用途受限的关联机制,并验证 state、PKCE、OIDC nonce 等适用检查,以及重定向 URI 和目标地址。普通登录也可能有 login CSRF,不能因为用户尚未登录就直接跳过来源与流程绑定。
跨不同可注册域的 SSO 通常通过身份协议与跳转建立各站会话,无法靠给 Cookie 写一个共同 Domain 或让两站读取同一份 localStorage 实现。不要把跨域共享长期凭证作为默认 SSO 方案。
2. SameSite=None 不保证跨站 Cookie 一定能工作
浏览器、隐私设置和嵌入环境可能阻止或分区第三方 Cookie。即使 CORS 和 SameSite 配置正确,也要验证真实部署中的跨站登录与恢复路径。参见 MDN:Third-party cookies。
在 iframe、嵌入组件、多站点门户中,要明确需求究竟是“每个宿主网站内保持一个会话”,还是“不同宿主网站共享全局登录”。两者对浏览器隐私边界的要求不同。
CHIPS 的 Partitioned Cookie 按顶层站点上下文等信息分区,适合特定嵌入场景,但不会自动获得跨所有顶层站点共享同一身份的效果。支持、安全上下文和相应属性也需要核对。参见 MDN:CHIPS。
根据场景评估顶层认证导航、同站部署或 BFF、Storage Access 等浏览器能力,并设计拒绝授权或存储不可用时的体验。不要依赖用户关闭浏览器隐私保护才能维持基本登录。
十三、多标签页、共享设备与账户切换
1. 同一作用域 Cookie 通常被多个标签页共享
A 标签登录账户甲,B 标签切换账户乙,A 下一次带 Cookie 的请求可能已经使用乙的会话。即使 A 的 React 状态里还显示甲,也不能假定请求身份没有变化。产品必须明确是否支持同一浏览器多个账户同时在线,不能靠每页一个内存变量暗中实现。
localStorage 同样按 origin 共享;sessionStorage 的页面会话划分不同,但这不改变服务端认证凭证本身的共享方式。若刷新接口依赖共享 Cookie,还可能给不同标签页的内存令牌引入身份错配。
2. 使用状态广播,不要广播长期凭证
可以通过 BroadcastChannel、合适的 storage 事件或已有状态协议通知登录、退出和账户变化,随后让页面重新向服务器确认身份。消息应包含必要的状态与会话代次,不应为方便同步就传播原始 Refresh Token。
缓存键与异步结果也要带上用户、租户和会话归属。切换账户时清理查询缓存、图片预览、编辑草稿、离线数据及连接,避免“权限没越过,但上个用户的数据还留在屏幕上”。
3. 关闭浏览器不等于服务端已经退出
localStorage 通常跨重启保留;持久 Cookie 有独立期限;没有 Expires 的会话 Cookie 也可能受浏览器会话恢复影响。共享设备的产品应提供明确退出、短期会话与必要的再次认证,不能把关闭标签页作为可靠的撤销事件。
十四、SSR、缓存、日志和请求封装中的隐蔽泄露
| 位置 | 常见坑 | 处理方向 |
|---|---|---|
| SSR 首次请求 | localStorage 在服务器不可用,首次导航也不会自动带其值 | 按架构处理服务端会话或客户端恢复,明确初始 unknown 状态 |
| 服务端全局变量 | 把一个用户的 Token 放进共享单例,导致其他请求复用 | 认证状态按请求与会话隔离 |
| SSR HTML 与水合数据 | 把服务端 Token 序列化进页面、调试对象或页面源码 | 只下发必要用户摘要,不下发服务器保管的凭证 |
| CDN 与 Service Worker | 缓存 /me、个性化 HTML 或敏感接口后跨账户复用 | 明确禁止或隔离敏感缓存,审查 SW 自定义缓存逻辑 |
| 请求拦截器 | 给任意完整 URL 自动加 Authorization / CSRF 头 | 按解析后的目标地址、路径与协议限制可信目的地 |
| 日志与监控 | Cookie、Authorization、URL Token、响应体被自动采集 | 在代理、服务、APM、埋点各层做凭证脱敏 |
| JWT 内容 | 把签名当加密,把前端 decode 当验签 | 最小化声明,服务端执行完整验证 |
| 大 Cookie | 把完整用户资料和大 JWT 塞进 Cookie | 控制体积与匹配范围,留意请求头与网关限额 |
Cache-Control: no-store 适合包含会话标识、令牌或敏感状态的相关响应,但自定义 Service Worker Cache API、应用缓存和错误采集还需要单独约束。不能因为使用 HttpOnly,就忽略响应中泄露相同 Token 或缓存中保存了业务数据。
JWT 的服务端校验需要依据预期算法、密钥、签发方、受众及用途执行,不应信任仅经过 Base64 解码的前端内容。常见签名 JWT 的内容也不因签名而自动保密。参见 RFC 8725:JWT Best Current Practices。
即使 HTTPS 与 Cookie 配置都正确,具有特殊权限的恶意浏览器扩展、终端恶意软件或本地设备访问也可能形成额外风险。HttpOnly 是浏览器 Web 脚本层面的限制,不是操作系统凭证保险箱;原生 App 应使用平台相应的安全存储与认证机制。
十五、WebSocket、SSE、下载与文件上传的边界
| 场景 | 为什么会遇到问题 | 可考虑的设计 |
|---|---|---|
| 普通页面跳转、img、下载链接 | 无法像通用 Fetch 一样随意设置 Authorization | 同站会话接口、受控下载代理,或短期用途受限的签名链接 |
| 原生 EventSource | 构造接口没有任意自定义头部选项 | Cookie 与合适的 credentials;或评估 Fetch 流及其重连处理 |
| 浏览器原生 WebSocket | 构造接口没有通用 Authorization 头部选项 | 安全 Cookie 会话,或短期连接票据与连接后认证协议 |
| 跨域文件上传 | 预检、Cookie 策略及对象存储授权要求不同 | 后端鉴权后签发受限上传凭据,并限制对象、大小与期限 |
| 跳转与第三方集成 | 凭证可能进入 URL、历史、日志和引用来源 | 不把长期 Access / Refresh Token 放到 URL,严格限制跳转目标 |
原生 EventSource 提供 withCredentials 等连接选项,浏览器原生 WebSocket 构造接口主要接受 URL 与子协议;它们的凭证接入方式不能简单照搬 Fetch 的 headers。参见 MDN:EventSource。
WebSocket 的浏览器构造参数没有任意请求头配置项,子协议也不应随意成为长期 Token 的传输容器。参见 MDN:WebSocket。
使用 Cookie 认证的 WebSocket 要验证握手来源和会话,并在连接及消息层执行适当授权与失效处理;不能直接把普通 CORS 配置当作 WebSocket 安全策略。参见 OWASP:WebSocket Security。
短期下载或连接票据仍然是凭证。若放在 URL,也可能进入日志,应限制用途、资源、有效期与适用的一次性语义,并在日志中脱敏。连接建立后也要考虑令牌过期、会话撤销和重连,不能默认一条长连接永久有效。
十六、本地开发和线上发布:为什么“本地能登,线上不行”
- 本地代理让 /api 看起来同源,生产却部署为另一 origin,credentials 与 CORS 配置因此变化。
- localhost 的某些安全属性特殊处理,与 IP、内网主机、HTTPS 生产域的行为不同;不要直接复制开发环境的宽松设置。
- Domain 不能包含协议或端口,Cookie 也不能由服务器设置给无关域;通常优先不写 Domain。
- 路径、反向代理前缀与默认 Path 发生变化,造成 Cookie 只在部分接口发送,或删除时匹配不到。
- 生产 HTTPS 在代理终止 TLS,后端对外部协议或主机的识别配置错误;只信任受控代理传来的相关信息。
- 请求头限制、浏览器第三方策略、嵌入上下文与身份回调方式在生产出现,开发链路没有覆盖。
- 同域不同端口的本地服务共享 Cookie 匹配范围,可能用同名 Cookie 相互覆盖;localStorage 却可能因为端口不同分开。
排查登录问题时应按顺序确认:响应有没有正确 Set-Cookie,浏览器是否接受,后续请求是否匹配并附带,服务端是否认可会话,前端能否读取响应。每一步失败的原因不同,不能统一归咎于“跨域”。
十七、按场景做选择
| 场景 | 优先评估的方案 | 必须补齐的条件 |
|---|---|---|
| 可控制后端的常规 Web / SSR | 服务端会话 + HttpOnly Cookie | HTTPS、CSRF、作用域、过期撤销、缓存隔离 |
| OAuth 登录且可部署代理后端 | 服务端保存令牌的完整 BFF | 授权流程、受控 API 代理、会话与 CSRF、服务运维 |
| 前端需直连资源 API,且有后端 | 令牌中介后端 + 短期 Access Token 内存 | 承认 Access Token 暴露面,保护刷新端点与权限范围 |
| 纯静态 SPA、没有后端 | 成熟 OAuth SDK、Code + PKCE、尽量少持久化 | 公共客户端约束、刷新安全与恢复策略、XSS 防护 |
| 跨源但同站的前后端 | host-only Cookie 仍可行 | credentials、精准 CORS,同站子域信任边界 |
| 跨站 API、iframe 或嵌入组件 | 先评估部署拓扑与平台能力 | 第三方 Cookie / 分区 / 拒绝授权时的产品路径 |
| 必须同浏览器多账户并存 | 先设计身份与存储分区方式 | 会话、刷新、缓存、标签页及 UI 归属一致 |
| 原生或混合客户端 | 平台安全存储与专用 OAuth 流程 | 不要照搬浏览器 localStorage/Cookie 二选一 |
如果团队因历史兼容选择 localStorage,不应把它描述为与 HttpOnly 会话同等的凭证读取边界。先识别其中存放的是短期 Access Token 还是高价值 Refresh Token,收窄权限与期限,清除泄露路径,制定可验证的迁移计划。兼容现状与认可风险是两件事。
十八、上线前验证清单:用行为证明配置成立
| 要验证的行为 | 建议覆盖的用例 |
|---|---|
| 脚本读取边界 | 认证 Cookie 不出现在 document.cookie;接口、HTML 和日志不重新暴露相同凭证 |
| Cookie 属性 | Secure、HttpOnly、SameSite、host-only、Path、保存与删除作用域符合设计 |
| 跨源与跨站 | 同源、同站跨源、真正跨站;有无 credentials;预检和不可信 Origin |
| CSRF | 外站表单、简单请求、缺失或错误 Token、登录与刷新、同站不可信子域 |
| OAuth/OIDC | 回调方法、state/PKCE/nonce 等适用校验、非法跳转、重复或过期回调 |
| 刷新并发 | 多个请求、多个标签页、多个服务实例、令牌轮换与失败恢复 |
| 退出竞态 | 刷新与退出交错;旧响应、旧 Set-Cookie、旧连接不恢复有效会话 |
| 账户隔离 | 甲切到乙后,旧请求、缓存、离线数据、SSR 与 UI 不串号 |
| 撤销与期限 | 会话空闲与绝对期限、令牌 exp、撤销后已签发 Token 的剩余可用时间 |
| 浏览器与环境 | 目标浏览器、隐私模式或存储拒绝、iframe、代理与生产域、移动端 |
| 数据处理 | CDN、Service Worker、监控与导出不意外保存或传播凭证 |
这些是测试设计建议,不代表本文已经执行。CSRF 验证要使用真实的不同站点上下文;Cookie、第三方策略与回调行为也需要目标浏览器验证,单靠服务端单元测试或 Postman 成功不能证明浏览器行为正确。
可以用安全审查和专门测试验证预期读取边界,不必在真实用户生产会话中注入恶意脚本。对于修改密码、资金或高价值账户操作,还应按风险增加重新认证与交易意图确认,减少单个已登录会话被滥用的后果。
十九、面试或技术评审,可以这样回答
我会先区分凭证格式、存储位置、发送方式和会话架构。JWT 与 Cookie 没有天然对立。对能控制后端的常规 Web 应用,我优先考虑服务端会话或 BFF,浏览器保存 HttpOnly、Secure、合适 SameSite 的会话 Cookie,让 OAuth Token 尽量留在服务端。
核心取舍是:localStorage 方便 JavaScript 取值添加 Bearer 头,但同源 XSS 可读走凭证并重放;HttpOnly 减少直接外带风险,但浏览器仍可能携带 Cookie 执行恶意请求,因此需要 XSS 与 CSRF 两方面的防护。localStorage 不自动附带凭证,只能说明传统 CSRF 面不同,不能证明登录、刷新及整个应用都没有 CSRF。
我还会检查 same-origin 与 same-site、credentials 和 CORS、Domain 与 Path、SSO 回调和第三方 Cookie。生命周期上要处理刷新轮换、跨标签并发、退出与刷新竞态、撤销和缓存隔离。纯 SPA 必须直连 API 时,采用合适的 OAuth 公共客户端流程并减少持久化;最终用目标浏览器和完整登录链路验证方案。
选择存储容器只是起点。完整的工程判断应能说明:哪些脚本能接触凭证,浏览器何时发送身份,服务器怎样识别合法请求,以及过期、刷新、退出和账户切换后哪些能力仍然有效。
参考资料
以下主要资料用于核对浏览器机制与认证安全要求。选型表、请求示例和验证清单为基于这些机制整理的工程建议;具体实现应使用适配框架与身份平台的成熟组件,并核对目标版本。
RFC 10017:OAuth 2.0 for Browser-Based Applications
RFC 9700:OAuth 2.0 Security Best Current Practice
RFC 8725:JWT Best Current Practices
