一、同源策略(SOP):一切的起点
1.1 什么是同源
浏览器用协议 + 主机 + 端口三元组定义"源"(Origin):
| URL | 与https://app.example.com的关系 |
|---|---|
https://app.example.com/page | ✅ 同源 |
http://app.example.com | ❌ 协议不同 |
https://api.example.com | ❌ 主机不同 |
https://app.example.com:8080 | ❌ 端口不同 |
1.2 SOP 保护的是什么
SOP(Same-Origin Policy)的核心规则:页面中的 JS 只能读取与自身同源的响应。
没有 SOP 会发生什么:
用户已登录 bank.com(浏览器持有 bank.com 的 Cookie) ↓ 用户访问 evil.com ↓ evil.com 的 JS 向 bank.com/api/transfer 发请求 浏览器自动携带 bank.com 的 Cookie ↓ 没有 SOP → JS 读到账户数据,完成转账 ← 攻击成功 有 SOP → JS 读不到响应内容 ← 攻击失败SOP 是浏览器一切安全机制的基础,CORS 和 Cookie 的 SameSite 属性都在它之上构建。
二、CORS:在 SOP 上有选择地开口
2.1 CORS 的本质
CORS(Cross-Origin Resource Sharing)不是"关闭 SOP",而是服务器通过响应头主动声明允许哪些外部来源读取自己的响应。
关键认知:CORS 是浏览器行为,不是服务器行为。
- 服务器只负责在响应头里写声明
- 浏览器决定是否放行,服务器无法控制浏览器跳过检查
- 非浏览器环境(curl、Postman、服务端代码)完全没有 CORS
2.2 简单请求与预检请求
浏览器将跨源请求分为两类,处理逻辑不同:
简单请求(满足以下全部条件):
- 方法:
GET/POST/HEAD Content-Type仅限:text/plain/application/x-www-form-urlencoded/multipart/form-data- 无自定义请求头
简单请求流程: 浏览器 服务器 │── GET /api/data │ │ Origin: https://app.example.com→ │ ← 请求已到达服务器并执行 │ │ │ ←─ 200 OK ─────────────────────── │ │ Access-Control-Allow-Origin: │ │ https://app.example.com │ │ │ ├─ ACAO 匹配 → JS 可读响应 ✅ │ └─ ACAO 缺失 → 浏览器拦截响应 ❌ │⚠️ 简单请求无论 CORS 结果如何,请求都已经发到服务器执行。CORS 只控制 JS 能否读响应,无法阻止请求本身(这也是为什么防 CSRF 不能只靠 CORS)。
预检请求(不满足简单请求条件,如application/json、Authorization头、PUT/DELETE方法):
预检请求流程: 浏览器 服务器 │── OPTIONS /api/data(预检)──────────────→ │ │ Origin: https://app.example.com │ │ Access-Control-Request-Method: POST │ │ Access-Control-Request-Headers: Authorization │ │ │ ←─ 204 No Content ─────────────────────── │ │ Access-Control-Allow-Origin: https://app.example.com │ Access-Control-Allow-Methods: GET, POST, PUT │ Access-Control-Allow-Headers: Authorization, Content-Type │ Access-Control-Max-Age: 7200 │ │ │ ├─ 预检通过 → 发实际请求 ✅ │ └─ 预检失败 → 实际请求不发送 ❌ │预检与简单请求的本质区别:预检失败时,实际请求根本不会发出。
2.3 携带凭证的跨源请求
默认情况下,跨源请求不携带 Cookie。需要同时满足两个条件才能带上:
// 前端:显式声明携带凭证fetch('https://api.example.com/data',{credentials:'include'});# 服务器响应头:两个条件缺一不可 Access-Control-Allow-Origin: https://app.example.com # 不能是 * Access-Control-Allow-Credentials: true2.4 如何合法避免预检
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 设计成简单请求 | 避免使用触发预检的方法/头 | API 设计阶段 |
Max-Age缓存预检结果 | 有效期内不重复预检 | 通用优化(Chrome 上限 2 小时) |
| 反向代理同源化 | Nginx 统一域名,浏览器认为同源 | 最彻底,推荐 |
| BFF 后端代理 | 浏览器只访问自家服务端,服务端无 CORS | 有后端的项目 |
三、Cookie:存储与发送规则
3.1 浏览器保存 Cookie 的 Domain 规则
浏览器收到Set-Cookie时,用域名匹配算法决定是否保存:
响应的 host 必须与 Domain 属性满足域名匹配关系:host 等于 Domain,或者 host 以
.Domain结尾。
响应来自 api.example.com: Set-Cookie: sid=abc; Domain=example.com → api.example.com 以 .example.com 结尾 ✅ 保存 → 发送范围:example.com 及所有子域 Set-Cookie: sid=abc; Domain=other.com → api.example.com 与 other.com 无关 ❌ 拒绝 Set-Cookie: sid=abc; Domain=sub.api.example.com → api.example.com 不以 .sub.api.example.com 结尾 ❌ 拒绝 (父域无法给子域设置 cookie) Set-Cookie: sid=abc(不带 Domain) → Host-Only 模式:仅 api.example.com 本身能收到 → 子域 sub.api.example.com 也收不到Public Suffix List(PSL)保护:浏览器内置公共后缀列表,Domain=com、Domain=github.io等公共后缀一律拒绝,防止跨站污染。
3.2 SameSite:控制 Cookie 的发送时机
重要区分:同源 ≠ 同站
同源(Same-Origin):协议 + host + 端口 完全相同 同站(Same-Site) :注册域(eTLD+1)相同即可 app.example.com vs api.example.com → 不同源(host 不同)→ 需要 CORS → 同站(同属 example.com)→ SameSite=Lax Cookie 可以发送| SameSite 值 | 同站 fetch | 跨站顶层跳转 | 跨站 fetch/XHR | 典型用途 |
|---|---|---|---|---|
Strict | ✅ | ❌ | ❌ | 高安全敏感操作 |
Lax(默认) | ✅ | ✅ | ❌ | 通用场景 |
None; Secure | ✅ | ✅ | ✅ | 跨站嵌入(受第三方 Cookie 封锁影响) |
3.3 第三方 Cookie 的困境
当 SPA(app.example.com)用fetch请求认证服务器(auth.okta.com)时,认证服务器尝试写入的 Cookie 属于第三方 Cookie:
- Safari(ITP):默认封锁
- Chrome(Privacy Sandbox):逐步封锁
- Firefox:默认封锁
这一趋势直接影响了依赖第三方 Cookie 的 OAuth 静默刷新方案。
四、OAuth 2.0 在 SPA 中的实践
4.1 核心概念回顾
Token 类型:
| Token | 有效期 | 作用 |
|---|---|---|
| Access Token | 短(5~15 分钟) | 携带在请求头,证明访问权限 |
| Refresh Token | 长(天/周级别) | 用于换取新的 Access Token |
客户端类型:
| 类型 | 是否能保密 | 典型场景 | 换 Token 方式 |
|---|---|---|---|
| Public Client | ❌ 代码对用户可见 | 浏览器 SPA、移动 App | PKCE,无 client_secret |
| Confidential Client | ✅ 代码在服务器 | 有后端的 Web 应用 | client_id + client_secret |
client_secret 为何不能放在 SPA:
SPA 代码运行在浏览器 → 任何人打开 DevTools → 网络面板看请求参数 → 源码面板看 JS 代码 → client_secret 形同公开,毫无意义PKCE(Proof Key for Code Exchange)是公开客户端的替代方案:每次登录动态生成一次性随机数,即使 code 被截获,没有对应的 verifier 也无法换取 Token。
4.2 静默刷新的历史与淘汰
早期 OAuth 2.0 的Implicit Flow专为 SPA 设计,但不发放 Refresh Token(认为浏览器存 Refresh Token 不安全)。Access Token 过期后,只能靠隐藏 iframe 续命:
主页面 └── 创建隐藏 <iframe> src = 认证服务器 /authorize?prompt=none ↓ 认证服务器读取 SSO Session Cookie(第一方 Cookie,登录时种下) ↓ ┌─ Cookie 有效 → 直接返回新 Token 到 iframe URL hash └─ Cookie 无效 → 返回 login_required 错误 ↓ iframe JS 读取 hash → window.parent.postMessage({ token }) ↓ 主页面 message 事件 → 更新 Access Token → 销毁 iframe该方案被淘汰的原因:
依赖 iframe 中的第三方 Cookie(SSO Session) ↓ Safari ITP / Chrome Privacy Sandbox 封锁第三方 Cookie ↓ iframe 无法携带认证服务器的 Session Cookie ↓ prompt=none 永远返回 login_required ↓ 静默刷新失效,用户被迫重新登录现在的推荐:Authorization Code Flow + PKCE,配合 Refresh Token 实现续签。
五、SPA + Web API 的两种认证模式
实际项目中,SPA 通常有自己的 Web API,两者的部署关系直接影响架构选择。
5.1 跨源请求的性质分类
| 请求 | 是否受 CORS 限制 | 原因 |
|---|---|---|
| 浏览器重定向到认证服务器登录页 | ❌ | 页面跳转,非 fetch |
| SPA fetch 认证服务器 /token | ✅ | 跨源 API 调用 |
| 服务端调认证服务器 /token | ❌ | 服务端对服务端,无浏览器参与 |
| SPA 调自家 Web API | 取决于部署 | 同域无问题,跨域需配 CORS |
5.2 模式一:SPA 作为 OAuth 客户端
SPA 直接完成 OAuth 流程,持有 Token。
Token 归属:
- Access Token → SPA 内存(页面关闭即丢失)
- Refresh Token → SPA 内存或 HttpOnly Cookie(有跨站限制)
适用场景:快速开发、无敏感数据、无 client_secret 需求的中小项目。
风险:Refresh Token 在浏览器侧,XSS 攻击可能窃取。
5.3 模式二:Web API 作为 OAuth 客户端(推荐)
既然项目已有 Web API,让它承担 OAuth 客户端角色,Token 完全不下发到浏览器。
Token 归属:
浏览器侧 │ 服务端(Web API) │ Session Cookie ──────┼──→ sessions 表 (不透明 ID) │ ┌─────────────────────────────────┐ │ │ sid │ user_id │ access_token │ refresh_token │ │ └─────────────────────────────────┘ │ ↑ │ Token 永远不离开服务端SPA 的 Session Cookie 需要 CORS 吗?
取决于部署方式:
情况一:反向代理同源(推荐) Nginx 统一 example.com / → SPA 静态文件 /api/ → Web API → 完全同源,无 CORS,无 SameSite 问题 情况二:子域分离 app.example.com(SPA) api.example.com(Web API) → 不同源,但同站(SameSite=Lax 生效) → Web API 配置 CORS 允许 app.example.com → Session Cookie 设置 Domain=example.com → SameSite=Lax,同站 fetch 可以携带 ✅Access Token 和 Refresh Token 在模式二中是否多余?
不多余,只是对 SPA 透明:
| 场景 | Token 的用途 |
|---|---|
| 有下游微服务 / 第三方 API | Web API 用 Access Token 调用下游 |
| 纯单体 Web API | Token 用于初次身份确认(/userinfo)和 SSO 登出(/revoke) |
| 多系统 SSO | 统一认证服务器识别同一用户 |
5.4 两种模式对比
| 模式一(SPA 持 Token) | 模式二(后端持 Token) | |
|---|---|---|
| Token 存放 | 浏览器内存 | 服务端数据库 |
| XSS 风险 | ⚠️ Token 有被窃风险 | ✅ Token 不进浏览器 |
| 支持 client_secret | ❌ | ✅ |
| CORS 复杂度 | SPA 需直接跨域调认证服务器 | 服务端调,无 CORS |
| 第三方 Cookie 影响 | ⚠️ 静默刷新依赖受限 | ✅ 不依赖第三方 Cookie |
| 架构复杂度 | 低 | 中 |
| OAuth 社区推荐 | ⚠️ 可用,但需谨慎 | ✅ RFC 9700 推荐 |
六、综合推荐架构
┌─────────────────────────────────┐ │ 认证服务器 │ │ (Auth0 / Keycloak / 自建) │ └──────────────┬──────────────────┘ │ 服务端对服务端 │ 无 CORS,可用 client_secret ┌──────────────▼──────────────────┐ │ Web API 后端 │ │ · 持有 Access Token(内存/Redis)│ │ · 持有 Refresh Token(数据库) │ │ · 签发 Session(HttpOnly Cookie)│ └──────────────┬──────────────────┘ │ 同源或同站 │ Session Cookie(SameSite=Lax) ┌──────────────▼──────────────────┐ │ SPA │ │ · 只持有 Session Cookie │ │ · 从不接触 Token 本体 │ └─────────────────────────────────┘部署层面,Nginx 反向代理同源化是最简洁的选择:
server { listen 443 ssl; server_name example.com; location / { root /var/www/spa; # SPA 静态文件,同源 } location /api/ { proxy_pass http://web-api:8080/; # 反向代理,浏览器视为同源 proxy_set_header Host $host; } }这一设置同时消灭了 CORS 问题和 SameSite 限制,是大多数项目的最优起点。
七、总结
| 问题 | 关键结论 |
|---|---|
| 什么是 SOP | 浏览器限制 JS 只能读取同源响应,防止跨站数据窃取 |
| 什么是 CORS | 服务器声明允许哪些外部源读取响应;是浏览器机制,非浏览器环境无效 |
| 预检能否跳过 | 不能;可通过 Max-Age 缓存或反向代理同源化规避 |
| Cookie Domain 规则 | 响应 host 必须是 Domain 的子域或本身;不带 Domain 则 Host-Only |
| SameSite 的核心 | 同站(eTLD+1 相同)≠ 同源;SameSite=Lax 允许同站 fetch |
| 静默刷新为何淘汰 | 依赖第三方 Cookie,被现代浏览器逐步封锁 |
| SPA 该用哪种模式 | 有 Web API 优先选模式二,Token 全部留在服务端,浏览器只持 Session Cookie |
| 最优部署方式 | Nginx 反向代理同源化,同时解决 CORS 和 Cookie 跨站问题 |
浏览器的安全边界是由浏览器划定的,服务器只能在规则内表达意图;理解这一点,是设计健壮 Web 认证架构的前提。