如果你的 API 被一个 Cloudflare Workers 包在前面,而前端在浏览器里调它时突然报 has been blocked by CORS policy: no 'access-control-allow-origin' header is present,我建议你先别往后端 CORS 配置上找原因——请求很可能压根没到你后端那层。我第一次遇到这个问题时,在后端框架里加了一堆跨域配置,重新部署三四回,报错纹丝不动。后来打开 Worker 的请求日志才反应过来:整个过程里,Workers 才是真正直面浏览器的那道门。
这个场景现在太常见了。很多人把 Cloudflare Workers 当作轻量网关、API 代理或者前端静态站点的 BFF 层来用,把真正的业务 API 藏在后面。这时候 CORS 就不再只是后端框架的配置问题,而是边缘代码要处理的头等大事。这篇东西主要写给两类人:一类是刚把接口放到 Workers 后面、被各种跨域报错折磨的初学者;另一类是已经写了几年业务、但没仔细想过边缘层 CORS 语义的开发者。我会把 Workers 场景下的 CORS 机制、能直接抄的中间件代码、反射 Origin 加 credentials 的坑,以及和 FastAPI 这类后端框架叠加时怎么收敛,一次讲清楚。
1. 同样叫 CORS,Workers 和普通后端处理的逻辑完全不一样
1.1 为什么本地能通、一上 Workers 就挂
CORS(跨域资源共享)本质上是浏览器的一套安全策略:只要页面所在的源(协议 + 域名 + 端口)和接口返回的源不一致,浏览器就会先发一个 OPTIONS 预检请求,或者直接拦截响应。注意一个关键点:CORS 是浏览器行为,不是服务器行为。你用 curl、Postman、或者后端写单元测试去调接口,永远不会看到 CORS 报错,因为根本没有浏览器参与,自然也就没有同源策略这件事。
很多人本地联调时用的代理方案,比如 Vite 的 devServer proxy、Webpack 的 devServer proxy,或者 Next.js 的 rewrites,本质上是让前端请求走同源路径,由开发服务器向后端转发,浏览器看到的是同源响应,CORS 完全不触发。一旦部署到生产环境,前端在 https://front.example.com,API 走 https://api.example.com,中间还夹着一个 Cloudflare Workers,完整的链路是:
code复制浏览器 -> Cloudflare Workers -> 后端源站
浏览器直接面对的是 Workers 的响应。Workers 里如果只是简单地把请求 fetch 转发到后端,然后把响应原样返回,那响应头里大概率什么都没有。后端就算配了 fastapi_cors、CorsMiddleware 之类的中间件,那也只在后端那一层生效,响应头要经过 Workers 再传回浏览器。如果你的 Worker 在转发时重建了 Response,顺手把后端的响应头丢掉了,那浏览器拿到的就是没有任何 CORS 头的裸响应——问题就出在边缘层。
1.2 预检请求(OPTIONS)在 Workers 里的位置
浏览器判断一个跨域请求是否需要预检,有一套规则:只要不是 GET/HEAD/POST 这些简单方法,或者请求里带了非简单头(比如 Authorization、Content-Type: application/json),就会先发一个 OPTIONS 请求问服务器"允许我这么干吗"。
这个 OPTIONS 请求在 Workers 场景下尤其容易翻车。它本质上是一个独立的请求,会进入 Worker 的 fetch 事件处理逻辑。很多 Workers 代码长这样:
javascript复制export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const response = await fetch(UPSTREAM_BASE + url.pathname, request);
return response;
}
};
如果后端的路由处理不了 OPTIONS 方法,或者返回了 404、405,又或者 Workers 层没有对 OPTIONS 做任何特殊处理,浏览器收到的预检响应不满足条件,就会直接判定整个跨域请求失败。这时候你在浏览器 Network 面板里看到的报错仍然是 No 'Access-Control-Allow-Origin' header is present,但根因可能是 OPTIONS 请求本身就没被正确响应。
所以我的第一个建议是:在 Workers 里提前拦截 OPTIONS 请求,直接返回一个轻量的 204 响应,把 CORS 头都带上。这样预检请求根本不会打到后端,少一层网络开销,也少一层不确定性。这个逻辑在下一节写进中间件,一次配置,全省心。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一份能直接上线的边缘 CORS 中间件代码
2.1 基础版:无凭证场景,直接通配符
如果你的接口不需要 Cookie、不需要带凭证的跨域请求,最省事的做法是返回 Access-Control-Allow-Origin: *,配合一个精心写的中间件。下面这份代码我自己的好几个项目都在用,基本逻辑是:先处理 OPTIONS 预检,再转发普通请求并改写响应头。
javascript复制const UPSTREAM_BASE = "https://api.origin-server.com";
function buildCorsHeaders(request, { credentials = false } = {}) {
const headers = new Headers();
if (credentials) {
const origin = request.headers.get("Origin");
if (origin) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Credentials", "true");
}
} else {
headers.set("Access-Control-Allow-Origin", "*");
}
headers.set("Vary", "Origin");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
headers.set("Access-Control-Max-Age", "86400");
return headers;
}
export default {
async fetch(request, env, ctx) {
// 预检请求:直接返回 204,不打到后端
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request, { credentials: false })
});
}
// 普通请求:转发到上游,再改写响应头
const url = new URL(request.url);
const upstreamUrl = UPSTREAM_BASE + url.pathname + url.search;
const upstreamRequest = new Request(upstreamUrl, request);
const response = await fetch(upstreamRequest);
const responseHeaders = new Headers(response.headers);
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Vary", "Origin");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
}
};
代码不复杂,但有三个细节值得说。
第一,response.body 是一个可读流,直接传给新的 Response 不会占用额外内存,这是 Workers 上比较高效的流式透传方式。不要在 Worker 里把整个响应 await response.text() 再传出去,除非你需要修改响应体内容,否则纯粹是浪费资源。
第二,new Request(upstreamUrl, request) 会把原始请求的方法、Headers、Body 都带过去,等于把浏览器发来的请求原封不动转发到上游。但如果你的 Worker 还要做鉴权、改写路径、加签名,那就得在这个 Request 上做调整,不要在 fetch 里直接裸传 request。
第三,Vary: Origin 很多人写了不知道为什么写。后面如果挂了缓存,没有这个头,CDN 可能把 Origin: https://a.com 的响应缓存下来,然后原样返回给 Origin: https://b.com,导致浏览器看到 CORS 头对不上。加上 Vary: Origin 后,CDN 会按 Origin 区分缓存条目,从机制上避免串数据。
2.2 注意别把上游状态码和响应体弄丢
改响应头最容易犯的错,是只关注 200 响应。实际场景里,后端可能回 302 重定向、400 参数错误、401 未授权、500 服务异常。你在 Workers 里重写响应时,必须把 status 和 statusText 原样透传,否则可能出现后端返回 500,浏览器却收到一个 200,导致前端逻辑判断错乱。
上面的代码里,我显式传了 response.status 和 response.statusText,就是为了避免 new Response() 默认成 200。如果你用的是 HTMLRewriter 或者需要修改响应文本,也要记得先存下原始状态码和状态文本,最后传回去。
还有一个容易被忽略的点:如果上游响应本身已经带了 CORS 头,而你的 Workers 里又 set 了一个新的,新值会覆盖旧值,没问题。但如果上游有多个同名头,set 会把所有旧值清空再写入新值。所以在我的代码里,先 new Headers(response.headers) 拿到完整头列表,再 set 覆盖 CORS 相关项,这样既保留了上游的其他响应头(比如 Set-Cookie、Cache-Control),又确保 CORS 头一定统一。
2.3 升级版:带白名单的凭证模式
无凭证场景用 * 省事,但一旦请求里带 Cookie 或使用 fetch 的 credentials: "include",浏览器会要求响应头里的 Access-Control-Allow-Origin 必须是具体来源,不能是 *,而且必须显式返回 Access-Control-Allow-Credentials: true。这里一个常见的做法是反射请求头里的 Origin,但反射有安全风险,下一节专门说。先给一个带白名单的升级版本:
javascript复制const SAFE_ORIGINS = [
"https://admin.example.com",
"https://app.example.com"
];
function isAllowedOrigin(origin) {
if (!origin) return false;
try {
const url = new URL(origin);
return SAFE_ORIGINS.includes(url.origin);
} catch {
return false;
}
}
function buildCredentialedCorsHeaders(request) {
const origin = request.headers.get("Origin");
const headers = new Headers();
if (origin && isAllowedOrigin(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Access-Control-Allow-Credentials", "true");
}
headers.set("Vary", "Origin");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
return headers;
}
在 isAllowedOrigin 里我专门做了两层校验:URL 解析后取 url.origin,再用 SAFE_ORIGINS.includes 做全等匹配。这样 https://app.example.com.evil.com 这种骗字符串包含判断的域名,https://fakeexample.com 这种长得像的域名,都会被挡在外面。凡是需要带凭证的接口,强烈建议用这种白名单模式,而不是直接反射。
3. 反射 Origin 配 credentials: true:热搜背后的大坑
3.1 浏览器规范为什么要卡死这个组合
很多人搜 CORS 解决方案时,看到"反射 Origin 加 credentials=true"这个说法,照着配完仍然报错,或者更糟——配完不报错了,但留下一个巨大的安全洞。
浏览器规范里有一条硬性要求:如果响应里有 Access-Control-Allow-Credentials: true,那么 Access-Control-Allow-Origin 不允许是 *。这是为了防止"任何来源都能带着用户凭证访问服务"这种极端情况。当你的代码写成:
javascript复制headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Credentials", "true");
浏览器会直接拒绝,表现仍然是浏览器端报 has been blocked by CORS policy: no 'access-control-allow-origin' header is present。你看到这个报错,千万别只盯着 Allow-Origin,还要检查是不是 credentials 和 * 冲突了。
那反射是什么意思?就是服务器把请求头里的 Origin 原样复制到响应头里:
javascript复制const origin = request.headers.get("Origin");
headers.set("Access-Control-Allow-Origin", origin);
放在普通后端里,这个操作意味着任何第三方网站发来的请求,浏览器都会认为它得到了授权。如果这时候你还加了 Access-Control-Allow-Credentials: true,那等于开着大门说:任何网站都可以带着用户在当前浏览器里的 Cookie 来调用你这个接口。攻击者只要在自己的页面上发一个跨域请求到你的接口,浏览器就会把目标域的 Cookie 一并带上。
这就是"反射 Origin + credentials=true"被称为配置错误的原因。你需要的不该是盲目反射,而是"在白名单内才反射"。上面第 2 节的升级版代码就是这么干的:只有来源匹配白名单,才在响应里写具体 Origin 和 credentials 头;不匹配的来源直接不写,浏览器自然拦截。
3.2 白名单校验:字符串 includes 判断要不得
我见过很多项目真把白名单写成这样:
javascript复制const allowed = SAFE_ORIGINS.includes(request.headers.get("Origin"));
这比反射好,但在某些场景下仍然有隐患。比如 SAFE_ORIGINS 里存的是 https://example.com,攻击者用一个 https://example.com.attack.com 的页面,浏览器发送的 Origin 是 https://example.com.attack.com,includes("https://example.com") 返回 true,被放行了。
正确的做法是解析 URL 后比对完整 origin,也就是 协议 + 域名 + 端口,三个部分任何一个不一致都不能放行。上面 isAllowedOrigin 里的实现就是这个逻辑。如果你有多个子域都要允许,比如 admin.example.com、app.example.com,可以把这些完整 origin 都放进白名单,或者自己实现域名后缀匹配,但一定要基于 URL 解析,不能字符串模糊匹配。
另外一个细节:Origin 可能是 null。比如某些隐私模式下的浏览器,或者通过 sandbox 属性加载的 iframe,发请求时 Origin 头是字面量 null,不是 "null" 这种字符串问题,而是值就是 null。如果你没做防御,直接把 "null" 当合法来源反射回去,等于又跳回了反射陷阱。白名单里千万别加 "null"。
3.3 Cookie 和 Authorization 在凭证请求里的差异
聊 credentials 时,很多人默认就是 Cookie。其实凭证模式还涉及 Authorization 头。浏览器对带 Authorization 头的请求,天然会触发预检,因为 Authorization 不是简单请求头;而跨域带 Cookie 的时候,就是 credentials 模式。这俩经常同时出现:你需要登录态,页面用一个 token 放 Authorization 头里,同时还需要 Cookie 维持会话。
在 Workers 里处理这个场景,我的建议是:如果上游 API 用 Authorization 头鉴权,就别同时依赖 Cookie 跨域了。因为一旦 Access-Control-Allow-Credentials: true,Access-Control-Allow-Origin 就不能用 *,必须针对来源做白名单校验。而如果只用 Bearer Token,你可以用 Access-Control-Allow-Origin: *,安全性反而更可控,因为 token 不会像 Cookie 那样被浏览器自动带上,攻击者要想拿到 token,先得过你自己的登录和授权逻辑。
4. Workers 转发 FastAPI:两层 CORS 叠加时谁说了算
4.1 两个 CORS 头同时存在时浏览器怎么处理
当你把 Workers 挂在 FastAPI 或其他后端框架前面,另一个高频场景出现了:FastAPI 自己配置了 CORSMiddleware,Workers 层又手动加了 CORS 头。这时候响应头里可能同时存在两组 CORS 头,浏览器怎么处理?
HTTP 协议里,同名响应头是允许出现多次的,浏览器端会把它们合并成逗号分隔的值。比如:
http复制Access-Control-Allow-Origin: https://a.example.com
Access-Control-Allow-Origin: https://b.example.com
合并后浏览器看到的是 https://a.example.com, https://b.example.com,这个值既不等于 https://a.example.com,也不等于 https://b.example.com,于是浏览器直接判定不匹配,报错。
更麻烦的是,如果后端 FastAPI 的 allow_origins 配的是 ["*"],同时又设了 allow_credentials=True,这个组合在 Starlette 的 CORSMiddleware 里会被特殊处理为反射当前 Origin(新版里甚至直接抛警告)。这等于你自己在两层分别做了一次反射,最终结果可能是一个连开发者也搞不清楚的混合状态。
所以核心原则是:CORS 响应头必须收敛到一层处理,不要两头都写。既然浏览器最终面向的是 Workers 的响应,那就统一在 Workers 层写。
4.2 收敛策略:只让 Workers 写 CORS 头
收敛的具体做法分两步。第一步,在后端把框架的 CORS 中间件关掉,或者只在后端面向非浏览器客户端时保留配置。如果你用的是 FastAPI:
python复制from fastapi.middleware.cors import CORSMiddleware
# 如果后端只被 Workers 代理访问,可以直接不挂 CORSMiddleware
# app.add_middleware(
# CORSMiddleware,
# allow_origins=["*"],
# allow_credentials=True,
# allow_methods=["*"],
# allow_headers=["*"],
# )
第二步,在 Workers 中间件里统一设置 CORS 头。每当上游响应本身带了 CORS 相关头,你需要在 new Headers(response.headers) 之后对它们做清零重写:
javascript复制const responseHeaders = new Headers(response.headers);
responseHeaders.delete("Access-Control-Allow-Origin");
responseHeaders.delete("Access-Control-Allow-Credentials");
// 然后重新设置自己的值
这里尤其要注意 delete 的顺序:先删掉上游的,再 set 自己的;如果先 set 再 delete,会把新值也删掉,等于白干。
后端关闭 CORS 中间件后,使用非浏览器客户端(内部定时任务、移动端 App、命令行工具)直接访问后端时不会受影响,因为非浏览器环境根本没有同源策略,也没有预检流程,CORS 头对它们来说是透明无感的。
4.3 FastAPI 里常见的误配置
FastAPI 的 CORS 误配置非常典型,跟热搜词里的提示完全对得上。最常踩到的就是 allow_origins=["*"] 配合 allow_credentials=True:
python复制app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
这个组合在 Starlette 里不会直接报错,但响应头会出现一个"特定来源"(它会反射请求的 Origin),如果你原本预期的行为是"允许所有来源带凭证",那实际会产生安全理解偏差。想要反射,就得明确写 Origin;想要全员开放,就不能要 credentials。这两者在语义上互斥。
另外一个容易踩的是 allow_headers=["*"]。在预检请求里,浏览器会发送 Access-Control-Request-Headers,值是实际请求要带的自定义头列表。如果 Workers 层的 Access-Control-Allow-Headers 没有包含这个值,浏览器会认为服务器不允许这些头,直接拦截。这是除了 Access-Control-Allow-Origin 之外第二常见的预检失败原因。
我见过有人排查了半天,最后发现前端请求里带了个 X-Trace-Id 这种内部调试头,而 Workers 中间件里的 Access-Control-Allow-Headers 只写了 Content-Type, Authorization。这种事情特别容易在前后端联调加自定义头时发生,顺手把实际会用到的头都加进去,不要想当然。
5. 排查 CORS 报错的标准动作:curl 模拟加浏览器核对
5.1 用 curl 模拟预检和正式请求
遇到 CORS 报错,第一步永远是用 curl 把完整链路跑一遍,确认响应头到底长什么样。注意 curl 不会做 CORS 校验,所以它用来检查"服务器有没有返回正确响应头",而不是用来判断"跨域是否成功"。
先测预检请求:
bash复制curl -i -X OPTIONS https://your-worker.example.com/api/data \
-H "Origin: https://front.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
看响应里有没有这几个头:
Access-Control-Allow-Origin是否是你期望的值Access-Control-Allow-Headers是否包含Access-Control-Request-Headers里列出的所有头Access-Control-Allow-Methods是否包含实际请求的方法- 状态码是否是 2xx 或 204
再测真实请求:
bash复制curl -i https://your-worker.example.com/api/data \
-H "Origin: https://front.example.com"
这一步主要看最终响应是否带 Access-Control-Allow-Origin。如果真实请求需要带凭证,还要手动加上 Authorization 头,看预检和真实响应是否都能正确返回对应头。
有一点要提醒:很多人的 Workers 在本地用 wrangler dev 调试时一切正常,部署到线上就出问题。这时候 curl 的请求地址一定要用线上地址,别在本地自嗨。线上和本地差异通常出在自定义域名、Route 规则、或者 Workers 环境变量上,这些都会影响你最终看到的响应头。
5.2 浏览器 Network 面板怎么判读
curl 确认服务器响应头没问题后,再去浏览器里看真实报错。打开 Network 面板,刷新页面,你会看到两类请求:一个是 OPTIONS(预检),一个是实际的 POST/GET。
重点看两个东西。第一,预检请求的状态码。如果 OPTIONS 返回 404、405 或者 500,说明 Workers 或者后端没有正确响应预检,先修这个。第二,实际请求的响应头。点开响应详情,看 Response Headers 里有没有 Access-Control-Allow-Origin。如果预检通过但实际请求仍然报错,多半是响应头在最终转发时丢掉了。
还有一个非常容易误判的地方:Access-Control-Allow-Origin 的值明明是对的,但浏览器仍然报 No 'Access-Control-Allow-Origin' header is present。这种情况通常是因为预检请求没有收到正确响应,而不是最终响应缺失。因为浏览器只要预检失败,后面的真实请求根本不会发出去,Network 面板里可能只有一个 OPTIONS 请求,连实际请求都没有,但控制台照样报 CORS 错误,很多新手在这里被误导,一直在等一个根本不存在的真实响应。
5.3 常见报错对照表
| 浏览器报错 / 现象 | 根因 | 排查方向 |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present |
响应头里没有这个字段 | curl 看最终响应头;确认 Workers 和后端是否有两层覆盖 |
Access-Control-Allow-Origin 值不正确 |
反射了但值不符合 Origin;或 CDN 缓存串了 | 检查是否配置 Vary: Origin;检查白名单逻辑 |
| 预检请求返回 404 / 405 | Workers 没拦截 OPTIONS,后端也不处理 OPTIONS | 在 Workers 提前对 OPTIONS 返回 204 |
Request header field xxx is not allowed by Access-Control-Allow-Headers |
Workers 的 Allow-Headers 缺少前端自定义头 | 把实际会用到的头都加进 Allow-Headers |
The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*' |
credentials: true 和 * 冲突 |
改成白名单模式,具体 Origin + credentials 组合 |
我在实际项目里维护着这么一张表,每次新同事接手 CORS 排查,直接照着第一列找第二列,再按第三列去查,效率能高出一大截。
5.4 别忽略缓存:旧预检结果是最大干扰项
最后补一个排查时要多想的维度:预检结果缓存。你可以在 Workers 里通过 Access-Control-Max-Age 控制浏览器缓存 OPTIONS 预检结果的时间,单位是秒。我上面代码里写的是 86400,也就是一天。
好处是:正常请求不会每次都先发一个 OPTIONS,网络请求数量少一半,页面平均加载速度能明显提升。坏处是:你在后端改了 CORS 配置后,浏览器可能仍然拿旧的预检结果,表现成"配置已改但浏览器依旧报错"。排查时如果遇到这种矛盾,先换个无痕窗口试,或者干脆清一下缓存,别在配置里钻牛角尖。
如果你给 Access-Control-Max-Age 设了很大的值,比如 7 天,那调试期间改完 CORS 头看不到效果会非常正常。你自己心里要有个数,要么临时把 Max-Age 调成 60,要么调试都用无痕窗口,不然会把时间浪费在"为什么改了没用"上。
我现在的习惯是,不管业务后端用什么框架,只要它挂在 Cloudflare Workers 后面,CORS 相关逻辑一律不留在业务代码里,全部收敛到 Worker 中间件。这样前端联调时只需要关心 Worker 的域名,后端同学也不用为不同环境的跨域配置发愁。整个过程中最容易被忽略的一个小点是:如果你用了 Access-Control-Max-Age 让浏览器缓存预检结果,改完 CORS 配置后记得让前端硬刷新或换个无痕窗口,不然浏览器一直拿旧的预检结果,你排查半天都看不出问题。
