登录态不是一枚令牌,而是一条可撤销的生命线
一次典型的登录态故障,往往不是"Token 过期了"这么简单:页面同时发出十个请求,十个请求同时收到 401;其中一个请求先去刷新,另一个标签页却执行了登出;刷新接口返回后,旧状态又覆盖了新状态。最终,用户看到的是"明明退出了却还像登录着",或"偶发地被踢下线"。
这说明:登录态不是某个 Cookie、JWT 或字段,而是一条持续演化、可被攻击、必须可撤销的凭证生命周期。
设计时不要先问"Cookie 和 Token 哪个更安全",而应先回答四个问题:
- 凭证放在哪里? 浏览器脚本、浏览器网络层,还是服务端?
- 谁能读取它? 同源 JavaScript、任意子域、后端会话服务,还是只有 BFF?
- 它何时失效? Access Token 到期、空闲超时、绝对超时、密码修改、管理员强制下线,分别如何生效?
- 失效后谁负责恢复或终止? 前端能否刷新、后端是否接受旧凭证、其他标签页如何同步?

先把登录态看成状态机,而不是存储选项
一个可控的登录态至少包含以下状态:
text
未认证
└─ 登录成功 → 已建立会话
├─ 正常请求 → 已建立会话
├─ Access Token 临近或已经过期 → 刷新中
│ ├─ 刷新成功 → 已建立会话
│ └─ 刷新失败 / 被撤销 → 已失效 → 未认证
├─ 用户主动登出 → 已失效 → 未认证
└─ 密码修改、风控命中、管理员下线 → 已失效 → 未认证
浏览器本地认为"已登录",不等于服务端仍认可该会话。前端缓存的用户资料、内存中的 Access Token,以及仍存在的 Cookie,都不能作为授权成立的最终依据。服务端必须在每次受保护请求中验证会话或令牌状态;前端负责用户体验与请求协调,而不是替代撤销、风控和授权决策。
OWASP 将会话标识视作与主认证同等敏感的凭证:一旦被盗用,就可能在有效期内代表用户执行操作。因此,会话固定、重放、长期不失效,以及登出后旧凭证仍可用,都属于生命周期设计缺陷,而不只是"前端存储不规范"。
威胁模型:不要用一种防线回答所有问题
| 风险 | 攻击者想得到什么 | 典型前提 | 主要防线 |
|---|---|---|---|
| XSS | 在受害站点执行恶意 JavaScript | 页面存在脚本注入或危险 DOM Sink | 输出编码、HTML 清洗、CSP、Trusted Types、第三方脚本治理 |
| Token 泄露 | 直接复制可长期使用的凭证 | Token 可被脚本、日志、URL 或错误存储读取 | HttpOnly Cookie、避免 Web Storage、短时效、轮换、撤销 |
| CSRF | 借助浏览器自动携带的身份发起状态变更 | 服务端依赖 Cookie,且缺少请求来源校验 | SameSite、CSRF Token、Origin/Referer、Fetch Metadata |
| 重放 | 重复使用已窃取的凭证 | 凭证仍有效且服务端无法识别异常使用 | 短时 Access Token、Refresh Token 轮换、发送者约束、撤销 |
| 会话固定 | 诱导用户使用攻击者预先掌握的会话标识 | 登录后未轮换会话 ID | 登录、提权与重新认证后轮换会话 |
| 登出后继续使用 | 使用尚未在服务端失效的旧凭证 | 登出只清了前端状态 | 服务端撤销、会话版本号、刷新令牌家族失效 |
**HttpOnly 只能降低脚本读取 Cookie 的风险,不能阻止已经执行的 XSS 以当前用户身份调用接口。**Cookie 即使不能通过 document.cookie 读取,仍可能随同源 fetch 或 XHR 自动发送。因此,把 Token 放进 HttpOnly Cookie 不是 XSS 的终点,而是把"复制凭证后离线滥用"缩小为"恶意脚本在线滥用当前会话"。
存储位置决定攻击面,不决定全部安全性
Cookie 是浏览器的传输与存储机制;Token 是凭证格式或授权载体。真正应比较的是:凭证由谁持有、谁能读取、请求如何携带、失效如何执行。
| 方案 | 浏览器脚本能否读取核心凭证 | 是否自动随请求发送 | 主要优势 | 核心代价与风险 |
|---|---|---|---|---|
| 服务端 Session + HttpOnly Cookie | 否 | 是,受 Cookie 规则约束 | 浏览器不直接持有可读会话秘密;撤销直观 | 必须设计 CSRF 防护、会话存储与跨域策略 |
Token 放 localStorage / sessionStorage |
是 | 否,需手动加请求头 | 调用 API 方便 | 一次 XSS 即可能读取并导出凭证;不应保存认证凭证 |
| Access Token 仅存内存 | 当前页面的恶意脚本通常仍可接触 | 否 | 刷新页面后不持久化,泄露窗口较小 | XSS 仍可实时滥用;刷新与多标签页协调复杂 |
| HttpOnly Cookie + 前端直连 API | 否 | 是 | 减少脚本读取秘密的机会 | 仍需防 CSRF,跨域 Cookie 与 CORS 复杂 |
| BFF + HttpOnly 会话 Cookie | 否,OAuth Token 留在服务端 | 浏览器只向 BFF 自动携带会话 Cookie | 浏览器不直接接触 OAuth Access/Refresh Token | BFF 需要承担转发、可观测性、扩缩容和隐私责任 |
OWASP 建议不要将认证 Token、Session ID、JWT 或 Refresh Token 存入 localStorage 或 sessionStorage:同源 JavaScript 都可能读取它们,一处 XSS 即可能泄露全部凭证。
对于 OAuth 浏览器应用,BFF 的价值不只是"多一层代理",而是让 BFF 作为机密客户端处理 OAuth 交互,持有 Access Token 与 Refresh Token,再以 Cookie 会话识别浏览器。这会增加转发、限流、审计、扩缩容和隐私边界等服务端责任。
Cookie 属性:每个属性只解决自己的问题
http
Set-Cookie: __Host-session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
Secure:只允许 Cookie 在 HTTPS 请求中发送;不阻止 JavaScript 读取未设置HttpOnly的 Cookie。HttpOnly:禁止 JavaScript 通过 Cookie API 读取;不阻止恶意脚本借助浏览器自动携带 Cookie 发请求。SameSite=Strict:限制跨站请求携带 Cookie,适合不依赖跨站回调、嵌入或跳转的会话。SameSite=Lax:在可用性与跨站限制之间折中,但不能替代对状态变更请求的 CSRF 防护。SameSite=None; Secure:允许跨站携带,必须配合 CSRF、CORS 和第三方 Cookie 策略设计。Domain:扩大 Cookie 可发送的主机范围。若非跨子域所需,不要设置。Path:限制发送路径,但不是可靠的安全隔离边界。Max-Age/Expires:定义浏览器保存期限,不等于服务端授权期限。__Host-:要求Secure、Path=/且不能设置Domain,适合单主机场景。
SameSite 判断的是"站点"关系,不能简单等同于"同源";同一注册域下的不同子域可能是同站但不同源。若子域并不完全可信,不能因为它们属于同一站点就共享高价值 Cookie。
fetch(..., { credentials: 'include' }) 只是请求浏览器尝试携带凭证;Cookie 是否真的发送,还受 SameSite 和浏览器第三方 Cookie 策略影响。凭证式 CORS 响应必须返回明确的 Access-Control-Allow-Origin,不能使用 *,并配合 Access-Control-Allow-Credentials: true。
XSS 与 CSRF:一个是在站内夺权,一个是借站外伪造
- CSRF:攻击者通常不能在你的站点执行脚本,但能诱导用户浏览器把 Cookie 带到你的站点。
- XSS:攻击者已经能在你的站点执行脚本,因此往往可以读取页面数据、调用接口,甚至绕过依赖前端附加请求头的防护。
SameSite 不能替代 XSS 防护,CSRF Token 也不能抵消页面脚本被接管的后果。两者必须独立设计。
XSS 防线应从"禁止读取 Token"升级为"减少可执行路径"
- 默认使用框架自动转义能力,把不可信内容当作文本渲染。
- 按 HTML、属性、URL、JavaScript、CSS 等上下文分别编码与校验。
- 避免把不可信数据交给
innerHTML、outerHTML、document.write、动态事件处理器、javascript:URL、eval和字符串形式的定时器。 - 确需富文本时使用经过审计的清洗策略,并纳入版本管理和测试。
- 使用 nonce 或 hash 收紧 CSP;可先通过
Content-Security-Policy-Report-Only观察违规,再执行阻断。 - 在支持的环境启用 Trusted Types,限制字符串直接流入危险 DOM Sink。
- 治理第三方脚本和依赖供应链,审查来源、版本、动态加载和标签管理器规则。
CSP 是纵深防御,不能替代输入处理和输出编码;Trusted Types 也不能解决所有 XSS 来源。
Cookie 会话的 CSRF 防护应由服务端判定
对会改变服务端状态的 POST、PUT、PATCH、DELETE 请求,建议组合使用:
SameSite=Strict或Lax作为基础限制;- 同步 CSRF Token 或经过严格设计的双重提交模式;
- 校验
Origin,缺失时谨慎回退到Referer; - 使用
Sec-Fetch-Site等 Fetch Metadata 拒绝明显的跨站状态变更请求; - 对改密码、支付、提现、绑定 MFA 等高价值操作要求重新认证、一次性确认或交易级签名。
刷新机制:轮换不是"多发一个 Token"
短时 Access Token 用于缩短单次泄露后的可用窗口;Refresh Token 用于避免用户频繁重新登录。由于 Refresh Token 往往有效期更长,必须被视为高价值凭证。
可靠的刷新策略至少包含:
- 短时 Access Token;
- 每次刷新签发新的 Refresh Token,并使旧 Token 失效;
- 服务端保存 Token 家族关系,检测失效旧 Token 的再次使用,并撤销对应链路;
- 空闲过期和绝对过期;
- 改密码、风险登录、权限变化、账号冻结和全端下线时撤销刷新能力;
- 支持查看和撤销特定设备或全部设备的会话。
RFC 9700 针对公开客户端提出发送者约束或 Refresh Token 轮换等重放检测措施。前端只能在刷新失败后清理状态并引导重新登录,不能单独实现轮换、复用检测、撤销列表或全端下线。
前端刷新协调器:把 401 当作并发控制问题
多个请求同时过期时,不能让每个 401 都各自刷新。在 Refresh Token 轮换模式下,第一个刷新可能已经使旧 Token 失效,第二个请求再使用旧 Token 就可能触发复用检测。
同一页面内可以使用单飞刷新;多个标签页还需要通过 BroadcastChannel、Web Locks 或服务端幂等策略协调。否则,每个标签页各自刷新,仍可能发生竞争。
ts
let refreshPromise: Promise<void> | null = null;
let authGeneration = 0;
class AuthInvalidated extends Error {}
function invalidateAuth() {
authGeneration += 1;
// 清理内存状态、取消等待中的请求,并通知其他标签页
}
async function recoverSession() {
if (!refreshPromise) {
const generationAtStart = authGeneration;
refreshPromise = (async () => {
await refreshSession();
if (generationAtStart !== authGeneration) {
throw new AuthInvalidated();
}
})().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function authenticatedFetch(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
const alreadyRetried = headers.get('X-Auth-Retry') === '1';
const response = await fetch(input, {
...init,
credentials: 'include'
});
// 刷新接口本身、登录接口和公开接口应在实际实现中单独排除
if (response.status !== 401 || alreadyRetried) return response;
try {
await recoverSession();
} catch {
invalidateAuth();
throw new AuthInvalidated();
}
headers.set('X-Auth-Retry', '1');
return fetch(input, {
...init,
credentials: 'include',
headers
});
}
生产实现还应补足:
- 每个原请求最多进行一次认证恢复重试;
- 刷新失败时统一进入"已失效",拒绝等待队列中的请求;
- 使用
AbortController取消已离开页面、已登出或已超时的请求; - 使用会话代际号,防止晚到的旧响应覆盖登出后的状态;
- 不把网络超时、
403权限不足和业务错误一概当成可刷新401; - SSR 中不能复用某个用户的认证上下文给另一个请求,每次服务端请求都应从当前请求构造身份。
多标签页、离线与登出:本地同步不是撤销
用户在标签页 A 登出时,标签页 B 可能仍在轮询接口。应同时执行:
- 本地传播 :通过
BroadcastChannel或其他协调机制通知同源页面停止认证请求、清理缓存并跳转登录页。 - 服务端撤销:登出接口撤销当前会话或 Refresh Token 家族。即使标签页 B 没有及时收到通知,下一次请求也必须被服务端拒绝。
BroadcastChannel 只负责消息传递,不提供会话撤销语义,不能代替服务端验证。
离线恢复时,不要因为本地还保存着用户资料就直接认定已登录。恢复网络后应由服务端重新确认会话;若刷新失败或会话已撤销,应清理敏感缓存。涉及资金、权限和数据导出的请求,尤其不能仅凭本地状态决定是否继续执行。

按风险选择架构,而不是追求唯一答案
| 场景 | 建议会话形态 | 必要配套 |
|---|---|---|
| 低风险、同站普通 Web 应用 | 服务端 Session + HttpOnly; Secure; SameSite=Lax/Strict Cookie |
CSRF 防护、会话超时、登录后轮换、XSS 基线治理 |
| 含个人数据的 SPA / SSR 应用 | HttpOnly Cookie 会话,或由 BFF 承担 OAuth 凭证 | CSRF Token、来源校验、刷新轮换、设备会话管理、CSP |
| 支付、资金、账号安全、管理员后台 | BFF 或严格服务端会话模型 | 重新认证、交易确认、风险评估、全端撤销、审计、最小权限 |
| 跨子域、嵌入 iframe、跨站 API | 根据真实站点关系单独设计 | 谨慎使用 SameSite=None; Secure、显式 CORS、CSRF 方案和重认证降级路径 |
如果应用是同站、低风险,后端已有成熟 Session 管理,受限作用域的 HttpOnly Cookie 会话往往更直接。如果应用使用第三方身份提供方、资源服务器分散,或权限敏感且需要降低浏览器暴露 OAuth Token 的机会,BFF 更值得投入。
真正不应接受的方案是:把长期凭证放进浏览器可读持久化存储,再用"我们有 CSP"或"Token 很短"作为唯一补救。前者让 XSS 获得直接导出凭证的能力,后者无法替代轮换、撤销和服务端治理。
发布前检查清单
- 登录、提权、重新认证后是否轮换会话 ID 或会话代际?
- 是否避免把认证 Token、JWT、Session ID、Refresh Token 放入 Web Storage、URL、日志和埋点?
- Session Cookie 是否具备
Secure、HttpOnly和合适的SameSite?单主机场景能否使用__Host-? - 是否为所有状态变更接口设计 CSRF 防护,并由服务端校验来源、Token 或 Fetch Metadata?
- 是否明确输出编码、富文本清洗、危险 DOM API、CSP、Trusted Types 与第三方脚本治理责任?
- Access Token、Refresh Token、空闲超时、绝对超时和安全事件撤销是否有清晰语义?
- Refresh Token 是否轮换,服务端是否能检测复用并撤销 Token 家族?
- 多个
401是否采用单飞刷新、跨标签页协调、失败广播和重试上限? - 登出是否同时完成本地清理、服务端撤销、跨标签页同步与未完成请求处理?
- SSR、跨域、跨子域、iframe、WebView 与第三方 Cookie 受限场景是否单独验证?
- 高价值操作是否要求重新认证,而不是只相信一个仍未到期的登录态?
登录态安全的目标,不是让某个 Token "绝对拿不到",而是让攻击者即使拿到局部能力,也难以长期复制、跨设备重放、静默续期或绕过撤销。把凭证读取权、浏览器自动携带行为、刷新轮换、服务端撤销和 XSS 防线放进同一条生命周期里,前端登录态才真正具备可推演、可审计、可恢复的安全边界。
参考资料
- OWASP Session Management Cheat Sheet
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- OWASP Cross Site Scripting Prevention Cheat Sheet
- MDN:Set-Cookie
- MDN:Cross-Origin Resource Sharing(CORS)
- RFC 9700:OAuth 2.0 Security Best Current Practice
- RFC 10017:OAuth 2.0 for Browser-Based Applications
- W3C:Content Security Policy Level 3
- W3C:Trusted Types