浏览器跨域
1. 先看懂跨域报错
关键词:浏览器是否允许 JS 读取响应。
你启动了一个前端项目,页面跑在:
1 | http://localhost:5173 |
后端接口跑在:
1 | http://localhost:8080 |
页面里写了一句请求:
1 | fetch('http://localhost:8080/users') |
结果浏览器报跨域。
Postman 能调通,后端日志也正常,为什么浏览器里的 fetch 就不行?更别扭的是,前端和后端都在本机,端口不同就算跨域。
先记住一句话:
跨域不是网络不通。跨域是浏览器不让网页里的 JS 读取不该读的响应。
同一个接口,Postman 能调,curl 能调,后端服务也能调。只有浏览器里的 JS 可能被拦。
因为浏览器里坐着一个已经登录了很多网站的用户。你登录过 bank.com,浏览器里有银行网站的 Cookie。然后你又打开了 evil.com。如果浏览器不做限制,evil.com 里的 JS 就可以偷偷请求 bank.com:
1 | 用户打开 evil.com |
这就是同源策略存在的原因:网页从哪里来,它里面的 JS 默认只能读哪里的数据。
常见报错长这样:
1 | Access to fetch at 'http://localhost:8080/users' |
这段报错要拆成三块看:
| 报错片段 | 你要读出什么 |
|---|---|
fetch at 'http://localhost:8080/users' | JS 想请求的接口地址 |
from origin 'http://localhost:5173' | 当前页面的来源 |
No 'Access-Control-Allow-Origin' | 服务器响应里缺少浏览器要看的放行头 |
所以这不是在说「接口一定坏了」。它是在说:
1 | 浏览器问服务器: |
搞清报错含义后,下一步是看浏览器怎么定义「同一个来源」。
2. 浏览器怎么判断「同一个来源」
浏览器判断两个地址是否同源,只看三样东西:
1 | 协议 + 域名 + 端口 |
路径不算。
以这个地址为基准:
1 | https://www.example.com:443/user/list |
看几个例子:
| 比较地址 | 是否同源 | 原因 |
|---|---|---|
https://www.example.com:443/order/list | 是 | 只有路径不同 |
http://www.example.com:443/user/list | 否 | 协议不同 |
https://www.example.com:8080/user/list | 否 | 端口不同 |
https://api.example.com:443/user/list | 否 | 域名不同 |
https://example.com:443/user/list | 否 | www.example.com 和 example.com 不是同一个域名 |
https://www.example.com/user/list | 是 | HTTPS 默认端口就是 443 |
所以这两个也不同源:
1 | http://localhost:5173 |
它们都在你的电脑上,但端口不同。浏览器不按「是不是本机」判断,它按「协议、域名、端口」判断。
排查时还要分清三个头:
| 名称 | 白话解释 | 例子 |
|---|---|---|
Host | 这次请求发给谁 | api.example.com |
Origin | 发起请求的页面来自哪里 | http://localhost:5173 |
Referer | 用户是从哪个完整页面过来的 | http://localhost:5173/profile |
CORS 主要看的是 Origin,不是 Host。
同源规则之外,本地开发还有一个容易踩坑的特殊场景:file:// 页面。
3. 为什么 file:// 也会跨域
前后端分离是最常见的跨域场景。file:// 则是另一个很容易让新手误会的坑。
你会觉得:
1 | file:///Users/you/demo/index.html |
它们在同一个文件夹里,应该同源。
但现代浏览器通常不这么想。
如果浏览器说「本地文件都同源」,那你从网上下载了一个 HTML,双击打开后,它就可能尝试读取你电脑上的其他文件。这个边界太危险。
所以浏览器常把 file:// 页面当成一种特殊来源,叫不透明来源。不用背这个词,只要记住它的意思:
1 | 浏览器不给这个页面一个正常的网站身份。 |
这种页面发请求时,来源经常会表现成:
1 | Origin: null |
null 不是「没有跨域问题」。它更像是浏览器在说:这个页面不是普通的 http://域名:端口 来源,我不把它当正常网站处理。
所以本地开发不要双击 HTML。起一个本地服务:
1 | python3 -m http.server 3000 |
然后访问:
1 | http://localhost:3000/index.html |
此时:
1 | 页面:http://localhost:3000/index.html |
协议、域名、端口都一样。浏览器就不会按跨域处理。
搞清楚来源之后,CORS 这条主线才真正说得通。
4. CORS 到底在放行什么
CORS 的全称是 Cross-Origin Resource Sharing,意思是跨来源资源共享。
白话说:
1 | 服务器告诉浏览器:哪些页面可以读我的响应。 |
注意,是服务器告诉浏览器,不是前端自己告诉浏览器。
前端不能靠自己加一个请求头来绕过跨域。因为如果前端能自己决定,恶意网站也能自己决定,安全规则就没意义了。
一次普通 CORS 请求大致如下:
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'actorBkg': '#3B82F6', 'actorTextColor': '#FFFFFF', 'actorBorder': '#2563EB', 'signalColor': '#1E3A5F'}}}%%
sequenceDiagram
participant Page as "页面 localhost:5173"
participant Browser as "浏览器"
participant Server as "服务器 localhost:8080"
Page->>Browser: fetch("/users")
Browser->>Browser: 检测到跨域
Browser->>Server: GET /users + Origin: localhost:5173
Server->>Server: 校验 Origin
Server->>Browser: 200 + Access-Control-Allow-Origin
Browser->>Browser: 校验 CORS 响应头
Browser->>Page: 返回响应体给 JS最核心的响应头是这个:
1 | Access-Control-Allow-Origin: http://localhost:5173 |
它的意思是:允许 http://localhost:5173 这个页面读取我的响应。
如果服务端少了它,或者写成了别的来源,浏览器就会拦。
简单请求走上面这条链路。一旦请求方法、请求头或 Content-Type 超出「简单请求」范围,浏览器还会先发一道预检。
5. 预检请求:为什么多了一个 OPTIONS
有些跨域请求,浏览器不会直接发正式请求,而是先发一个 OPTIONS 请求。这个 OPTIONS 请求叫预检请求。
它像是在问服务器:
1 | 我等会儿要发 POST。 |
预检请求长这样:
1 | OPTIONS /users |
服务器如果允许,就回:
1 | 204 No Content |
浏览器看到预检通过,才会继续发真正的 POST /users。
什么时候会预检?日常开发记三个条件就够:
- 方法不是
GET、HEAD、POST,例如PUT、DELETE - 带了自定义请求头,例如
Authorization Content-Type是application/json
所以你看到接口调用前多了一个 OPTIONS,不用慌。那通常不是你代码多调了一次,而是浏览器在做 CORS 预检。
这里也解释了「跨域请求服务器到底收没收到」:
| 场景 | 业务请求是否可能到达服务器 |
|---|---|
| 简单请求 | 通常会到达,浏览器再决定 JS 能不能读响应 |
| 预检失败 | 正式业务请求通常不会发 |
| 预检成功,正式响应缺 CORS 头 | 业务请求到了,但 JS 读不到响应 |
预检失败最常见的原因:
| 现象 | 常见原因 | 修法 |
|---|---|---|
Authorization 不被允许 | 后端没放行这个请求头 | 加 Access-Control-Allow-Headers: Authorization |
Content-Type 不被允许 | 后端只放行了默认请求头 | 加 Access-Control-Allow-Headers: Content-Type |
DELETE / PUT 失败 | 后端没放行方法 | 加 Access-Control-Allow-Methods |
OPTIONS 返回 401 / 403 | 鉴权中间件拦了预检 | 让 OPTIONS 在鉴权前直接返回 204 |
| 正式接口 500 后变成跨域错误 | 错误响应没带 CORS 头 | 确保异常响应也加 CORS 头 |
原理讲完了,接下来用 DevTools 把报错和 Network 面板对上号。
6. 用 DevTools 排查跨域
遇到跨域报错,不要先改代码。先打开浏览器 DevTools,看 Network。
按这个顺序查。
6.1 看页面来源和接口地址
先看控制台报错里的两段:
1 | Access to fetch at 'http://localhost:8080/users' |
这告诉你:
1 | 页面来源:http://localhost:5173 |
协议、域名、端口有一个不同,就是跨域。
6.2 看有没有 OPTIONS
如果 Network 里有 OPTIONS,先点开它。
看 Request Headers:
1 | Origin: http://localhost:5173 |
这说明浏览器在问:localhost:5173 能不能用 POST,并带 authorization、content-type 请求头。
再看 Response Headers,至少应该有:
1 | Access-Control-Allow-Origin: http://localhost:5173 |
缺哪个,就修哪个。
6.3 看正式请求的响应头
如果预检通过了,再看真正的业务请求。
正式响应里也要有:
1 | Access-Control-Allow-Origin: http://localhost:5173 |
很多项目的坑是:成功响应有 CORS 头,错误响应没有。
结果后端明明返回了 401、403、500,前端看到的却是跨域错误。此时要修的是后端异常响应链路,而不是前端请求。
6.4 不要用 mode: 'no-cors'
新手很容易搜到这种写法:
1 | fetch('http://localhost:8080/users', { |
这不是解决跨域。
no-cors 的意思更接近:我可以发这个请求,但别让我读响应。
你会拿到一个 opaque 响应。状态码、响应头、响应体基本都读不到。对于调用 API 来说,它没有解决问题。
排查方法有了,再看开发环境和生产环境分别怎么修。
7. 开发环境怎么修
开发时最常见的三种修法。
7.1 本地文件:起本地服务
不要双击打开 file:///Users/you/demo/index.html。参考第 3 节,用本地 HTTP 服务把页面跑起来。
如果只是本地 HTML 读本地 JSON,这一步通常就够了。
7.2 前后端分离:让后端加 CORS
页面在:
1 | http://localhost:5173 |
接口在:
1 | http://localhost:8080 |
后端要允许前端来源:
1 | Access-Control-Allow-Origin: http://localhost:5173 |
如果有预检,还要允许方法和请求头:
1 | Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS |
注意两点:
- CORS 中间件要放在鉴权和业务处理之前
OPTIONS预检不要被登录校验拦住
7.3 前端代理:让浏览器只看到同源
代理的思路是:让浏览器只请求同源地址。
页面来自:
1 | http://localhost:5173 |
前端只请求:
1 | fetch('/api/users') |
浏览器看到的是:
1 | http://localhost:5173/api/users |
这是同源。
然后开发服务器再转发到真正后端:
1 | http://localhost:8080/users |
浏览器看不见这一步。服务器和服务器之间没有浏览器同源策略。
Vite 里常见配置是:
1 | export default { |
开发环境修完,带 Cookie 的跨域还要额外满足几条规则。
8. Cookie 跨域为什么更麻烦
不带 Cookie 的跨域请求,只要服务器放行来源,通常就能读响应。
带 Cookie 时,要同时满足四件事。
前端要写:
1 | fetch('https://api.example.com/user', { |
后端要回:
1 | Access-Control-Allow-Origin: https://app.example.com |
这里不能写:
1 | Access-Control-Allow-Origin: * |
Cookie 本身也要允许跨站发送:
1 | Set-Cookie: session=xxx; SameSite=None; Secure; HttpOnly |
再确认 Cookie 的 Domain 是否覆盖接口域名。
例如:
1 | app.example.com |
它们不同源,因为域名不同。
但它们可能同站,因为都属于 example.com 这个站点。这里要分清两个词:
| 概念 | 判断标准 | 例子 |
|---|---|---|
| 同源 Same-origin | 协议 + 域名 + 端口完全一致 | https://app.example.com 和 https://api.example.com 不同源 |
| 同站 Same-site | 通常看可注册域名 | app.example.com 和 api.example.com 通常同站 |
CORS 说的是同源。Cookie 的 SameSite 说的是同站。它们不是一回事。
所以跨域 Cookie 出问题时,按这个清单查:
- 前端有没有
credentials: 'include' - 后端有没有
Access-Control-Allow-Credentials: true Access-Control-Allow-Origin是不是明确来源,而不是*- Cookie 有没有
SameSite=None; Secure - Cookie 的
Domain、Path是否覆盖当前接口 - 浏览器是否启用了第三方 Cookie 限制
Cookie 规则理清之后,再看生产环境怎么从架构上减少跨域。
9. 生产环境优先用反向代理
生产环境最稳的做法,是让浏览器只面对一个域名。
前端页面:
1 | https://app.example.com/ |
接口地址:
1 | https://app.example.com/api/users |
Nginx 再把 /api/ 转发到内部后端:
1 | 浏览器 -> https://app.example.com/api/users |
浏览器看到的始终是 https://app.example.com,跨域问题从架构上消失。
如果前后端必须分域,例如:
1 | 前端:https://app.example.com |
那就配置 CORS。关键不是背某个 Nginx 模板,而是保证四件事:
- 正常响应带 CORS 头
- 错误响应也带 CORS 头
OPTIONS预检能直接返回- 允许的来源、方法、请求头和凭证配置一致
Nginx 中 add_header 常要加 always,否则 4xx、5xx 响应可能没有 CORS 头。
主线讲完了,最后补几个容易和 CORS 混在一起的边界场景。
10. 几个容易混的边界
10.1 图片能显示,不代表 JS 能读
网页可以加载跨域图片:
1 | <img src="https://cdn.example.com/a.png" alt="demo"> |
因为展示图片不等于读取图片数据。
但如果你把这张图片画到 Canvas,再读像素:
1 | ctx.drawImage(img, 0, 0) |
浏览器就会拦。因为这一步已经从「展示资源」变成了「JS 读取跨域数据」。
10.2 Iframe 能嵌入,不代表能读 DOM
页面可以嵌入跨域 iframe:
1 | <iframe src="https://embed.example.com"></iframe> |
但父页面不能直接读取跨域 iframe 的 DOM。
如果两个页面确实要通信,用 postMessage,并且接收方要校验 event.origin。
10.3 CSRF 说明同源策略不是万能的
同源策略主要阻止「读取响应」,不完全阻止「发送请求」。
所以 CSRF 仍然可能发生。
例如恶意页面放一个图片:
1 | <img src="https://bank.example.com/transfer?to=hacker&amount=10000" alt=""> |
浏览器可能会把请求发出去。攻击者不需要读响应,只要请求造成副作用就够了。
所以服务端不能只靠 CORS 防安全问题,还要用 CSRF Token、SameSite Cookie、Origin / Referer 校验等手段。
至于 JSONP、WebSocket、CORB,先知道它们是特殊场景就行。新手优先把 SOP、Origin、CORS、预检、Cookie 这条主线吃透。
11. 最终排障决策树
遇到跨域报错,按下面顺序走:
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
A["出现跨域报错"] --> B{"页面是 file:// 打开?"}
B -->|是| C["起本地 HTTP 服务"]
B -->|否| D{"页面与接口同源?"}
D -->|是| E["查业务错误、Mixed Content、网络"]
D -->|否| F{"Network 有 OPTIONS?"}
F -->|有| G["查预检响应头"]
F -->|否| H["查正式响应头"]
G --> I{"预检通过?"}
I -->|否| J["查 Allow-Origin/Methods/Headers 及 OPTIONS 鉴权"]
I -->|是| H
H --> K{"Allow-Origin 正确?"}
K -->|否| L["补 CORS 头,含 4xx/5xx 响应"]
K -->|是| M{"需要 Cookie?"}
M -->|是| N["查 credentials、Allow-Credentials、SameSite"]
M -->|否| O["跨域链路已通,查业务逻辑"]最后记这一句:
1 | 跨域不是接口坏了。 |