浏览器安全机制与现代 SPA 认证架构深度解析

一、同源策略(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:存储与发送规则

浏览器收到 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 等公共后缀一律拒绝,防止跨站污染。

重要区分:同源 ≠ 同站

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 封锁影响)

当 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 认证架构的前提。

相关推荐
jay神4 分钟前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat8 分钟前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss1 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan2 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真2 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment2 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan2 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么2 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥3 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能