前端 CORS 跨域与 Cookie 策略:SameSite、跨域携带凭证实战
每天清晨,数以亿计的前端应用发出跨域请求:SPA 调用后端 API,微前端子应用拉取主站数据,第三方 SDK 回传遥测信息。CORS(跨域资源共享)是这些请求的守门人——它允许或拒绝,决定了你的应用能否正常工作。
然而,绝大多数前端开发者对 CORS 的理解仅限于「后端加个 Access-Control-Allow-Origin: *」。当请求突然带上 credentials: 'include' 失败时,当 SameSite 策略默默吃掉 Cookie 时,当预检请求 403 时,排查过程往往充满玄学。
本文将从浏览器同源策略出发,深入到 CORS 的完整握手流程、SameSite Cookie 的行为差异、跨域携带凭证的坑与解法,以及如何在调试面板中快速定位问题。读完之后,你将不再「盲目加 Header」,而是真正理解每一步的原理。
一、同源策略:为什么需要 CORS?
1.1 什么是「同源」
浏览器的**同源策略(Same-Origin Policy)**是 Web 安全的第一道防线。两个 URL 同源,必须满足三个条件完全一致:
| 条件 | 示例 A | 示例 B | 是否同源 |
|---|---|---|---|
| 协议 | https://app.example.com | http://app.example.com | ❌(https vs http) |
| 域名 | https://app.example.com | https://api.example.com | ❌(app vs api) |
| 端口 | https://example.com:3000 | https://example.com:8080 | ❌(3000 vs 8080) |
同源策略的核心规则:非同源的脚本无法读取对方的 DOM、Cookie、LocalStorage 等敏感数据。如果没有这条规则,恶意网站可以通过 JavaScript 读取你的网银页面 Cookie。
1.2 跨域 ≠ 被禁止
同源策略限制的是脚本读取响应,而不是发送请求。实际上,<img>、<script>、<form> 天然就可以跨域。CORS 的存在,是让 fetch / XMLHttpRequest 这些 AJAX 请求也能安全地跨域——但前提是目标服务器明确允许。
浏览器发出跨域请求 → 服务器返回 CORS Header → 浏览器检查是否允许 → 允许则放行,否则拦截关键点:跨域拦截发生在浏览器端,不是服务端。 服务端实际上已经收到了请求并返回了响应,只是浏览器在响应到达后判断「不允许」,然后对 JavaScript 屏蔽了响应内容。
二、CORS 的两种请求流程
2.1 简单请求(Simple Request)
满足以下所有条件的请求走简单流程:
- 方法:
GET、HEAD或POST - Header 仅限:
Accept、Accept-Language、Content-Language、Content-Type(仅限text/plain、multipart/form-data、application/x-www-form-urlencoded) - 没有自定义 Header
XMLHttpRequest.upload没有事件监听器
// 简单请求示例fetch('https://api.example.com/data', { method: 'GET',});
// 浏览器实际发出的请求头:// GET /data HTTP/1.1// Host: api.example.com// Origin: https://app.example.com// (注意:Origin 是浏览器自动添加的)浏览器在请求头中自动加上 Origin: https://app.example.com。服务端必须在响应头中回以:
Access-Control-Allow-Origin: https://app.example.com如果响应中没有这个 Header,或者值不匹配,浏览器会拦截响应,JavaScript 拿到的是网络错误。
2.2 预检请求(Preflight Request)
不满足简单请求条件的,浏览器会先发一个 OPTIONS 预检请求:
// 触发电检请求的条件fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/json', // 非简单 Content-Type 'X-Custom-Header': 'value', // 自定义 Header }, body: JSON.stringify({ key: 'value' }),});
// 浏览器实际发出的预检请求:// OPTIONS /data HTTP/1.1// Host: api.example.com// Origin: https://app.example.com// Access-Control-Request-Method: POST// Access-Control-Request-Headers: content-type, x-custom-header服务端必须正确响应预检请求:
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: POST, GET, OPTIONSAccess-Control-Allow-Headers: content-type, x-custom-headerAccess-Control-Max-Age: 86400| 响应头 | 作用 | 示例值 |
|---|---|---|
Access-Control-Allow-Origin | 允许的源(必须精确匹配或用 *) | https://app.example.com |
Access-Control-Allow-Methods | 允许的 HTTP 方法 | GET, POST, PUT, DELETE |
Access-Control-Allow-Headers | 允许的自定义请求头 | content-type, authorization |
Access-Control-Max-Age | 预检结果缓存秒数 | 86400(24小时) |
Access-Control-Allow-Credentials | 是否允许携带凭证 | true 或省略 |
⚠️ 常见死锁:* 和 credentials 互斥
# ❌ 非法组合:浏览器直接拒绝Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: trueCORS 规范明确规定:当 Access-Control-Allow-Origin 为 * 时,不能同时设置 Access-Control-Allow-Credentials: true。这是很多开发者踩到的第一个坑。
三、SameSite Cookie 策略:跨域的隐形杀手
3.1 SameSite 的三个值
2016 年引入的 SameSite 属性,用来控制 Cookie 在跨域场景下的发送行为。这是理解跨域携带凭证的核心。
| 值 | 行为 | 适用场景 |
|---|---|---|
Strict | 完全不发送跨域 Cookie | 银行、支付等极高安全场景 |
Lax | 仅顶级导航的 GET 请求发送(点击链接、表单提交) | Chrome 80+ 默认值 |
None | 所有跨域请求都发送 | 需要跨域携带 Cookie 的场景(需配合 Secure) |
# Set-Cookie 示例Set-Cookie: session_id=abc123; SameSite=Lax; Path=/Set-Cookie: session_id=abc123; SameSite=None; Secure; Path=/Set-Cookie: csrf_token=xyz; SameSite=Strict; Path=/⚠️ Chrome 80+ 的重大变更:
从 Chrome 80(2020 年)开始,未显式设置 SameSite 的 Cookie 默认视为 SameSite=Lax。这意味着:
fetch()/XMLHttpRequest跨域请求不会携带 Cookie<img src>跨域不会携带 Cookie- 只有
<a href>点击跳转和<form method="GET">会携带
如果你的后端没有显式设置 SameSite=None; Secure,前端设置 credentials: 'include' 也不会携带 Cookie。这不是前端的 bug,而是浏览器的安全策略。
3.2 SameSite=Lax 的 POST 表单例外
SameSite=Lax 有一个重要的例外:顶级导航 + POST 表单也会发送 Cookie。
<!-- ✅ SameSite=Lax 的 Cookie 会发送 --><a href="https://api.example.com/login">点击跳转</a><form action="https://api.example.com/submit" method="POST"> <input name="data" value="test"> <button type="submit">提交</button></form>
<!-- ❌ SameSite=Lax 的 Cookie 不会发送 --><script>fetch('https://api.example.com/data', { method: 'POST', credentials: 'include', // 即使设置了也不会带 Cookie});</script>四、跨域携带凭证:credentials 实战
4.1 三种凭证模式
// 1. omit(默认):从不发送 Cookiefetch('https://api.example.com/data', { credentials: 'omit',});
// 2. same-origin:仅同源请求发送 Cookiefetch('https://same-origin.example.com/data', { credentials: 'same-origin',});
// 3. include:所有请求都尝试发送 Cookiefetch('https://api.example.com/data', { credentials: 'include',});4.2 完整的前后端配合方案
要让跨域请求携带 Cookie,需要前后端同时正确配置,缺一不可:
前端代码
const API_BASE = 'https://api.example.com';
export async function fetchWithAuth(path: string, options: RequestInit = {}) { const response = await fetch(`${API_BASE}${path}`, { ...options, credentials: 'include', // 关键:允许跨域携带 Cookie headers: { 'Content-Type': 'application/json', ...options.headers, }, });
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); }
return response.json();}
// 使用const data = await fetchWithAuth('/user/profile');后端配置(Express / Node.js)
// server.js - Express 后端 CORS 配置const express = require('express');const cors = require('cors');const app = express();
const ALLOWED_ORIGIN = 'https://app.example.com';
// 手动配置 CORS(推荐,比 cors 中间件更灵活)app.use((req, res, next) => { const origin = req.headers.origin;
// 验证 Origin 是否在白名单中 if (origin === ALLOWED_ORIGIN) { res.setHeader('Access-Control-Allow-Origin', origin); // 不能用 * res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'content-type, authorization'); res.setHeader('Access-Control-Max-Age', '86400'); }
// 处理预检请求 if (req.method === 'OPTIONS') { return res.sendStatus(204); }
next();});
// 设置 Cookieapp.post('/api/login', (req, res) => { const token = generateToken(req.body.username); res.cookie('session_id', token, { httpOnly: true, // 防 XSS secure: true, // 仅 HTTPS(SameSite=None 必须) sameSite: 'none', // 允许跨域发送 path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天 }); res.json({ success: true });});
// 验证 Cookieapp.get('/api/profile', (req, res) => { const sessionId = req.cookies?.session_id; if (!sessionId) { return res.status(401).json({ error: '未登录' }); } const user = verifyToken(sessionId); res.json({ user });});
app.listen(3000);后端配置(Python FastAPI)
# main.py - FastAPI 后端 CORS 配置from fastapi import FastAPI, Response, Requestfrom fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# 使用 FastAPI 内置 CORS 中间件app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], # 精确匹配,不能用 ["*"] allow_credentials=True, # 允许携带凭证 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["content-type", "authorization"], max_age=86400,)
@app.post("/api/login")async def login(response: Response): token = generate_token("user123") # 关键:same_site="none" + secure=True response.set_cookie( key="session_id", value=token, httponly=True, secure=True, samesite="none", max_age=7 * 24 * 60 * 60, path="/", ) return {"success": True}
@app.get("/api/profile")async def get_profile(request: Request): session_id = request.cookies.get("session_id") if not session_id: return {"error": "未登录"} user = verify_token(session_id) return {"user": user}4.3 部署环境的关键约束
| 约束 | 说明 | 违反后果 |
|---|---|---|
| HTTPS | SameSite=None 的 Cookie 必须设置 Secure 属性 | Cookie 不会被设置 |
| 精确 Origin | Access-Control-Allow-Origin 不能是 * | 浏览器拒绝携带 Cookie |
| 域名匹配 | Cookie 的 Domain 必须匹配请求域名 | Cookie 不会发送 |
| Path 匹配 | Cookie 的 Path 必须匹配请求路径 | Cookie 不会发送 |
| 端口独立 | :3000 和 :8080 是不同源 | 各自需要独立的 CORS 配置 |
五、常见场景与解决方案
5.1 场景一:开发环境跨域调试
本地开发时前端 http://localhost:5173,后端 http://localhost:3000,需要携带 Cookie:
// vite.config.ts - 使用 Vite Proxy(开发时推荐)export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, cookieDomainRewrite: 'localhost', // 重写 Cookie Domain }, }, },});使用 Proxy 方案,浏览器认为请求是同源的,完全绕开 CORS。这是开发环境的最佳实践。
5.2 场景二:子域名跨域
前端 https://app.example.com,后端 https://api.example.com,Cookie 在 example.com 域下:
# 后端设置 CookieSet-Cookie: session_id=abc; Domain=.example.com; SameSite=None; Secure; Path=/# ↑ 注意前面的点:.example.com 表示所有子域名共享
# 后端 CORS 响应Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: true// 前端请求fetch('https://api.example.com/data', { credentials: 'include', // 会携带 Domain=.example.com 的 Cookie});5.3 场景三:多环境动态 Origin
生产环境有多个前端域名(主站、管理后台、H5),需要动态匹配:
// Node.js 多 Origin 动态验证const ALLOWED_ORIGINS = new Set([ 'https://app.example.com', 'https://admin.example.com', 'https://m.example.com',]);
app.use((req, res, next) => { const origin = req.headers.origin; if (ALLOWED_ORIGINS.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); } if (req.method === 'OPTIONS') return res.sendStatus(204); next();});⚠️ 安全警告: 不要用 req.headers.origin 直接回写(res.setHeader('Access-Control-Allow-Origin', req.headers.origin)),这等于 *。必须用白名单验证。
六、调试指南:如何快速定位 CORS 问题
6.1 Chrome DevTools 检查清单
打开 DevTools → Network 面板 → 点击失败的请求:
- Headers 标签 → General:确认 Request URL 的协议/域名/端口
- Headers 标签 → Response Headers:检查是否有
Access-Control-Allow-*系列头 - Headers 标签 → Request Headers:确认
Origin是否正确发送 - Application 标签 → Cookies:检查 Cookie 的 SameSite、Secure、Domain、Path 属性
- Console 面板:查看具体的 CORS 错误信息
6.2 典型错误与排查路径
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
Access to fetch at '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header | 服务端未返回 CORS Header | 后端添加 Access-Control-Allow-Origin |
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include' | * 和 credentials: 'include' 冲突 | 后端改用精确 Origin |
Cookie "session_id" will be soon rejected because it has the "SameSite" attribute set to "None" without the "Secure" attribute | SameSite=None 缺少 Secure | Cookie 加 Secure 属性,并使用 HTTPS |
Response to preflight request doesn't pass access control check: It does not have HTTP ok status | 预检请求返回非 2xx 状态码 | 后端 OPTIONS 返回 204 |
NetworkError when attempting to fetch resource | 可能是网络问题或 CORS 拦截 | 检查 Network 面板的实际响应 |
6.3 用 curl 绕过浏览器验证
当不确定是后端问题还是浏览器问题时,用 curl 直接请求:
# 模拟浏览器请求(带 Origin)curl -v -X OPTIONS https://api.example.com/data \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type"
# 检查响应头中的 CORS 字段# 如果 curl 能收到正确的 CORS Header,说明是前端代码问题# 如果 curl 也收不到,说明是后端配置问题七、Cookie 安全最佳实践
7.1 Session Cookie 配置清单
// 生产环境 Session Cookie 推荐配置{ httpOnly: true, // 防 XSS(JavaScript 无法读取) secure: true, // 仅 HTTPS 传输 sameSite: 'none', // 跨域携带(需 secure: true) path: '/', // 全站有效 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 天 // domain: '.example.com', // 跨子域共享(按需启用)}7.2 CSRF Token 防护
当使用 Cookie 携带凭证时,CSRF 攻击成为现实威胁。防御方案:
// 方案一:Double Submit Cookie(推荐)// 1. 后端设置两个 CookieSet-Cookie: session_id=abc; HttpOnly; Secure; SameSite=NoneSet-Cookie: csrf_token=xyz; Secure; SameSite=None // JS 可读
// 2. 前端读取 csrf_token 并放入请求头const csrfToken = getCookie('csrf_token');fetch('/api/data', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrfToken, },});
// 3. 后端验证 Header 中的 token 与 Cookie 中的 token 一致// 攻击者无法读取 Cookie(同源策略),也无法伪造 Header(预检拦截)| 方案 | 优点 | 缺点 |
|---|---|---|
| Double Submit Cookie | 实现简单,无需服务端存储 | 依赖子域名隔离 |
| SameSite Cookie | 浏览器原生防护 | 旧浏览器不支持 |
| Custom Header + 预检 | 双重保障 | 增加一次预检请求 |
八、总结
CORS 和 Cookie 策略的核心逻辑可以用一句话概括:浏览器是守门人,服务端是规则制定者,前端是遵守者。
| 层次 | 角色 | 关键动作 |
|---|---|---|
| 服务端 | 规则制定者 | 返回正确的 Access-Control-Allow-* 和 Set-Cookie |
| 浏览器 | 守门人 | 检查同源策略、SameSite、CORS Header |
| 前端 | 遵守者 | 设置 credentials: 'include',处理错误 |
排查 CORS 问题的三个原则:
- 看 DevTools:Network 面板的 Request/Response Headers 比任何猜测都准确
- 分步验证:先确认后端返回正确的 CORS Header,再检查 Cookie 属性,最后看前端代码
- 理解 SameSite:Chrome 80+ 的默认
Lax是大多数跨域 Cookie 问题的根源
延伸阅读
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!