如果你是一名前端或者全栈工程师,看到 “has been blocked by cors policy: no 'access-control-allow-origin' header is present on the requested resource” 这行报错,大概率已经和浏览器纠缠半天了。很多人第一反应是搜索“CORS 怎么解决”,然后顺手在后端加上一个Access-Control-Allow-Origin: *,发现页面不报错了,就以为自己搞定了。我劝你千万别这么干,这个*号看着省事,实际是在给线上项目埋雷。
这篇博文我会从跨域原理讲起,把Access-Control-Allow-Origin的正确配置方式、使用场景、白名单思路、配套 Cookie 处理、Nginx 网关方案全部过一遍。不是讲教科书概念,而是结合我真实调试过的项目经验,告诉你什么时候该用*、什么时候绝对不能碰,以及报错信息到底在说什么。如果你是刚被 CORS 折磨过的新手,建议从头看;如果是老手,可以直接跳到第 3 章和第 5 章的速查表。
1. CORS问题不是玄学,先搞懂它到底在做什么
1.1 同源策略:浏览器为什么“多管闲事”
CORS 全称是 Cross-Origin Resource Sharing,中文叫跨域资源共享。但要说清楚它,得先从同源策略(Same-Origin Policy)讲起。
浏览器有个安全机制:默认情况下,一个网页里的 JavaScript 只能读取“同源”的数据。同源的意思是协议(https/http)、域名(example.com)、端口(8080/3000)三者完全一致。比如你的前端跑在http://localhost:8080,后端接口跑在http://localhost:8081,这俩端口不同,就是跨域,浏览器会拦截响应数据。
为什么浏览器要“多管闲事”?因为如果没有这个限制,你登录了银行网站,再去打开一个恶意网站,恶意网站的脚本就能偷偷向银行接口发请求,然后读取你的账户信息。同源策略的本质,是给“谁可以读我的数据”画了一条边界线。
但实际开发中,前后端分离早就成了主流,前端部署在 CDN 或 Nginx,后端是一组独立的 API 服务。两边域名天然不同,你不可能不让它们通信。CORS 就是浏览器开放的一条“合法跨域通道”——服务端通过响应头告诉浏览器“这个外部来源可以访问我”,浏览器校验通过后,才把数据交给你页面的 JavaScript。
1.2 跨域请求的两个阶段:简单请求与预检请求
CORS 请求分为两种。第一种是“简单请求”,条件比较苛刻:方法只能是GET、POST、HEAD,Content-Type 只允许application/x-www-form-urlencoded、multipart/form-data、text/plain,而且不能带自定义头。简单请求会直接发出,浏览器在拿到响应后检查响应头里有没有Access-Control-Allow-Origin,没有就抛错。
第二种是“预检请求”(Preflight)。只要请求带了Authorization、Content-Type: application/json这种自定义头,或者用了PUT、DELETE、PATCH方法,浏览器就会先发一个OPTIONS请求去“探路”,问服务端:我要用POST+application/json,你允不允许?服务端得通过Access-Control-Allow-Methods、Access-Control-Allow-Headers告诉它允许哪些方法和头,浏览器再决定要不要发真正的请求。
这个设计听起来严谨,实际调试时特别容易出问题。我见过很多人只设置了Access-Control-Allow-Origin,忘了处理OPTIONS请求,结果接口明明在线,前端却一堆预检报错。所以第 4 章我会专门写一个完整的OPTIONS处理示例。
1.3 谁来配置Access-Control-Allow-Origin:一定是服务端,不是前端
很多新手问我:前端能不能在 request 里加Access-Control-Allow-Origin头,把问题“堵回去”?答案是绝对不行。这个头属于响应头,只能由服务端在返回数据时携带。前端能做的,只是确认Origin头是否正确发送,以及在跨域模式下用fetch或XMLHttpRequest时把credentials设置成合适的值。
记住一个原则:跨域控制权永远在服务端,也就是说这个场地的门禁卡由后端来发。你前端再怎么改请求配置,也过不了浏览器这一关。
2. 设置成*号一时爽,后面全是坑——通配符方案的三大致命问题
2.1 安全边界失效:等于给所有网站开了数据后门
Access-Control-Allow-Origin: *的含义是,任意来源的网页都能读取这个接口的响应。对纯粹的公开信息接口——比如天气数据、公开新闻列表——这没什么问题。但凡接口涉及登录态、用户资料、订单信息、内部数据,这就是灾难。
假设你有一个接口https://api.example.com/user/info,返回当前登录用户个人资料,响应头设置成了*。那就意味着我在自己的恶意网站上部署一个页面,用你浏览器残留的 Cookie 去请求这个接口,浏览器会想:服务端已经允许所有来源了,行吧,数据放大。你的姓名、手机号、收货地址就被我这个恶意页面读走了。这不是理论推断,而是实际可以被构造的攻击场景,叫 CORS-based attack。
所以现在很多大厂的安全规范里,直接写死“内部接口不允许配置Access-Control-Allow-Origin: *”。你可以把*理解为“不设防”,只适合放公开数据,不适合放任何需要凭证的接口。
2.2 与携带凭证(Cookie)的组合直接冲突
这可能是*最坑的一点。如果你需要在跨域请求里带上 Cookie(最常见的场景是跨域单点登录、带 session id 的接口),那么服务端不能把Access-Control-Allow-Origin设成*,同时Access-Control-Allow-Credentials必须设为true。
浏览器规范规定,当请求模式是credentials: include(即携带 Cookie)时,响应里的Access-Control-Allow-Origin必须是具体的源,不能是*。如果你把*和Allow-Credentials: true一起配置,浏览器直接报错:
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'.
翻译成人话:你带凭证了,我还知道你是谁呢?*等于告诉浏览器“我谁都不认识”,但你又要求带 Cookie,规范不允许这种自相矛盾。所以你要么不带 Cookie,要么就把具体域名写出来。
我见过一个实际案例,同事开发时把*配上了,调试登录功能时一直报错,最后发现是因为浏览器 Set-Cookie 跨域失效。Node 和 Java 项目里这种问题非常多,根源都是用了通配符。
2.3 遇到自定义头、特定HTTP方法时照样报错
除了安全与凭证问题,*还有一层尴尬:有些场景下,即使你设置了Access-Control-Allow-Origin: *,请求依然报错。
比如前端要带一个自定义头X-Trace-ID,用于全链路追踪,或者用Content-Type: application/json发PUT请求。这时浏览器会先发OPTIONS预检,服务端必须在Access-Control-Allow-Headers里明确列出X-Trace-ID,在Access-Control-Allow-Methods里明确列出PUT。如果服务端只配了Allow-Origin,预检照样弹红。
也就是说,通配符*只能解决“最简单的 GET/POST 请求”的一部分问题,一旦业务稍微复杂一点,它根本兜不住。正确做法是完整配置 Allow-Origin、Allow-Methods、Allow-Headers 三件套,缺一个都可能踩坑。
3. 正确配置的三种落地方式,按场景选型
3.1 静态白名单:适合前端域名固定的常规项目
如果你的前端域名是固定的,最稳妥、最优雅的方案就是静态白名单,直接列出允许的来源。
以 Node.js 的 Express 为例,最简单的手写版本是这样的:
const allowedOrigins = ['https://admin.example.com', 'https://www.example.com']; app.use((req, res, next) => { const origin = req.headers.origin; if (allowedOrigins.includes(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'); } if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });这段代码的关键逻辑是:先判断请求的Origin是否在白名单里,在的话才把这个具体的 Origin 原样回写。这样浏览器看到的是Access-Control-Allow-Origin: https://admin.example.com,而不是*,凭证模式也能正常工作。
如果你用的是 NestJS 或 Express 的cors中间件,配置更简单:
const cors = require('cors'); const app = express(); app.use(cors({ origin: ['https://admin.example.com', 'https://www.example.com'], credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], }));Spring Boot 项目也一样,可以写一个全局的WebMvcConfigurer:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://admin.example.com", "https://www.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("Content-Type", "Authorization") .allowCredentials(true); } }注意:Spring Boot 里.allowedOrigins("*")和.allowCredentials(true)同时出现会启动报错,Spring 从 5.3 开始已经对这个做了严格限制。这正是*不可行的又一个佐证。
3.2 动态校验Origin:适合多域名、子域名场景的通用做法
有些项目不止一两个前端域名,而是有一整套子域名体系,比如user.example.com、shop.example.com、m.example.com。手写白名单数组也行,但更好的是写一个动态校验函数,用正则或后缀匹配判断Origin是否属于可信域名。
我在一个多租户项目里用的方案大致是这样:
const trustedDomains = ['.example.com', '.example.org']; function isTrustedOrigin(origin) { if (!origin) return false; try { const url = new URL(origin); return trustedDomains.some((domain) => { return url.hostname === domain.slice(1) || url.hostname.endsWith(domain); }); } catch (e) { return false; } } app.use((req, res, next) => { const origin = req.headers.origin; if (isTrustedOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Vary', 'Origin'); } if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });这里我加了一行res.setHeader('Vary', 'Origin'),很多人会忽略。它的作用是告诉缓存系统“响应的内容会因为 Origin 不同而不同”,避免 CDN 或浏览器把 A 域名拿到的响应缓存住,然后返回给 B 域名,造成数据串台的诡异 bug。当你动态返回不同Access-Control-Allow-Origin时,Vary: Origin是必须加的。
3.3 在Nginx网关统一处理:适合前后端分离的部署场景
很多项目前端是静态资源部署在 Nginx,后端 API 喝另一组地址。这种情况下,与其在每个后端服务里各写一遍 CORS 配置,不如在 Nginx 反向代理层统一处理,逻辑更集中,切换域名时只需改网关配置。
一个标准配置片段长这样:
server { listen 443 ssl; server_name api.example.com; location /api/ { set $cors_origin ""; if ($http_origin = "https://admin.example.com") { set $cors_origin $http_origin; } if ($http_origin = "https://www.example.com") { set $cors_origin $http_origin; } add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Vary Origin always; if ($request_method = OPTIONS) { return 204; } proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意always参数。Nginx 默认只在响应码为 200、201、204 等情况下才添加add_header,加上always后,即使后端返回 302、400、500 也会带上 CORS 头。否则前端请求出错时,浏览器看到的还是“No Access-Control-Allow-Origin header”,你排查半天也不知道问题在响应头还是业务逻辑。
这套方案特别适合一个 API 网关对应多个前端。前端域名有变化时,改下 Nginx 配置 reload 一下就生效,不需要重新部署后端。
4. 从报错到修复:一次完整的CORS排查实操
4.1 读取报错信息:先把浏览器给的信息翻译成人话
CORS 报错文字虽然长,但信息量其实很高。常见的几种:
CORS policy: No 'Access-Control-Allow-Origin' header is present:最常见。说明响应里根本没配置这个头,或者配置被浏览器判定无效。CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values '*, *':说明你前后端各配了一遍,或者 Nginx 和后端都加了 CORS 头,响应头重复了。CORS policy: The 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include':你用了通配符,又要求带凭证。CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response:预检通过,但带Authorization头时被拒绝,说明服务端没在Access-Control-Allow-Headers里放行。
拿到报错后,第一步,打开 DevTools 的 Network 面板,找到失败的请求,先看请求头里的Origin是什么,再看响应头里有没有Access-Control-Allow-Origin。不要直接改代码,先确认到底是哪一个头缺失,方向错了后面都是在白忙。
4.2 后端修复前后对比:三个框架的配置示例
我用一个实际场景把整套配置流程串起来。假设前端部署在https://myapp.example.com,后端 API 部署在https://api.example.com,前端需要带 Cookie 和Authorization头,主要方法是GET、POST、PUT。
前端代码里,fetch 请求要这样写:
const response = await fetch('https://api.example.com/api/user/profile', { method: 'GET', credentials: 'include', // 必须带,否则 Cookie 不会发送 headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, });后端以 Express 为例,修复后应该包含完整的三件套。我推荐直接用cors中间件,少写手写代码,也不容易漏头:
const corsOptions = { origin: function (origin, callback) { const allowedList = ['https://myapp.example.com']; if (!origin || allowedList.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 3600, }; app.use(cors(corsOptions));注意这里maxAge: 3600,意思是预检请求的响应结果可以缓存一小时。如果预检配置没问题,一小时内浏览器不会重复发送OPTIONS请求,能省不少网络开销,也降低服务端压力。
如果是 Django 后端,配置逻辑一样,但用django-cors-headers插件会更省事:
INSTALLED_APPS = [ ... 'corsheaders', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ... ] CORS_ALLOWED_ORIGINS = [ "https://myapp.example.com", ] CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_METHODS = [ "DELETE", "GET", "OPTIONS", "POST", "PUT", ] CORS_ALLOW_HEADERS = [ "content-type", "authorization", ]修复完成后,重启服务,回到浏览器 DevTools,清一下缓存,再触发请求。Network 面板里如果能看到Access-Control-Allow-Origin: https://myapp.example.com和Access-Control-Allow-Credentials: true,这个跨域链路就通了。
4.3 用curl和浏览器验证Access-Control-Allow-Origin是否生效
有时候后端修完了,浏览器还是报错,可能是因为浏览器缓存了旧的响应,也可能 CDN 层缓存了响应头。这时候用 curl 验证最直接,绕过浏览器缓存:
curl -i -X OPTIONS 'https://api.example.com/api/user/profile' \ -H 'Origin: https://myapp.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: authorization,content-type' \ -H 'Access-Control-Request-Private-Network: true'注意我在请求里加了Origin和预检相关的头,是模拟浏览器发出的 preflight。观察响应里有没有:
Access-Control-Allow-Origin: https://myapp.example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: content-type, authorization只要这些头都在,说明服务端配置没问题。再到浏览器里硬刷新一次,一般就正常了。如果 curl 头都对了但浏览器还是报错,那问题八成是缓存或代理,别把服务端代码翻来覆去改。
5. 常见问题与排查技巧实录
5.1 CORS高频报错速查表
我把日常遇到最多的报错整理成了一张表,开发时可以直接对照排查。
| 报错关键词 | 可能原因 | 解决办法 |
|---|---|---|
| No 'Access-Control-Allow-Origin' header | 服务端没配置 CORS 头,或配置被拦截 | 检查后端和 Nginx 是否真正上了响应头 |
| multiple values '*, *' | 后端与 Nginx 各配了一次 CORS | 只保留一处配置,去掉重复 add_header |
| must not be wildcard '*' with credentials mode include | 同时用了*和 credentials | 改用具体白名单,并设置 credentials=true |
| authorization is not allowed by Access-Control-Allow-Headers | 预检时没放行 Authorization 头 | 在 Allow-Headers 加入 authorization |
| method PATCH not allowed by Access-Control-Allow-Methods | 预检时没放行对应方法 | 在 Allow-Methods 加入所有需要的 HTTP 方法 |
| response to preflight request doesn't pass access control check | OPTIONS 请求未正确处理,或缺少头 | 在服务端统一响应 OPTIONS,返回 204 和 CORS 头 |
5.2 我自己踩过的几个坑
第一个坑:开发环境配好了,线上环境突然全部 CORS 报错。排查了半天,发现是 CDN 环节把响应头过滤了。国内有些云 CDN 为了“安全”,默认会剥离Access-Control-Allow-Origin头。解决方法是到 CDN 控制台配置“自定义响应头”,把 CORS 头在 CDN 层重新加上,或者把 CDN 的“过滤参数”关掉。
第二个坑:项目里用了 Spring Security,CORS 配置写在WebMvcConfigurer里,但请求被拦在 Security 过滤链里,跨域配置根本没执行。这是 Spring Boot 项目的典型问题,解决方式是实现CorsFilter并注册到 Security 的过滤器链前面。你可以在SecurityFilterChain里调用http.cors()启用 CorsFilter,而不是只写 MVC 配置。
第三个坑:预检OPTIONS请求本来不需要登录态,但我的后端拦截器把所有请求都要求校验 Token,结果 OPTIONS 直接返回 401,浏览器连预检都过不了。这个问题很隐蔽,因为前端看到的报错还是“No Access-Control-Allow-Origin header”,实际是 OPTIONS 根本没走到 CORS 头那一步。所以现在我在所有项目里都会对OPTIONS请求提前放行,并且确认响应头已经写入,再进业务拦截器。
第四个坑:跨域 Cookie 的SameSite属性。即使你把Access-Control-Allow-Origin和Access-Control-Allow-Credentials都配对了,Cookie 也可能发不出去,因为浏览器要求跨域请求下的 Cookie 必须设置SameSite=None; Secure。如果服务端返回的 Set-Cookie 里没有这两个属性,Cookie 会被浏览器默默忽略。那次排查让我意识到,CORS 问题往往不是单一原因,而是一条链路,任何一环掉链子都会表现为“跨域失败”。
最后再分享一个调试技巧:强烈建议在服务端代码里记录 CORS 相关的拦截日志,把每个请求的Origin、Method、匹配到的Allow-Origin打成一行日志。线上环境问题定位时,这个日志能帮你区分“浏览器压根没发请求”还是“服务端没返回正确头”,少走很多弯路。根据我个人的项目经验,CORS 配置错误里大概有一半可以通过日志排查法在五分钟内定位,根本不需要抓包。