1. 项目概述:CORS 问题到底卡在哪
1.1 一次真实的联调事故
先说个我自己经历过的场景。上个月给一个前端团队做接口联调,前端跑在 localhost:5173,后端服务挂在 Cloudflare Workers 上,域名是 xxx.workers.dev。前端项目里用 fetch 调接口,结果浏览器控制台刷出一整片红色报错:
Access to fetch at 'https://xxx.workers.dev/api/user' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.前端第一反应是后端代码写错了,后端第一反应是前端不该用 fetch 而该用 axios,两边来回踢了半小时皮球。最后查下来,问题出在一个非常容易被忽略的地方:Cloudflare Workers 默认不会在响应里自动附加 CORS 头。也就是说,只要你在 Worker 里没有手动设置Access-Control-Allow-Origin,任何跨域请求都会被浏览器拦截。这个行为跟传统的 Node.js 后端(比如 Express 里配好了 cors 中间件)完全不同,很多人第一次迁到 Workers 上就直接踩坑。
1.2 CORS 是什么,为什么总跟 Cloudflare Workers 过不去
CORS 全称是 Cross-Origin Resource Sharing,中文叫跨域资源共享。它的核心逻辑很简单:浏览器发起跨域请求时,会先看看目标服务器的响应头里有没有允许当前源(Origin)访问的声明。如果没有,浏览器就直接拦截响应,哪怕服务器已经把数据返回来了。
Cloudflare Workers 之所以容易出 CORS 问题,因为它是一个运行在边缘节点上的 JavaScript 运行时,你可以把它理解成一个在全球各地部署的微型服务端。它不像 Express 那样自带中间件生态,也没有 Django/Flask 那种框架级的 CORS 处理机制。你是通过fetch事件直接接管请求、自己构造响应的,所以响应头完全由你说了算——但也意味着,如果忘了加,就没有人帮你加。
更麻烦的是,Workers 通常还承担着反向代理的角色。很多人的架构是这样的:浏览器 → Cloudflare Worker → 后端服务(比如 FastAPI 或任意 HTTP API)。这时候 CORS 问题就变成了两层:一是 Worker 返回给浏览器的响应头,二是 Worker 转发请求时后端返回的响应头。两层任何一层出错,最终都会表现为浏览器里的 CORS 报错。
1.3 这个问题适合谁来读
这篇文章主要写给下面这几类人:
- 把后端服务部署在 Cloudflare Workers 上,前端在本地开发或部署在另一个域名上,遭遇了跨域拦截。
- 用 Cloudflare Workers 作为 API 网关或反向代理,转发请求给 FastAPI / Flask / 其他后端服务,但纠结 CORS 头应该配在哪一端。
- 看到
has been blocked by CORS policy这类报错就想直接上网搜“关闭跨域”但没搜到有效方案的初学者。
如果你属于以上任意一类,这篇文章会从排查思路讲起,再给出一套可以直接落到 Workers 代码里的解决方案,最后补充一些生产环境里才会遇到的细节问题。整个过程不绕弯子,都是我实际摸过的路径。
2. CORS 机制拆解:浏览器到底在拦什么
2.1 Origin、请求头与响应头的关系
要理解 CORS,得先搞清楚三个概念:请求方 Origin、目标服务器的响应头、浏览器的拦截逻辑。
浏览器在发起跨域请求时,会自动带上Origin请求头,标明当前页面的来源,格式是协议 + 域名 + 端口。好比你去银行办业务,进门先报上你是从哪个支行来的。服务器收到请求后,如果允许这个来源访问,就要在响应头里带上Access-Control-Allow-Origin,值可以是具体的 Origin,也可以是*(通配符)。浏览器拿到响应后,会先检查响应头里有没有允许自己这个 Origin 的声明,有则放行,没有则拦截。
注意一个关键点:拦截不是发生在网络层,而是发生在浏览器解析层。也就是说,服务器其实已经返回了数据,请求也确实发出了,只不过浏览器不让 JavaScript 读取到响应内容。所以你会发现,在 DevTools 的 Network 面板里,请求状态甚至可能是 200,但控制台照样报 CORS 错误。这个现象是排查时最容易迷惑人的地方。
2.2 简单请求与预检请求(OPTIONS)
CORS 把跨域请求分为两类:简单请求和非简单请求。简单请求指满足以下条件的请求:方法只能是 GET、POST 或 HEAD,Content-Type 只能是application/x-www-form-urlencoded、multipart/form-data或text/plain,且没有自定义请求头。
如果请求不满足这些条件——比如用了application/json的 Content-Type,或者带上了自定义的Authorization头——浏览器会先发送一个 OPTIONS 请求,这叫预检请求(Preflight)。预检请求的目的是问服务器:“我接下来要发一个带 JSON 的 POST 请求,还带 Authorization 头,你允许吗?”服务器需要用Access-Control-Allow-Methods和Access-Control-Allow-Headers来回应,告诉浏览器“允许哪些方法和哪些头”。只有预检通过,浏览器才会发送真正的业务请求。
这就是为什么很多人的 Workers 代码处理了 GET 和 POST,却忽略了 OPTIONS 请求——结果预检请求直接被 Worker 当成普通请求路由了,返回的是一堆业务数据而不是 CORS 响应头,浏览器自然就拦截了。
2.3 Cloudflare Workers 的特殊身份:既是服务端又是代理
Cloudflare Workers 和其他后端服务有一个本质区别:它既可以直接响应请求(自己写业务逻辑、返回 JSON),也可以作为代理,把请求转发给上游服务器(比如你的 FastAPI 后端),再把上游的响应原样返回给浏览器。
作为服务端时,你需要自己给响应加 CORS 头;作为代理时,你不仅要保证自己返回的响应带 CORS 头,还要注意上游返回的响应头是否会被透传、以及是否会被浏览器接受。我在实际排查中遇到过一种情况:Workers 端的 CORS 头配置正确了,但上游 FastAPI 也配置了 CORS,两个头叠加在一起,浏览器反而懵了——这是后话,放到第 5 章细讲。
3. 排查思路与实操方法
3.1 先看报错再动手:三类典型报错
我在排查 CORS 问题时,一般先把报错分门别类,因为不同类型的报错对应的问题源头完全不同。
第一类是开头提到的No 'Access-Control-Allow-Origin' header is present。这个报错说明响应里压根没有任何 CORS 相关的头。通常发生在 Workers 返回响应时完全没有设置 CORS 头,或者 OPTIONS 预检请求没有被正确处理。
第二类是The 'Access-Control-Allow-Origin' header contains multiple values '*, http://localhost:5173'。这种报错一般是多层服务都加了 CORS 头,导致同一个响应头出现了两次。请求从 Worker 转发到 FastAPI,FastAPI 返回了Access-Control-Allow-Origin: *,Worker 又自己加了一个具体 Origin,响应头和在一起就变成了两个值。浏览器要求这个头只能有一个值,所以直接拒绝。
第三类是The value of the 'Access-Control-Allow-Credentials' header in the response is 'true' which must be 'true' when the request's credentials mode is 'include'——听着很绕,简单说就是当请求带着 Cookie 或者凭证信息时,服务器的Access-Control-Allow-Credentials必须设置为true,而且Access-Control-Allow-Origin不能是*,必须是具体的 Origin。很多人一上来就把Allow-Origin设成*,然后又开了Allow-Credentials: true,这俩一组合,浏览器直接报错。
3.2 用浏览器 DevTools 精准定位请求阶段
报错信息只能告诉你结果,不能告诉你原因。要定位是哪个环节出了问题,第一步是打开 DevTools 的 Network 面板,刷新页面触发跨域请求。
先看请求列表里有没有 OPTIONS 请求。如果有,点开它,查看响应头的Access-Control-Allow-*字段。如果 OPTIONS 请求的响应里没有这些头,说明是预检请求没有被正确处理。如果没有 OPTIONS 请求,说明这是一个简单请求,直接看 GET/POST 请求的响应头即可。
第二步是看实际的响应头。在请求详情里找到 Response Headers 区域,确认access-control-allow-origin这个字段是否存在。如果存在,看下值是否包含你的前端 Origin(注意端口也算在内,http://localhost:5173和http://localhost:4173是不同的 Origin)。
第三步是看请求头。在 Request Headers 区域找到Origin字段,确认浏览器实际发送的 Origin 是什么。有些情况下,前端代码里显式设置了mode: 'no-cors',导致浏览器发送的是不透明请求(opaque request),这种请求下就算服务器返回了 CORS 头,你也没法读取——这属于前端代码的问题。
3.3 用 curl 直接复现:绕开浏览器的判断
浏览器拦截是 CORS 错误的第一道防线,但也正是因为它“管得太多”,导致你没法直接判断是服务器的问题还是浏览器的问题。这时候用 curl 验证是最高效的方式。
curl -i https://xxx.workers.dev/api/user \ -H "Origin: http://localhost:5173"重点看响应头里是否包含access-control-allow-origin: http://localhost:5173。curl 不会像浏览器那样拦截响应,它会把所有响应头原封不动打印出来,所以你一眼就能看出服务器到底有没有吐出 CORS 头。
如果想模拟预检请求,可以这样:
curl -i -X OPTIONS https://xxx.workers.dev/api/user \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type, Authorization"如果这个 OPTIONS 请求的响应里没有access-control-allow-methods和access-control-allow-headers,那问题就锁定了——预检没过。
我用这个方法排查过很多次,每次都能在 5 分钟内判断出问题是在 Workers 代码还是在上游服务。尽量不要在 DevTools 里反复刷新猜测,那样效率太低。
4. Workers 端解决方案
4.1 最简方案:手动构造响应头
如果你的 Worker 只是处理简单的 API 请求,直接返回 JSON 数据,那么最直接的方式就是在fetch事件里给 Response 对象手动添加 CORS 头。
export default { async fetch(request, env, ctx) { const response = await handleRequest(request); // 给响应统一添加 CORS 头 const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; const newResponse = new Response(response.body, response); for (const [key, value] of Object.entries(corsHeaders)) { newResponse.headers.set(key, value); } return newResponse; }, };这段代码的核心逻辑是:先拿到业务逻辑生成的 Response 对象,然后new Response(response.body, response)复制一份,再往复制后的对象上设置 CORS 头。为什么要复制而不是直接修改原对象?因为有些情况下,你拿到的响应可能是由fetch()从上游服务器拿到的,直接修改它的 headers 会报错。复制一份再修改就安全得多。
需要注意的是,Access-Control-Allow-Origin: *只适用于不需要携带 Cookie 的场景。如果前端请求是credentials: 'include'模式,*会被浏览器拒绝,必须改成具体的 Origin,这个细节我在 4.3 节讲。
4.2 处理 OPTIONS 预检请求(关键)
很多人在 Workers 里写了业务逻辑,却忘了处理 OPTIONS 请求,导致预检直接打到业务代码里。如果业务代码里面对 OPTIONS 请求返回了 405 Method Not Allowed,或者返回了一个不带 CORS 头的 JSON,那前端就永远过不了预检。
正确的做法是在处理请求一开始就判断方法是否为 OPTIONS,如果是,直接返回一个 204 响应,并带上 CORS 预检所需的头:
export default { async fetch(request, env, ctx) { // 处理预检请求 if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }, }); } // 其他请求正常处理 return handleRequest(request); }, };Access-Control-Max-Age: 86400表示预检结果可以缓存 24 小时,也就是 24 小时内同样的请求不会再触发预检。这个值建议设置成 86400(即 86400 秒,一天),既不会太长导致服务端配置变更后客户端还在用旧配置,也不会太短导致频繁发预检影响性能。
回到刚才那段最简方案,你会发现一个问题:如果同时处理 OPTIONS 和普通请求,CORS 头代码会写两遍,非常啰嗦。更好的方案是把 CORS 头统一封装成一个函数,我放在 4.4 节。
4.3 动态反射 Origin 的安全底线:别跟 credentials 搭配
生产环境下,你的前端可能部署在多个域名,比如测试环境是https://test.example.com,正式环境是https://app.example.com。这时候如果写死Access-Control-Allow-Origin: *,那所有网站都能调用你的 API——可能你不在乎,但如果你的 API 涉及用户数据,这就是一个安全隐患。
所以很多人的做法是动态读取请求头里的Origin,原样反射到响应头里:
const origin = request.headers.get("Origin") || ""; const corsHeaders = { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", };这种做法有个很大的坑:反射任意 Origin 等同于允许任何网站跨域调用你的 API。恶意网站只要在自己的页面里发一个 fetch 请求,浏览器就会自动带上前端页面的 Origin,服务器把这个 Origin 反射回去,浏览器就放行了。如果你的接口不依赖 Cookie 验证,而是用 Token,那还好;如果依赖 Cookie 或者 Session,那恶意网站就可以利用用户已登录的状态发起请求,造成 CSRF 攻击。
另外一个必须遵守的底线是:反射 Origin 时绝对不能同时设置Access-Control-Allow-Credentials: true。因为浏览器规定,当Allow-Credentials: true时,Allow-Origin不能是*,必须是具体值。很多人为了既能用 Cookie 又能允许多个域名,就把Allow-Origin设置成反射的动态 Origin,同时把Allow-Credentials设成true。这看起来“很灵活”,实际上等于对任意来源放行携带凭证的请求——任何一个恶意网站都能以用户的身份调用你的接口。
如果确实需要支持带凭证的跨域请求,正确的做法是维护一个白名单:
const allowedOrigins = [ "https://app.example.com", "https://test.example.com", ]; export default { async fetch(request, env, ctx) { const origin = request.headers.get("Origin") || ""; const corsHeaders = { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; if (allowedOrigins.includes(origin)) { corsHeaders["Access-Control-Allow-Origin"] = origin; corsHeaders["Access-Control-Allow-Credentials"] = "true"; } if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } const response = await handleRequest(request); return new Response(response.body, { ...response, headers: { ...Object.fromEntries(response.headers), ...corsHeaders }, }); }, };白名单校验通过后,才把具体的 Origin 反射回去,同时允许携带凭证。白名单之外的来源,没有Allow-Origin头,浏览器会自然拦截。这个方案既支持多域名,又不会引入安全隐患,是我在生产环境里一直在用的。
注意:热搜词里提到的“cors 配置错误(反射 origin + credentials=true)”就是上述问题。很多文章示例代码里直接反射 Origin 又开启 credentials,这是网上流传最广的误导性写法,千万别照着抄。
4.4 把 CORS 处理抽成公共函数
在 Workers 里,一个 Worker 可能同时处理多个路由(用路由匹配/api/user、/api/order等)。这种情况下,如果每个路由处理函数里都手动加 CORS 头,代码会非常冗余,而且容易漏加。
我的做法是把 CORS 处理抽成一个公共函数,统一在入口处处理:
function createCorsHeaders(request, env) { const origin = request.headers.get("Origin") || ""; const allowedOrigins = ["https://app.example.com", "https://test.example.com"]; const cors = { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }; if (allowedOrigins.includes(origin)) { cors["Access-Control-Allow-Origin"] = origin; cors["Access-Control-Allow-Credentials"] = "true"; } return cors; } function handleOptions(request, env) { const cors = createCorsHeaders(request, env); return new Response(null, { status: 204, headers: cors }); } async function handleRequest(request, env) { const url = new URL(request.url); const cors = createCorsHeaders(request, env); if (request.method === "OPTIONS") { return handleOptions(request, env); } if (url.pathname.startsWith("/api/user")) { const data = { name: "test", id: 123 }; return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json", ...cors }, }); } return new Response("Not Found", { status: 404 }); } export default { async fetch(request, env, ctx) { return handleRequest(request, env); }, };这样设计的好处有几个:第一,CORS 逻辑集中在一处,改白名单只改一个函数;第二,每个业务响应只需要在构造 Response 时展开cors对象即可,不会遗漏;第三,OPTIONS 预检和普通请求共享同一套 CORS 头,保证行为一致。
如果你用 TypeScript 写 Workers,建议把createCorsHeaders的返回类型定义成Record<string, string>,这样在展开到 Response headers 时不会出现类型报错。
5. 与 FastAPI 后端配合时的双层 CORS
5.1 Worker 代理 FastAPI 时的常见坑
很多人的架构不是把整个后端逻辑写在 Workers 里,而是用 Workers 作为反向代理,转发请求到阿里云或者本地的 FastAPI 服务上。这时候就出现了双层 CORS:浏览器 → Worker(第一层响应头),Worker → FastAPI(第二层响应头)。
第一层是浏览器能直接看到的响应头,由 Worker 决定。第二层是 Worker 转发请求后,FastAPI 返回的响应头,Worker 可以选择透传,也可以选择覆盖。
最常见的坑是:FastAPI 已经配置了 CORSMiddleware,会自动给响应添加Access-Control-Allow-Origin。而 Worker 在做代理转发时,直接把上游响应原样返回给浏览器,没有覆盖或删除 FastAPI 加的 CORS 头。如果你同时又在 Worker 里自己加了Access-Control-Allow-Origin,那响应里就会出现两个Access-Control-Allow-Origin头,浏览器直接报multiple values错误。
我遇到过的最迷惑案例是:FastAPI 返回Access-Control-Allow-Origin: *,Worker 返回Access-Control-Allow-Origin: https://app.example.com,两个头前后拼接,浏览器认为响应非法,直接拦截。表面上看每个服务都配置得很正确,合在一起却变成了错误。
5.2 FastAPI 端 CORSMiddleware 的正确配置
如果你用的是 FastAPI,通常会这样配置:
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )这个配置本身没问题,但要注意:如果前面还有 Cloudflare Worker 挡着,FastAPI 其实只需要接受来自 Worker 的请求,而 Worker 的请求是没有 Origin 头的(或者说是由 Worker 自身发起的)。所以,FastAPI 在大多数情况下根本不需要配置 CORS,因为 Worker 才是它的客户端,真正需要 CORS 的是 Worker 和浏览器之间的通信。
如果 Worker 转发请求时,把浏览器的Origin头原样传递给了 FastAPI,那么 FastAPI 的 CORSMiddleware 也会生效,就会产生双层 CORS 头叠加的问题。解决办法有二:第一种,在 Worker 转发请求时把Origin头删掉,让 FastAPI 认为这是一个同源请求;第二种,在 Worker 拿到 FastAPI 的响应后,删除其中所有Access-Control-*头,再添加 Worker 自己的 CORS 头。我建议用第一种,因为更干净——FastAPI 根本无需感知 CORS 的存在。
5.3 双层 CORS 头叠加问题与解决
给大家一个我实际使用过的转发方案,里面对 CORS 头做了完整的清理和重建:
async function proxyRequest(request, env) { const url = new URL(request.url); const targetUrl = env.UPSTREAM_BASE_URL + url.pathname + url.search; const forwardHeaders = new Headers(request.headers); // 清理可能干扰 CORS 的请求头 forwardHeaders.delete("Origin"); forwardHeaders.delete("Host"); const upstreamResponse = await fetch(targetUrl, { method: request.method, headers: forwardHeaders, body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, redirect: "manual", }); // 创建新响应,剥离上游的 CORS 头 const responseHeaders = new Headers(upstreamResponse.headers); responseHeaders.delete("Access-Control-Allow-Origin"); responseHeaders.delete("Access-Control-Allow-Methods"); responseHeaders.delete("Access-Control-Allow-Headers"); responseHeaders.delete("Access-Control-Allow-Credentials"); // 重新添加 Worker 这一层的 CORS 头 const cors = createCorsHeaders(request, env); for (const [key, value] of Object.entries(cors)) { responseHeaders.set(key, value); } return new Response(upstreamResponse.body, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: responseHeaders, }); }这里有两个细节值得注意。第一,forwardHeaders.delete("Origin")能避免 FastAPI 的 CORSMiddleware 生效,从根源上防止双层 CORS。第二,把上游响应里的Access-Control-*头全部删掉再重建,能确保最终返回给浏览器的 CORS 头是唯一的、可控的。
如果你没法改 Worker 代码(比如用的是一个现成的网关),那就只能在 FastAPI 端想办法了。可以让 CORSMiddleware 的allow_origins设置为一个不会匹配到任何请求的值,比如["https://worker.invalid"],这样 CORSMiddleware 虽然存在但不会实际生效。不过这种方式比较 hack,能改 Worker 还是优先改 Worker。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
我在实际排查中积累了一些高频问题,整理成一个速查表,方便各位对照排查。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
报错 NoAccess-Control-Allow-Originheader | Worker 响应没加 CORS 头 | curl 加-H "Origin: ..."看响应头 | 在 Worker 入口统一添加 CORS 头 |
| OPTIONS 请求返回 404/405 | Worker 没处理预检 | DevTools Network 看 OPTIONS 请求状态 | 在代码中拦截 OPTIONS 返回 204 |
| 响应头出现多个 Allow-Origin 值 | Worker 和上游都加了 CORS 头 | curl 查看完整响应头 | Worker 转发时删除上游 CORS 头 |
| 携带凭据的请求被拦截 | Allow-Origin: *+Allow-Credentials: true | 检查前端是否用credentials: 'include' | 改为白名单动态 Origin + credentials |
| 修改 Worker 后浏览器还是报错 | 浏览器缓存了预检结果 | 硬刷新或等待 Max-Age 过期 | 临时把 Max-Age 设小,或者用隐私窗口测试 |
| Worker 转发后响应头丢失 | 上游响应头处理逻辑有问题 | 在 Worker 里 log 上游响应头 | 用new Headers(upstreamResponse.headers)复制 |
第 5 行特别值得多说几句。预检结果会被浏览器缓存,Access-Control-Max-Age设得越大,缓存时间越长。如果你改了 Worker 里的 CORS 配置,但浏览器还在用旧的缓存预检结果,就会造成“明明改了为什么还报错”的假象。排查时优先用无痕窗口,可以避免缓存干扰。
6.2 我踩过的一些坑和最终心得
排查 CORS 问题,本质上是在理清一条链路:浏览器 → Worker → 上游服务器。只要链路上每一个环节的响应头都在你的掌控之中,问题就不会存在。所以我最后的建议就两条:第一,CORS 头必须在最外层统一处理,不要散落在各个业务代码里;第二,转发代理时要主动清理上游的 CORS 头,保证响应头唯一。
我最初做 Workers 开发时也走过不少弯路,当时甚至想过用mode: 'no-cors'绕过跨域,结果发现响应变成不透明请求,前端根本拿不到数据,纯属南辕北辙。还有一次为了省事,把所有 CORS 头都设成*,后来上线后才发现接口可以被任何网站调用,赶紧加白名单才堵上。希望读到这里的你,不要再踩这几个坑。
最后分享一个小技巧:在开发阶段,可以在 Worker 的代码里临时加一个只对开发环境生效的宽泛 CORS 规则(Allow-Origin: *),等联调完成后再切到白名单模式。这样既不影响开发效率,又能保证生产环境的安全。切换的时候记得用无痕窗口验证,避免预检缓存干扰测试结果。