
那些年为了绕过 CORS 我干过的蠢事,第 3 个上过生产
CORS 让人抓狂,从来不是因为它复杂,而是因为它报错的对象搞错了人——红字打在前端的控制台里,要改的往往在后端。
咱们前端谁没被那行红字创过。
我第一次遇到的时候,干的事跟所有人一样:把那段红字原样粘进搜索框,翻到一个几千赞的答案,往 Express 里塞了一句 Access-Control-Allow-Origin: *,好了,能跑了,下一个需求。
然后这套动作我重复了三年。每次遇到,每次这么干。一次都没停下来想过,浏览器到底在检查什么。
直到有天开会,一个刚来的实习生问我:「阿森哥,CORS 到底是干嘛的呀?」
我张了张嘴,一个字没说出来。
我"解决"过这个错误上百次,站在那儿发现自己压根不知道它是什么。
那天晚上我认真坐下来把这事搞明白了。结果最让我难受的不是"原来这么简单",而是——我以前干过的那些绕过手段,有一半是在给自己埋雷,其中一个还上过生产。
下面这 7 件蠢事,我全干过。每件我都配了当时的心理活动和后来的补救,看看你中了几件。
蠢事一:把 Access-Control-Allow-Origin 加在请求头里
📌 先看个场景
控制台说缺 Access-Control-Allow-Origin。
那还不简单,加上不就完了:
jsfetch('https://api.qianduandaren.com/user', {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*' // ← 它说缺这个,我就给它
}
});刚写完看着挺合理,对吧?报错说缺什么,我就补什么。
但问题来了。 不但没好,报错还变了——多了一句 Request header field access-control-allow-origin is not allowed by Access-Control-Allow-Headers。
我当时人都傻了:我加个头怎么还多出个错?
因为 Access-Control-Allow-* 这一整族,全都是响应头,是服务器说的话。你把它塞进请求里,等于你替对方回答"我允许你进来"。
更惨的是,你塞的这个自定义头会被算进预检要授权的名单里——服务器得在 Allow-Headers 里明确允许它,请求才走得通。你等于凭空给自己加了一道要过的关。
(严格说,上面这个例子的 Content-Type: application/json 本身就已经会触发预检了,不是你加的那个头引起的。但如果原本是个简单请求,加一个自定义头就足以把它变成预检请求——这个坑是真的。)
打个比方
这就像你去别人家小区,保安说"你没有访客登记"。
你于是自己掏出一张纸,写上"本人已获准入内",递给保安。
保安不但不放你进去,还多问了一句:"你这纸哪来的?"
💡 记住一句话
Access-Control-Allow-* 是服务器说的,不是你说的。 你在前端能改的,只有请求本身长什么样——改不了对方允不允许。
插一句:到底什么才算"跨域"
上面那个例子里,我的页面是 www.qianduandaren.com,接口是 api.qianduandaren.com。
都是我自己的域名,怎么就跨域了?
这是我当年第二个想不通的地方。答案是:浏览器判断"同不同源",只看三样东西——
code协议 + 域名 + 端口
三个里有任何一个不一样,就是不同源。 没有"差不多"这一说。
code基准: https://www.qianduandaren.com
https://api.qianduandaren.com ❌ 不同源(子域不同)← 最多人栽在这
http://www.qianduandaren.com ❌ 不同源(协议不同)
https://www.qianduandaren.com:444 ❌ 不同源(端口不同)
https://qianduandaren.com ❌ 不同源(少了 www 也算)
https://www.qianduandaren.com/api ✅ 同源(路径不影响)
https://www.qianduandaren.com:443 ✅ 同源(443 是 https 的默认端口)最后一行是个容易搞混的地方:比的不是"字符串长得一样",而是规范化之后的「协议 + 主机 + 有效端口」这三样。 https://xxx.com 和 https://xxx.com:443 写法不同,但有效端口都是 443,所以同源。主机名的大小写也会被规范化。
还有两个特别容易翻车的:
localhost和127.0.0.1不同源。 它俩指向同一台机器,但比较的是主机名字符串本身,不做 DNS 解析。本地开发时你在一个地址上测通了,换另一个就跨域,人会很懵。- 端口。
localhost:3000的前端调localhost:8080的后端,就是跨域。这是绝大多数人第一次遇到 CORS 的场景。
💡 一句话:同源比的是规范化后的那三样,不是"实际上是不是同一家"。 你的域名归你所有,跟浏览器认不认,是两回事。
蠢事二:装个"跨域插件",然后忘了关
📌 先看个场景
后端说改 header 要走发布流程,得等两天。两天?我今天就得联调。
于是我装了个 Chrome 插件,一键 Allow CORS。红字消失,世界安静,我美美地把功能写完了。
但问题来了。
我忘了关。
接下来两周,我本地测什么都是通的。QA 那边偶尔说"这个页面数据不出来",我在自己电脑上一试——好好的啊,是不是你缓存问题?
来回扯了三次,最后是我同事路过瞄了一眼我的浏览器,说了句:「你这插件图标咋是亮的?」
我用一个把安全检查关掉的浏览器,测了两周的跨域功能。
比插件更狠的是这个,我也干过:
bash# 千万别把这行存进你的 shell alias
open -na "Google Chrome" --args \
--disable-web-security \
--user-data-dir=/tmp/chrome-cors-test⚠️ 注意那个 -n。少了它,如果 Chrome 已经开着,open 很可能直接复用现有实例,后面那些参数一个都不生效——你以为关了安全检查,其实没关,然后对着一个"怎么还报错"的窗口挠头。
另外 --user-data-dir 一定要指向一个临时目录:这样它是个全新的、干干净净的 profile。别在里面登录任何真实账号,用完关掉窗口、把目录删了。
这个更危险,因为它不像插件有个图标提醒你。它就是一个普普通通的 Chrome 窗口,只是你所有的网页都在裸奔。你用它顺手刷个微博、登个后台,安全策略全是关的。
打个比方
这就像为了方便,把自家防盗门拆了。
拆的时候只想着"我这两天要频繁搬东西"。
然后你搬完了,忘了装回去,还在里面住了两周。
💡 别走极端
我不是说开发时绝对不能用。但要用就用一次性的:
- 插件用完立刻关,或者干脆用一个专门的浏览器 profile,只在联调时打开
--disable-web-security一定要配-n(强制新实例)和--user-data-dir(临时目录),用完就删- 更好的做法是本地起个代理(Vite / Webpack devServer 都自带),从根上不产生跨域
最要紧的一条:绕过手段只能用来"让开发继续下去",绝不能用来"验证功能是对的"。 你的验证环境必须和用户的一样。
蠢事三:把 Origin 原样反射回去 —— 这个上了生产
📌 先看个场景
项目要支持三个前端域名:主站、管理后台、H5。* 又不能用(要带 cookie)。写死一个又不够。
我当时觉得自己特别机灵,写了这么一段:
js// 我当年的"通用解法"
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin); // 谁来就让谁
res.header('Access-Control-Allow-Credentials', 'true');
next();
});多优雅啊。 谁来访问就把谁的域名回给谁,三个域名全支持,以后加域名都不用改代码。
它工作得完美无缺。上线了,跑了大半年,一个 bug 都没有。
但问题来了。
大半年后一次安全评审,同事看了三秒,问我:「那我自己建个站,也能拿到用户数据?」
我说不能吧,只有我们三个域名……
他打开自己写的一个页面,一行代码:
jsfetch('https://api.qianduandaren.com/user/profile', { credentials: 'include' })
.then(r => r.json())
.then(console.log); // 用户的完整资料,出来了出来了。
因为我写的是 谁来就让谁。他的站来了,我的服务器就客客气气地回了一句「你的域名,我允许」,还附赠 Allow-Credentials: true——如果那份登录 cookie 在这个跨站场景下发得出去,它就跟着一起过去了。
这里得补一句,不然就说得太满:跨站带 cookie 还有一道闸,叫 SameSite。
而这道闸有个特别容易搞错的地方——它拦的是"跨站",不是"跨源",这俩不是一回事。
code 跨源? 跨站?
https://www.qianduandaren.com
→ https://api.qianduandaren.com ✅ 是 ❌ 不是(同站)
→ http://www.qianduandaren.com ✅ 是 ✅ 是(协议不同)
→ https://evil-site.com ✅ 是 ✅ 是同源比的是「协议 + 主机 + 端口」,同站比的是「协议 + 可注册域名」(也就是 qianduandaren.com 这一层)。所以 www 和 api 是跨源、但同站。
这意味着两件事:
第一,在上面这种跨站 fetch 场景里,SameSite=Lax 通常不会带上 cookie。 evil-site.com 用脚本发起的跨站请求拿不到你的登录态,上面那个攻击就成不了。真正会中招的,是会话 cookie 显式配了 SameSite=None; Secure 的场景——iframe 嵌入、第三方组件这类真跨站上下文才需要这么配。普通的跨子域调用不需要,别搞混了。(而且 SameSite=None; Secure 也只是必要条件,浏览器的第三方 cookie 策略还可能再拦一道。)
⚠️ 但 Lax 不是完整的 CSRF 防护。它确实挡掉了不少东西(跨站的子资源请求、跨站 POST 都带不上 cookie),可它有个明摆着的口子:跨站的顶层导航 + 安全方法(也就是 GET),cookie 照样发。
也就是说,恶意网站只要把用户"导航"到你一个有副作用的 GET 地址上——比如 <a href="https://api.qianduandaren.com/pay">领红包</a>——用户的 cookie 会跟着过去,钱就扣了。
这正是"写操作绝对不能用 GET"最硬的理由。 后面那节我还会再说一次。
第二,也是更要命的:SameSite=Lax 挡不住同站的恶意页面。 如果你有一个子域被人接管了(过期的测试域名、没回收的 CDN 子域、第三方托管的活动页),它跟你的主站是同站——SameSite 这道闸对它不起作用。
不过这里也别说得太满,中间还有几个条件:
code子域上的恶意页面要拿到数据,需要同时满足:
1. 它显式写了 credentials: 'include'
(跨源 fetch 默认不带 cookie,得主动要)
2. 那个 cookie 的 Domain / Path / Secure 作用域正好覆盖到目标 API
3. 你的 CORS 允许了它的 Origin ← 这一步是你能控制的注意第 2 条的含义:被接管的子域并不能直接"读到"你的 cookie,它能做的是发一个请求,让浏览器自己把匹配的 cookie 附上去。
所以准确的说法是:Origin 反射不是"一定会泄露",是"把本来该由你把关的那道闸拆了"。 一旦哪天有人为别的需求把 cookie 改成 SameSite=None,或者某个子域失守,洞就自己开了——而且没人会想到去看 CORS 配置。
⚠️ 两个都别当成万能的:
别拿
SameSite当 CORS 白名单的替代品——它挡外站,不挡同站。也别以为配好 CORS 白名单就万事大吉。它拦的是"恶意脚本读不读得到响应",拦不住请求本身被执行(简单请求尤其)。敏感的写操作,该上的 CSRF Token、精确的 Origin 校验、正常的鉴权、幂等键,一个都不能省。
还有个坑:如果你在服务端用
*.qianduandaren.com这种通配规则去匹配 Origin,子域被接管照样绕过去。要列具体域名。(顺带澄清一个常见误会:
*.qianduandaren.com只能是你服务端代码里的匹配规则,不能直接当Access-Control-Allow-Origin的值返回。这个响应头只认三种东西——一个具体的 Origin、*、或者null,不支持半通配的域名。)
这一节和后面那节「CORS 不是用来保护接口的」是同一个意思——CORS 是一层,不是全部。
这就是它最阴的地方:配错了不会报错。 它安安静静地工作,看起来一切正常,只是保护已经被你亲手拆了,就等一个触发条件。
正确的写法核心就一件事:先查名单。
先说结论:生产环境别自己手搓,直接上 cors 这个包。
jsconst cors = require('cors');
const ALLOWED_ORIGINS = new Set([
'https://www.qianduandaren.com',
'https://admin.qianduandaren.com',
'https://m.qianduandaren.com',
]);
app.use(cors({
origin(origin, callback) {
callback(null, Boolean(origin && ALLOWED_ORIGINS.has(origin)));
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 7200,
optionsSuccessStatus: 204,
}));
app.use(auth); // CORS 一定要排在 auth 前面,理由见蠢事六
app.use(routes);但我还是想把手搓版摆出来,因为只有自己写一遍,才知道这里面有多少个能写漏的地方。
我第一版是这么写的(叫 corsMiddleware,别跟上面 require('cors') 那个重名了,同一个文件里会直接报 Identifier 'cors' has already been declared):
jsfunction corsMiddleware(req, res, next) {
const origin = req.headers.origin;
res.header('Vary', 'Origin');
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
}看着挺对。跑一下:
code预检状态码 : 204
Allow-Origin : https://www.qianduandaren.com
Allow-Methods : ❌ 无
Allow-Headers : ❌ 无
→ 预检不通过,真请求根本发不出去注意状态码是 204、Allow-Origin 也对——看起来一点毛病没有。但浏览器还是会拦,因为它要的东西没给全。
一个专门用来修 CORS 的配置,自己没通过 CORS 检查。
这里我一开始还讲错了原因,顺便纠正一下——预检并不是"只看 Allow-Methods 和 Allow-Headers、不看 Allow-Origin"。
预检的检查顺序是这样的:
code1. 先做一次标准的 CORS 检查
→ Allow-Origin 对不对?
→ 凭证模式是 include 的话,Allow-Credentials 是不是 true?
2. 再看方法
→ 请求方法在 Allow-Methods 里吗?
→ GET / HEAD / POST 属于「CORS 安全列出的方法」,本来就放行,不用列
3. 最后看请求头
→ 那些非安全列出的请求头,都在 Allow-Headers 里吗?(第 2 步这个"安全"是 CORS 语境下的专有说法,叫 CORS-safelisted method,别跟 HTTP 里的"安全方法"混了——HTTP 语义里 POST 可不安全,它是会改数据的。)
所以我那版真正挂掉的原因是第 3 步:发的是 POST + application/json,POST 在 CORS 里属于安全列出的方法,就算不写 Allow-Methods 也过得去;是 Content-Type 没被 Allow-Headers 允许,才卡在这里的。
顺带说清 Allow-Headers: * 这个通配符——它的适用范围比你以为的窄得多。
code非凭证请求(credentials 不是 include)
→ * 确实能当通配符用
→ 但 Authorization 是个例外,规范把它单列了,* 盖不住
凭证请求(credentials: 'include')
→ * 压根不当通配符用,会被当成字面上那个星号
→ 所有「非安全列出」的请求头都得老老实实写出来注意第二种情况的措辞:要写的是非安全列出的那些。像 Accept、Accept-Language、符合值限制的 Content-Type,本来就在安全列表里,任何模式下都不用写进 Allow-Headers。
另外浏览器实现之间还有差异——出于兼容历史代码,某些实现在非凭证模式下会放行被 * 覆盖的 Authorization,切到 include 就拦了。同一份配置,换个模式、换个浏览器,行为可能不一样。
所以别赌。把你实际用到的非安全列出请求头(Content-Type 发 JSON 时、Authorization、你那些 X- 开头的)全部显式写出来,是在所有情况下都成立的做法。
除了写漏,手搓版还有两个我自己没意识到的问题,实测才发现:
问题一:res.header('Vary', 'Origin') 会把上游的 Vary 覆盖掉。
coderes.header('Vary','Origin') → Vary: Origin ❌ Accept-Encoding 没了
res.vary('Origin') → Vary: Accept-Encoding, Origin ✅如果你前面有压缩中间件设过 Vary: Accept-Encoding,被这一行抹掉之后,CDN 可能把 gzip 的响应发给不支持 gzip 的客户端。Express 里要用 res.vary(),它是追加。
问题二:if (req.method === 'OPTIONS') 会把所有 OPTIONS 都劫持了,包括那些跟 CORS 毫无关系的普通 OPTIONS 请求(有些客户端会用它探测接口支持哪些方法)。真正的预检必须同时带 Origin 和 Access-Control-Request-Method,得判断一下。
所以手搓版的完整形态是这样:
jsfunction corsMiddleware(req, res, next) {
const origin = req.headers.origin;
res.vary('Origin'); // 用 vary() 追加,不是 header() 覆盖
if (origin && ALLOWED_ORIGINS.has(origin)) { // ← 关键:先查名单
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
// 只拦真正的预检,别把普通 OPTIONS 也劫了
const isPreflight = req.method === 'OPTIONS'
&& origin
&& req.headers['access-control-request-method'];
if (isPreflight) {
if (ALLOWED_ORIGINS.has(origin)) {
res.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE');
// Authorization 显式写出来,别指望 * 盖住
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.header('Access-Control-Max-Age', '7200');
}
return res.sendStatus(204);
}
next();
}
app.use(corsMiddleware);数一下:查名单、vary 而不是 header、区分真假预检、Allow-Headers 要写全、Authorization 不能靠通配符——五个点,我第一版错了三个。
这就是为什么我最后还是建议你用 cors 包。上面这段是拿来理解的,不是拿来抄进生产的。
打个比方
这就像小区门口的访客登记。
正确做法:保安手里有一份业主名单,你报个名字,他对一下,才放行。
我当年的做法:保安不看名单,你说你是谁,他就在登记本上抄一遍你说的名字,然后放你进去。
登记本填得工工整整,流程一步没少。就是一点用没有。
💡 这条是全篇最要命的
其他几条最多让你多熬一晚。这条会让你把用户数据送出去,而且在出事之前完全没有任何征兆。
一个判断标准:你的接口带凭证吗?带的话,CORS 配置里有没有一份"名单"? 如果只有"来者不拒",那你现在就该去看一眼代码。
别走极端:不是所有接口都需要白名单。公开的、本来就欢迎任何人读取的资源(开放数据接口、公共 CDN 上的 JSON),大大方方用 Access-Control-Allow-Origin: * 完全合理,也更省事。
那什么时候能用 *?两个条件同时成立:
code1. 这个响应的内容,交给任意一个网站的脚本读,你都不心疼
2. 请求的凭证模式不是 include(否则协议上就不允许 *)⚠️ 注意第 1 条不能省。"不带 cookie" ≠ "可以给所有人看"。
内网接口、URL 里带临时密钥的数据、纯 Bearer Token 的业务接口——这些用 * 在协议上都跑得通,但你等于宣布了"任何网页来源都可以读我的响应"。
这里得说准一点,别吓唬人:* 不会凭空把 token 交给谁。 恶意网站得先自己拿到那个 Bearer Token 才谈得上读数据,而合法站点存在内存或 localStorage 里的 token,别的源默认是读不到的。
所以对 Bearer Token 接口来说,* 的真实含义是:任何已经持有 token 的网页来源,都能读到响应。 要不要再收一道"只允许我指定的几个前端",那是业务和安全策略的选择,不是 CORS 协议逼你做的。
能跑通不等于该这么配——但也别把"该不该"说成"会出事"。
蠢事四:为了躲预检,把 JSON 改成 text/plain
📌 先看个场景
预检老是不过,后端一时半会儿改不了。我在某篇文章里学到一招"绝活":
预检只有在特定条件下才会触发。那我把请求伪装成不需要预检的样子不就完了?
js// 我当年的"骚操作"
fetch('https://api.qianduandaren.com/pay', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // ← 骗过预检
body: JSON.stringify({ amount: 100 }) // 内容还是 JSON,后端自己 parse
});真的有用。OPTIONS 请求不见了,业务跑通了。我当时还挺得意,觉得掌握了什么内幕知识。
但问题来了。
它"有用"的方式,跟我以为的完全不一样。
我以为:躲过预检 = 绕过了检查 = 请求安全通过了。
实际是:躲过预检 = 请求直接就发出去了,服务器也真的执行了,浏览器只是不让我读那个响应而已。
这个区别有多大?我做了个实验。
停一下,这是本文最重要的部分
我起了个服务,/pay 这个接口会扣 10 块钱,初始 balance 100。故意不配任何 CORS 响应头。然后模拟两种跨域请求打过去。
场景 1:简单请求(GET,或者上面那个 text/plain 的 POST)
code服务端返回: 200 {"ok":true,"balance":90}
响应里有 Access-Control-Allow-Origin 吗? ❌ 没有
→ 浏览器会在 Console 里报 CORS 错误,JS 一个字都读不到
→ 但服务端 balance 已经变成: 90场景 2:预检请求(JSON,或者带 Authorization 头)
code预检响应头:
Allow-Origin : https://www.qianduandaren.com
Allow-Headers: ❌ 无 ← 少了这个,预检不通过
→ 浏览器不会发出真正的 POST
→ balance 保持: 90(这次服务端确实没执行)服务端收到的全部请求:
codeGET /pay Origin=https://evil-site.com ← 执行了,扣了钱
OPTIONS /pay Origin=https://www.qianduandaren.com ← 只有这一条,真请求没来把两种情况并排放,才看得出问题有多大:
code 简单请求 预检失败
────────── ──────────
真实业务请求发了吗? ✅ 发了 ❌ 没发
(只发了 OPTIONS)
服务端执行了吗? ✅ 扣钱了 ❌ 没执行
后端日志有吗? 有完整记录 只有一条 OPTIONS
你的 JS 收到什么? 都是一个分不出来的网络错误
(Chrome 下都是 TypeError: Failed to fetch)最后一行是关键:你代码里 catch 到的东西,两种情况下没法区分。
拿到的就是一个 TypeError,没有状态码,没有响应体,也没有失败原因。你的错误处理逻辑对着它什么也判断不出来。
(错误的具体文案各浏览器不一样,规范只保证网络错误会以 TypeError 拒绝,没规定 message 写什么。Chrome 常见的是 Failed to fetch,Safari、Firefox 措辞不同。但共同点是一样的:信息量为零。)
(DevTools 控制台里倒是会给出不同的说明——一种是"没有 Allow-Origin 头",一种是"预检没通过"。但那是给人看的,你的代码拿不到。)
所以当你在代码里 catch 到 CORS 失败的时候,你其实不知道自己在哪种情况里:
- 可能是请求压根没出门,接口白白背锅
- 也可能是接口已经跑完了,钱已经扣了,只是你看不到结果
我以前默认是前一种。所以每次报 CORS,我都直接去改 header,从来没想过要先看看数据是不是已经变了。
而我用 text/plain 那招"绕过"的,恰恰是后一种——每一次"失败"的请求,服务端都真真实实地执行了一遍。如果那个接口不幂等,重试几次就是几笔。
打个比方
这就像快递。
简单请求:快递已经送到楼下了,签收单也签了,货已经进了仓库。门卫看了眼寄件人不在白名单,不让你把货领上楼。你以为"快递没到",其实它到了,只是你没拿到。
预检请求:快递员出发前先打了个电话问"能送吗",门卫说不行,快递员掉头就走了。这次是真没送。
两次你收到的通知都是"配送失败"。但一次货在仓库里,一次货还在快递站。
💡 所以以后看到 CORS 报错,先去 Network 面板看一眼
别在 Console 里猜,去 Network 里看真实请求发出去没有:
code只有一条失败的 OPTIONS,没有真实请求
→ 真请求没发出去,服务端没执行
Network 里已经出现了那条真实请求(哪怕它标红)
→ 服务端很可能已经执行了,去查数据⚠️ 有个陷阱:没看到 OPTIONS,不代表这是简单请求。
预检结果是会被浏览器缓存的(就是 Access-Control-Max-Age 那个),缓存期内后续请求不会再发 OPTIONS。所以你可能看到的是"一条真实请求 + 没有 OPTIONS",但它其实是预检请求,只是预检早就过了。
判断依据是"那条真实请求在不在 Network 里",不是"有没有 OPTIONS"。
不过 Network 面板也只能算线索,不能算铁证——请求出现在那里,只说明浏览器尝试发了;DNS、TLS、代理、Service Worker、连接中断,任何一环都可能让它没真正走到你的业务逻辑。
最终证据永远在服务端:日志、链路追踪,或者直接去看数据变没变。Network 面板告诉你"该去哪查",不告诉你"结论是什么"。
至于什么算"简单请求",日常 fetch 场景可以先粗略这么判断:
code方法是 GET / HEAD / POST
如果你手动设了 Content-Type,它的媒体类型只能是这三个之一:
text/plain
application/x-www-form-urlencoded
multipart/form-data
(没设的话不用管这条 —— 一个光秃秃的 GET 本来就没有 Content-Type)
且你手动设的请求头,都在「CORS 安全列出」的那几个里那几个"安全列出"的请求头是:Accept、Accept-Language、Content-Language、Content-Type(值受上面限制)、还有格式受限的单段 Range。除此之外你加的任何头,都会触发预检。
⚠️ 这里有个措辞坑:Authorization 不是"自定义头",它是个标准头,只是不在安全列表里。 所以别以为"我用的都是标准头就没事"——判断依据是在不在安全列表里,不是标不标准。
(完整规则还牵扯到 XHR 的 upload 监听、ReadableStream 请求体等边角情况,日常用不太上,MDN 的 CORS 文档里有全的。)
一旦你发 JSON、或者手动加了 Authorization 头,就进预检了。
这也解释了那句最常见的吐槽:「本来好好的,我一加登录态就跨域了。」
但这句话得说准一点,不然容易误判:
code手动加 Authorization: Bearer xxx → ✅ 触发预检(它是非安全请求头)
只加 credentials: 'include' 带 cookie → ❌ 不触发预检
(简单 GET + cookie 依然是简单请求,
但服务端必须回 Allow-Credentials,且 Allow-Origin 不能是 *)带 cookie 和触发预检是两件事,别混。 你没把 CORS 搞坏,你只是从"简单请求"跨进了"预检请求",触发了一套原来没走过的流程。
🛑 插一句更重要的:CORS 不是用来保护接口的
写到这我得停一下,因为上面那个"扣钱"的例子很容易让人得出一个危险的结论:"我把 CORS 配好,接口就安全了。"
完全不是。
CORS 决定的是"跨源响应能不能共享给这个页面"。 简单请求下,它管的是"读得到读不到";预检请求下,预检没过还会拦住真实请求不让它发出去。
但不管哪一种,它都不决定"这个请求该不该被执行"。
看我实测的这一条:
code不在白名单的正常请求 状态 200 Allow-Origin: 无服务端照样返回了 200,照样把活干了。CORS 中间件只是没给它发放行条而已。
推到底就是:
- CORS 挡不住 curl、Postman、后端程序、爬虫。 它们根本不执行同源策略,
Origin头想伪造就伪造。 - CORS 不是身份认证。 谁能调用你的接口,得靠 token / session 去判断。
- CORS 不是权限控制。 这个用户能不能删这条数据,是你业务逻辑的事。
- CORS 不是完整的 CSRF 防护。 简单请求压根不预检就发出去了——这正是 CSRF 的经典攻击面。
所以我那个 /pay 示例,本身就是个反面教材:一个 GET 请求不该有扣钱这种副作用。 我拿它举例是为了把"服务端已经执行了"这件事演示得够刺眼,但真实项目里千万别这么写。
真正该配套上的是这些:
code写操作 → 用 POST / PUT / DELETE,别用 GET
CSRF → CSRF Token 打底,加上精确的 Origin 校验
Cookie → SameSite=Lax 起步,除非确有跨站需求
重复 → 幂等键,别让重试变成重复扣款⚠️ 如果你打算用 Sec-Fetch-Site 这类 Fetch Metadata 来做校验,注意一个坑:子域被接管时,它的值恰好是 same-site。 你的策略要是默认信任 same-site,这道防线等于没有。真要防这个,就得把 same-site 也当成不可信来源,再配上精确的 Origin 白名单。
💡 一句话记住:CORS 是浏览器替用户做的一道信息隔离,不是你服务器的门锁。 门锁得你自己装。
蠢事五:加上 mode: 'no-cors',然后以为好了
📌 先看个场景
翻文档看到 fetch 有个 mode 参数,还有个值叫 no-cors。
no-cors——不要 cors。这不就是我要的吗?
jsfetch('https://api.qianduandaren.com/user', { mode: 'no-cors' })
.then(r => {
console.log(r.status); // 0
return r.json(); // 💥 报错
});红字确实没了。我高兴了大概三十秒。
但问题来了。 r.status 是 0,r.json() 直接抛错,headers 全是空的。
no-cors 不是"关掉 CORS",是"我不需要读这个响应"。浏览器会给你一个 opaque(不透明)响应——一个你完全读不了的壳子。
它是给 Service Worker 缓存静态资源、或者发那种不看结果的埋点用的。用它来取数据,等于把请求发出去然后把眼睛蒙上。
打个比方
这就像收到催款短信,你把短信通知关了。
提示音是没了。
账单一分没少。
💡 一句话
no-cors 只在"我压根不看响应"的场景下有意义。你要是需要 res.json(),它就一定不是你要的东西。
蠢事六:错误响应忘了带 CORS 头,害我查了一下午假 bug
📌 先看个场景
我们的中间件是这么排的,看着特别正常:
jsapp.use(auth); // 没登录直接打回
app.use(corsMiddleware); // CORS
app.use(routes);先验身份再干活,多合理。正常请求一切都好。
直到有天线上报了个 CORS 错误。
我查了一下午。CORS 配置从头到尾看了五遍,白名单在、方法在、头也在,本地怎么都复现不出来。
但问题来了。 那压根不是 CORS 的问题。
是用户的 token 过期了,鉴权中间件直接把响应吐了出去——根本没走到 CORS 那一行:
js// auth 中间件里
if (!req.headers.authorization) {
return res.status(401).json({ message: 'Unauthorized' });
}⚠️ 顺带说个我踩过的低级错误:res.status(401) 单独用是不会发送响应的,它只设了个状态码。我实测过,请求会一直挂着直到超时:
coderes.status(401) → ⏳ 3 秒没有任何响应,请求挂住了
res.status(401).json({ ... }) → ✅ 401 正常返回
res.sendStatus(401) → ✅ 401 正常返回我实测了一下这个组合:
codeGET /me(无 token)
状态 401 | Allow-Origin: ❌ 无浏览器一看这个响应没有 Access-Control-Allow-Origin,就把它拦了,然后在控制台报了个 CORS 错误。
真正的错误是 401 未登录。用户看到的是跨域失败,我查的是跨域配置。而正确答案在后端日志里躺着。
任何一个"提前 return"的中间件都会造成这个后果:鉴权、限流、body 解析失败、参数校验。它们越早 return,就越可能绕过你的 CORS。
修法就一条:CORS 要排在所有"可能提前结束响应"的中间件前面。
jsapp.use(logger); // 日志、监控这类不会提前 return 的,放前面没问题
app.use(corsMiddleware); // ← 必须排在下面这些之前
app.use(auth); // 鉴权 —— 会提前 return 401
app.use(rateLimit); // 限流 —— 会提前 return 429
app.use(routes);
app.use(errorHandler); // 错误处理放最后(Express 的错误中间件必须是 4 个参数、且注册在路由之后)注意不是"排在最最前面"——日志、埋点、监控这些不会提前结束响应的基础中间件,放在 CORS 之前完全没问题。关键是别让任何会自己吐响应的东西跑在 CORS 前头。
改完再测一遍:
code预检 OPTIONS 状态 204 | Allow-Origin: https://www.qianduandaren.com ✅
GET /me(401) 状态 401 | Allow-Origin: https://www.qianduandaren.com ✅ 错误能如实报出来了
GET /me(有 token) 状态 200 | Allow-Origin: https://www.qianduandaren.com ✅
GET /me(不在白名单) 状态 200 | Allow-Origin: 无最后一行值得单独说一句,因为它很容易被误读:
不在白名单的那次,服务端照样返回了 200,照样把活干了。 CORS 中间件做的只是"不发放行条"——浏览器因此不会把响应交给那个页面,但请求本身早就执行完了。
这正好接上下一节要说的事。
打个比方
这就像有人给你寄了封信,说明你家水管为什么爆了。
但信封上没贴邮票,被邮局退了。
你只知道"没收到信",完全不知道水管的事。
💡 这条特别值得转给后端
「跨源页面会收到的响应,都得带 CORS 头——包括 4xx 和 5xx。」
注意不是"全站所有响应都得带",是那些会被允许的跨源页面拿到的响应。同源请求、内部调用不需要。
不然你们前后端会一起浪费一个下午,去查一个根本不存在的跨域问题。
蠢事七:* 和 credentials 一起上
📌 先看个场景
最后这个,几乎每个人都会撞一次。
要带 cookie,那就:
jsfetch(url, { credentials: 'include' });后端:
jsres.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Credentials', 'true');看着挺全乎的,对吧? 允许所有来源,也允许带凭证,两个都开了。
但问题来了。 浏览器直接翻脸:带凭证的请求,Access-Control-Allow-Origin 不能是 *。
不是"不推荐",是根本不允许,没有商量余地。
道理其实很朴素:* 的意思是"我不在乎你是谁"。而 cookie 的意思是"这是某个具体用户的身份"。这两句话放一起是自相矛盾的——你既然要处理具体用户的身份,就必须说清楚你信任的到底是哪个站。
解法就是蠢事三里那段白名单代码。回具体的 Origin,别回 *。
这里有个我一直搞混的地方,说清楚: 决定"能不能用 *"的,是请求的凭证模式(credentials: 'include'),不是"你有没有带 Authorization 头"。
codecredentials: 'include'(带 cookie)
→ Allow-Origin 必须是具体域名,不能是 *
→ 还必须回 Allow-Credentials: true
手动加 Authorization: Bearer xxx,但 credentials 不是 include
→ Allow-Origin 用 * 依然可以
→ 但 Authorization 要写进 Allow-Headers。
别用 * —— 非凭证模式下 * 虽然是通配符,
但 Authorization 被规范单独排除在外了我以前一律记成"带 token 就不能用 *",结果在一个纯 Bearer token、不带 cookie 的项目上折腾了半天白名单。
事后想想,从"CORS 协议能不能过"这个角度,那份白名单确实不是必需的——我当时是在解一个不存在的问题。至于要不要保留它,那是另一个问题:如果业务上就只允许自家那几个前端访问,留着也有意义。只是别像我那样,以为不加就跑不通。
打个比方
* 是在门口贴一张"欢迎光临,随便进"。
credentials 是拿着某位业主的门禁卡进门。
保安不可能一边说"我不管你是谁",一边接受一张写着具体名字的卡。这两件事没法同时成立。
💡 顺带一个小优化
预检是要花一次往返的。如果你的接口调用频繁,加一行:
jsres.header('Access-Control-Max-Age', '7200'); // 缓存预检结果浏览器就会把预检结果记久一点。注意 Chromium 实现上把上限设成了 7200 秒(2 小时),你写 86400 它也只认 2 小时。
这里也纠正一个我以前的误解:不设 Max-Age 并不等于每个请求都要预检一次。 规范给的默认值是 5 秒——也就是说不设的话,预检结果也会被缓存 5 秒,只是短得几乎等于没有。
对于高频调用的接口,从 5 秒提到 2 小时,省下的往返相当可观。
七件蠢事背后,是同一个误会
咱们回头看这七件——往请求里塞响应头、关掉浏览器安全检查、来者不拒地反射 Origin、伪装 Content-Type、用 no-cors 蒙眼睛、错误响应漏了头、* 配 credentials——
表面上五花八门,骨子里是同一个误会:
我一直以为浏览器在挡我。
所以我干的所有事,本质上都是"想办法把它绕过去"。装插件、关检查、伪装请求头——全是在跟浏览器较劲。
但浏览器根本不是在挡我。
真正在起作用的是两样东西,我以前把它们混成了一个:
一是同源策略。 这是浏览器的默认规矩,为的是保护坐在屏幕前的那个人——你的浏览器里存着他的 cookie、token、登录态,默认情况下,A 站的脚本别想读到 B 站的响应。
二是 CORS。 它不是限制,是放行。它是 API 的所有者对浏览器说的一句话:
「来自这几个 Origin 的脚本,我允许它访问我。」
具体管两件事:简单请求下,管的是"响应交不交给这个页面";预检请求下,还管"那个真实请求发不发得出去"。
所以那行红字的完整含义其实是——
浏览器:这个页面不能访问那个响应,因为对方没说它可以。
注意主语。是 API 所有者授的权,不是用户授的权,更不是身份认证或者业务鉴权。 这三件事我以前是分不清的。
我觉得挺奇怪的是,几乎所有教程都从 header 的语法讲起,很少有人先讲这个 header 到底是谁对谁说的。
想通这一层之后,最大的变化不是我会配了,而是——我不再把那行红字当成敌人。
但也别把它当成万能的诊断书。它能告诉你的只有一句:浏览器没法把这个响应安全地交给页面。 至于为什么——可能是 CORS 配错了,也可能是被 CORS 遮住的 401、500、一次重定向,甚至就是网络挂了。红字只是最外面那一层。
至于我为什么会栽在第三件蠢事上,现在也说得通了:我当时以为自己在配一个"更灵活的白名单"。实际上我是在替 API 说一句它不该说的话——
「谁来都行,随便读。」
而我说这话的时候,压根不知道自己在授权。
提交前的 10 秒自检清单
下次改 CORS,或者 review 别人的 CORS 配置,把这份清单过一遍:
- 带凭证的接口,有名单吗:
Allow-Origin是查白名单返回的,还是把 Origin 原样反射的?(公开无凭证的接口用*没问题) *和凭证撞了吗:只要请求是credentials: 'include',Allow-Origin就不能是*Vary是追加还是覆盖:Express 里用res.vary('Origin'),别用res.header('Vary', ...),后者会吃掉上游的Accept-Encoding- 预检处理对了吗:只拦带
Origin+Access-Control-Request-Method的真预检,别把普通OPTIONS也劫了 - 实际用到的「非安全列出」请求头都列了吗:发 JSON 时的
Content-Type、Authorization、你那些X-开头的。别依赖*——它只在非凭证请求里当通配符,而Authorization连那时候都不被它覆盖(Accept这类安全列出的头不用写) - CORS 排在会提前 return 的中间件前面吗:鉴权、限流、参数校验只要排在 CORS 前面,它吐出的 4xx 就不带 CORS 头,真实错误会被伪装成跨域(日志、监控这类不提前响应的可以放更前面)
- 重定向会不会坑到预检:
OPTIONS打到一个会跳转的地址(少个斜杠、http 转 https),基本必挂;真实请求被跨源重定向,各浏览器行为还不太一样 - 中间层动手脚了吗:nginx / 网关 / CDN 有没有把你的头改掉或删掉
Max-Age设了吗:默认只缓存 5 秒,高频接口设到 7200 能省掉大量往返- 别把 CORS 当门锁:配好了也挡不住 curl 和后端程序。写操作别用 GET,该上的 CSRF Token、SameSite、幂等键一个都不能少
第 1 条、第 10 条最值钱:前者防数据泄露,后者防你把安全感建立在一个根本不管这事的机制上。
排查时还有一条:Network 里那条真实请求在不在? 在,就说明服务端很可能已经执行了,去数据库看一眼。(别只看有没有 OPTIONS——预检结果是会被缓存的。)
💬 说说你的 CORS 血泪史
我到现在也没变成什么圣人。临时项目里我照样往 Express 里拍一句 Access-Control-Allow-Origin: *,晚上十一点谁不想赶紧收工。
但凡是跟真实用户登录态沾边的,我现在一定会把 Network 面板打开,把预检那条从头看到尾。不再靠猜了。
三年的复制粘贴,答案一直就在 Network 面板里躺着。
所以想问问:
- 这七件蠢事,你中了几件? 有没有哪件是我没写到的?
- 你们的 CORS 配置里,有那份"白名单"吗? 还是来者不拒地反射 Origin?(看完这篇建议现在就去看一眼,真的)
- 最惨的一次,你在 CORS 上耗了多久? 最后发现真正的原因是什么?
我是阿森,专注前端技术干货分享。觉得有用的话,点个「在看」让更多前端同行看到——特别建议转给正在写 CORS 中间件的那位后端同事,第 3 条可能真能帮他挡一次事故。
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

