一、同源策略(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 会发生什么:
bash
用户已登录 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- 无自定义请求头
bash
简单请求流程:
浏览器 服务器
│── 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 方法):
bash
预检请求流程:
浏览器 服务器
│── 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。需要同时满足两个条件才能带上:
bash
// 前端:显式声明携带凭证
fetch('https://api.example.com/data', {
credentials: 'include'
});
bash
# 服务器响应头:两个条件缺一不可
Access-Control-Allow-Origin: https://app.example.com # 不能是 *
Access-Control-Allow-Credentials: true
2.4 如何合法避免预检
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 设计成简单请求 | 避免使用触发预检的方法/头 | API 设计阶段 |
Max-Age 缓存预检结果 |
有效期内不重复预检 | 通用优化(Chrome 上限 2 小时) |
| 反向代理同源化 | Nginx 统一域名,浏览器认为同源 | 最彻底,推荐 |
| BFF 后端代理 | 浏览器只访问自家服务端,服务端无 CORS | 有后端的项目 |
三、Cookie:存储与发送规则
3.1 浏览器保存 Cookie 的 Domain 规则
浏览器收到 Set-Cookie 时,用域名匹配算法决定是否保存:
响应的 host 必须与 Domain 属性满足域名匹配关系 :host 等于 Domain,或者 host 以
.Domain结尾。
bash
响应来自 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 的发送时机
重要区分:同源 ≠ 同站
bash
同源(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:
bash
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 续命:
bash
主页面
└── 创建隐藏 <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
该方案被淘汰的原因:
bash
依赖 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。
Web API认证服务器浏览器 SPAWeb API认证服务器浏览器 SPA① 重定向登录(带 PKCE code_challenge)② 重定向回 SPA(带 authorization code)③ POST /token(带 code + code_verifier)④ 返回 Access Token + Refresh Token⑤ 请求(Bearer Access Token)⑥ 验证 JWT 签名,返回数据⑦ Access Token 过期,用 Refresh 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 归属:
bash
浏览器侧 │ 服务端(Web API)
│
Session Cookie ──────┼──→ sessions 表
(不透明 ID) │ ┌─────────────────────────────────┐
│ │ sid │ user_id │ access_token │ refresh_token │
│ └─────────────────────────────────┘
│ ↑
│ Token 永远不离开服务端
SPA 的 Session Cookie 需要 CORS 吗?
取决于部署方式:
bash
情况一:反向代理同源(推荐)
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 推荐 |
六、综合推荐架构
bash
┌─────────────────────────────────┐
│ 认证服务器 │
│ (Auth0 / Keycloak / 自建) │
└──────────────┬──────────────────┘
│ 服务端对服务端
│ 无 CORS,可用 client_secret
┌──────────────▼──────────────────┐
│ Web API 后端 │
│ · 持有 Access Token(内存/Redis)│
│ · 持有 Refresh Token(数据库) │
│ · 签发 Session(HttpOnly Cookie)│
└──────────────┬──────────────────┘
│ 同源或同站
│ Session Cookie(SameSite=Lax)
┌──────────────▼──────────────────┐
│ SPA │
│ · 只持有 Session Cookie │
│ · 从不接触 Token 本体 │
└─────────────────────────────────┘
部署层面,Nginx 反向代理同源化是最简洁的选择:
bash
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 认证架构的前提。