
1. 先说个真实背景一个能登进去却调不通的接口记得上个Django项目做完联调那几天前端同事突然甩过来一张截图Chrome控制台里清清楚楚写着Access to XMLHttpRequest at http://api.example.com/api/user/info from origin http://localhost:8080 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.当时我第一反应是“前端又没带token”结果人家说接口在Postman里跑得好好的就是浏览器里不行。这个问题看起来只是胸口碎大石一样的常识可真到了Django项目里牵扯出来的细节比我预想的多得多。后台返回的数据没问题Django日志也没有异常唯独浏览器这个“中间人”死活不让你跨过去。后来我花了大半天把Django的跨域问题整个捋了一遍从CORS原理到django-cors-headers的配置再到手写中间件、处理预检请求、配合Cookie和DRF、前端调试该踩的坑基本都踩了个遍。这篇就纯粹做一次实战复盘适合那些项目里已经出现跨域报错、或者准备前后端分离开发但还没遇过坑的人。2. 先搞清楚一个关键问题跨域到底是谁在拦你2.1 跨域的本质是浏览器在“多管闲事”很多人有个误区觉得跨域是后端拒绝的。其实不是。Django服务器压根儿没拒过谁请求打过来它照样返回200数据也带着。真正拦下响应的是浏览器里的同源策略。同源策略要求协议、域名、端口三者完全一致只要有一个对不上浏览器就会把跨域响应挡在页面之外。比如你本地开发时前端跑在http://localhost:8080Django后端起在http://localhost:8000端口不一样天然就是跨域。按同源策略的逻辑浏览器不允许一个页面随便去读另一个“源”的数据这在安全上是对的否则任意一个恶意站点都能从你的网银页面里偷数据了。但问题是前后端分离架构里前端页面、后端API本身就得分跑在两个源上。前端要拿后端的JSON数据却又被浏览器拦着这就成了一个开发模式下的死结。用生活化的说法同源策略就像物业规定“你不是本楼的住户就不能进这栋楼拿快递。”可现在快递是你自己网购的你自己就在楼门口站着快递员也同意给你物业却死活不让你取——后端就是快递员浏览器就是那个刻板物业。2.2 区分“简单请求”和“预检请求”CORS机制为了解决这个死结设计了一套规则。浏览器在发起跨域请求时会先看这个请求的“复杂程度”来定是直接放行还是先问一下后端你同不同意这个源的访问简单请求方法是GET、POST、HEAD之一Content-Type只允许text/plain、application/x-www-form-urlencoded、multipart/form-data并且没有自定义头。满足这些条件浏览器会直接发真实请求然后检查响应头里有没有Access-Control-Allow-Origin。预检请求只要有下面任意一种情况浏览器都会先发一个OPTIONS请求过来探路方法不是GET、POST、HEAD比如DELETE、PUT。Content-Type不是上面三种比如常用的application/json。带了自定义请求头比如Authorization、X-Requested-With这些。Django项目里90%的跨域问题都出在这个预检请求上。因为前后端分离的项目接口几乎都是JSON交互Content-Type天然就是application/json再加上要带token自定义头也必不可少——这就必然触发预检。如果Django后端没正确响应OPTIONS浏览器就认为后端拒绝跨域真正的POST或PUT请求根本不会发出去。2.3 为什么Postman正常、浏览器却不行这是最让新手迷惑的一点。Postman这类工具发请求压根不经过同源策略那一套它不像浏览器那样有“页面”的概念所以不管跨不跨域响应它照单全收。浏览器呢必须先确认响应头里带了允许跨域的声明才肯把响应数据交给你页面里的JavaScript。所以一旦遇到“Postman正常但浏览器报CORS错”基本可以确定问题出在后端响应头没配好或者浏览器预检请求没过。这个判断很关键能帮你快速锁定排查范围不用瞎改前端代码。3. 最省事的方案django-cors-headers三步搞定3.1 安装与注册说到Django的跨域方案大部分团队首选就是django-cors-headers这个库。原因很简单Django自带的能力里没有原生跨域配置自己不写中间件的话靠库最省事。先把库装上pip install django-cors-headers然后在settings.py里把相关中间件加进去。需要注意顺序CorsMiddleware尽量放在CommonMiddleware前面因为处理OPTIONS预检请求得趁早避免被后面其他逻辑拦截INSTALLED_APPS [ # ... corsheaders, ] MIDDLEWARE [ corsheaders.middleware.CorsMiddleware, # 放前面 django.middleware.common.CommonMiddleware, # ... ]只加这个还不够如果你用的是新版Django还得确保中间件最终生效的范围是整个站点。装上之后Django会自动给响应添加CORS相关头但允许哪些源跨域访问得继续配置。3.2 白名单配置的几种写法最常规的写法是白名单模式CORS_ALLOWED_ORIGINS [ http://localhost:8080, http://127.0.0.1:8080, https://yourfrontend.example.com, ]这种写法明确只允许指定端口和域名访问。注意http://localhost:8080和http://127.0.0.1:8080要分开写浏览器里这两者看起来差不多但对浏览器来说源的定义是严格的IP和域名不能混为一谈访问localhost时源就是localhost访问127.0.0.1时Origin就是那个IP漏掉哪一个都会被拦。如果你开发阶段图省事可以直接允许所有源CORS_ALLOW_ALL_ORIGINS True生产环境千万别一直开着这个这等于让任何网站都能读取你后端的接口数据身份认证体系形同虚设。我见过有人把CORS_ALLOW_ALL_ORIGINS直接带到线上然后说有安全风险其实问题不出在CORS库而是那个开关本身就不该在生产环境开。3.3 带Cookie的场景要多配两个参数有些项目需要跨域传递Cookie比如登录凭证的一部分依赖Cookie。只配CORS_ALLOWED_ORIGINS还不够得把CORS_ALLOW_CREDENTIALS打开CORS_ALLOW_CREDENTIALS True注意一旦打开了CORS_ALLOW_CREDENTIALS你的CORS_ALLOWED_ORIGINS里就不允许写*浏览器规范明确要求凭证请求时Access-Control-Allow-Origin必须指定具体的源不能用通配符否则浏览器依然会拒绝。这个坑连有经验的开发都容易忽略配置完发现还是报错一看响应头里Allow-Origin确实是*就知道踩中了规则冲突。4. 深入原理手写一个CORS中间件也不难4.1 中间件的核心逻辑拆解用第三方库说白了就是省事但要真理解跨域手写中间件的过程特别值得走一遍。思路其实很清晰后端要想办法告诉浏览器“这个源可以访问”手段就是往响应头里塞三个关键字段class SimpleCorsMiddleware: def __init__(self, get_response): self.get_response get_response def __call__(self, request): # 非跨域请求直接放行 if Origin not in request.headers: return self.get_response(request) # 判断这个Origin是否在允许列表里 allowed_origins [ http://localhost:8080, http://127.0.0.1:8080, ] origin request.headers[Origin] if origin not in allowed_origins: return self.get_response(request) response self.get_response(request) response[Access-Control-Allow-Origin] origin response[Vary] Origin return response这段代码的逻辑和django-cors-headers的白名单模式几乎一样先看请求头里有没有Origin没有的话就是同源请求或非浏览器请求不用处理有的话判断是否在白名单里是的话就给响应加上Access-Control-Allow-Origin而且回的值是请求方那个具体的Origin不是固定的某个字符串。Vary: Origin这行容易被忽略但它很重要。它告诉中间缓存比如CDN、浏览器缓存“这个响应会因Origin头的不同而变化”避免不同源的请求拿到同一份缓存响应把跨域头搞错。4.2 处理预检请求才是重点上面的写法能处理简单请求但application/json的POST和带Authorization头的请求都会先触发OPTIONS预检这时候中间件必须单独处理class CorsMiddleware: def __call__(self, request): if request.method OPTIONS and Access-Control-Request-Method in request.headers: # 这是一个预检请求直接返回空响应只带头信息 response HttpResponse(status200) response[Access-Control-Allow-Origin] request.headers.get(Origin, *) response[Access-Control-Allow-Methods] GET, POST, PUT, PATCH, DELETE, OPTIONS response[Access-Control-Allow-Headers] Content-Type, Authorization, X-Requested-With response[Access-Control-Max-Age] 86400 return response # 非预检请求正常返回但注入允许跨域的响应头 response self.get_response(request) response[Access-Control-Allow-Origin] request.headers.get(Origin, *) response[Access-Control-Allow-Methods] GET, POST, PUT, PATCH, DELETE, OPTIONS response[Access-Control-Allow-Headers] Content-Type, Authorization, X-Requested-With return response判断预检请求的关键点有两个方法必须是OPTIONS同时请求头里带Access-Control-Request-Method这个头说明浏览器是在替你“探路”想知道后端接不接受真实的跨域请求。如果只判断方法那些普通的OPTIONS请求也可能被误处理造成没必要的中断。预检请求一般不需要真的访问数据库或路由直接返回200就行重点是附加那一堆Access-Control-*响应头。有一个容易被忽略的字段叫Access-Control-Max-Age单位是秒表示本次预检的结果可以被浏览器缓存多久。设置成86400一天的话一天内同源同类型的跨域请求就不必每次都发一次OPTIONS了能明显减少请求次数优化首屏接口的加载链。4.3 手写方案的取舍既然手写也不难直接用第三方库是不是就显得多余了其实不然。django-cors-headers对很多边界情况都做了处理比如Vary头的自动设置、正则匹配、CORS_URLS_REGEX按URL范围控制、异常中间件的适配等。手写的时候如果想要完全覆盖这些边界代码量会膨胀还容易漏。我的个人建议是项目里追求快速稳定直接用django-cors-headers配置简单社区维护。想要彻底搞懂原理、或者需要高度自定义某些响应头组合可以先手写一个精简版再把第三方库源码读一遍。实际工作中我很少遇到必须彻底手写的情况。但不妨把上面的中间件代码存下来万一有环境装不了第三方库或者公司有严格的依赖审查制度这份代码就能顶上。5. 当Django遇到前端Debug这些报错信息的真实含义5.1 浏览器控制台常见报错逐条解读报错一No Access-Control-Allow-Origin header is present。这是最常见的说明后端响应里压根没有跨域头。要么中间件没配好要么预检请求直接返回了500且没经过中间件。排查方向先用Chrome的Network面板看这条请求的状态码如果是200那就确认后端没加响应头如果OPTIONS请求状态是404或者500那问题不在CORS头本身而是这个预检请求压根没被后端正常处理。报错二Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response。后端返回的Access-Control-Allow-Headers里没包含Authorization浏览器就认为你在预检里申请“我请求时想带Authorization头”结果后端说“不允许”于是报错。手动中间件里必须把实际会用到的自定义头列全django-cors-headers的最新版本会自动根据请求合并Allow-Headers所以用库的话一般不会有这个问题。报错三The value of the Access-Control-Allow-Credentials header in the response is which must be true。这个问题出在前端代码设置了withCredentials: true但后端没开对应的Allow-Credentials头。前端带Cookie跨域时必须前后端同时配合前端允许携带凭证后端允许凭证访问两个条件缺一个就报这个错。报错四Response to preflight request doesnt pass access control check: It does not have HTTP ok status。预检请求没有返回2xx状态码。可能是django-cors-headers处理了但被你的自定义拦截器拦截住了或路由没匹配上。Django处理OPTIONS时默认会走ALLOWED_METHODS的规则如果路由里没允许OPTIONS方法可能直接返回405这时得检查路由配置。5.2 实操调试技巧无痕模式、curl和Fiddler调试跨域问题时我最常用的是无痕窗口。因为跨域涉及缓存和Cookie普通窗口里可能有历史响应缓存改了后端配置后浏览器用了旧缓存导致你误以为“改了半天没用”。每次修改后端CORS配置后用无痕窗口刷新能躲开大量干扰。打开开发者工具的Network面板勾选Preserve log重点观察请求分为两类红色被拦的请求和灰色的OPTIONS预检请求灰色的预检如果状态不是200就往下看响应体原因往往都写在里面。如果后端环境不开前端也能模拟用curl最直接curl -i -X OPTIONS http://localhost:8000/api/user/info \ -H Origin: http://localhost:8080 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: Content-Type, Authorization这条命令手动模拟浏览器预检请求-i参数会把响应头完整打印出来。一看响应头列表就有答案了Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization如果响应里缺了某些头那就是后端处理的问题如果响应头齐全那就是浏览器端有缓存或者其他代理环境在添乱。还有一个小技巧是看响应头的Vary字段如果缺少Vary: Origin可能存在缓存污染但开发环境一般影响不大。Fiddler或Charles这类抓包工具在这个场景里并不是首选项因为浏览器已经能完整看到请求响应头和CORS拦截结果。抓包工具的价值主要体现在线上环境、HTTPS证书调试或者在排查代理服务器比如Nginx改没改响应头的时候。比如后端响应头明明带了Access-Control-Allow-Origin但前端从Nginx反代拿到的响应里却没有这就得考虑是Nginx代理层把它过滤掉了。5.3 前端错误排查清单这里也给出一个简单的前端排查顺序方便前后端协作时快速定位先在Network面板确认请求的真实状态码。看响应头里有没有Access-Control-Allow-Origin没有则后端问题有则看值是否等于前端源。看前端代码是否设置了withCredentials如果设置响应头里必须有Access-Control-Allow-Credentials: true。看预检请求OPTIONS是否通过没通过则重点查Allow-Methods和Allow-Headers。看响应头是否被Nginx或代理改过这一步在联调环境尤其常见。6. 延伸组合场景Cookie、CSRF、DRF和Nginx反代6.1 Django的CSRF与跨域Cookie问题前后端分离、跨域带Cookie时Django的CSRF机制也会跳出来捣乱。先说明白CSRF的原理Django默认会把CSRF token存到Cookie里前端需要从Cookie中把值取出来再放到请求头X-CSRFToken里交给后端校验。如果浏览器禁用了跨域Cookie那前端页面拿不到CSRF token后面的POST请求自然过不了校验。解决办法通常是两步。第一步允许跨域携带Cookie这就回到前面说的CORS_ALLOW_CREDENTIALSTrue。第二步对于API接口普遍采用的是token认证方案而非Cookie会话方案。用JWT这类token认证本身不需要Cookie也就绕开了CSRF这个坑。我在热词里看到“Django Cookie 设置 Token”说明很多人确实在考虑Cookie和token并存。实际项目中可以这么做认证信息放token放Authorization头Cookie仅存放非关键的偏好设置没有必要的话让API彻底脱离Cookie这样后端对CSRF的依赖就降为零。如果确实需要Session Cookie跨域得给API路由加上csrf_exempt装饰器比如from django.views.decorators.csrf import csrf_exempt csrf_exempt def api_login(request): ...但记住csrf_exempt只适用于无状态API接口且必须配合严格的CORS白名单使用不然等于给攻击者敞开了大门。这个取舍要做清楚别图省事把全站CSRF都给关了。6.2 DRF项目里的CORS配合要点用Django REST Framework时CORS配置本身不复杂。DRF的APIView会自动处理OPTIONS请求但它返回的Allow头是针对同一资源方法的。如果你已经配好了cors-headers中间件会在响应上再加Access-Control-Allow-Methods这两个头不会冲突。但有一个细节如果你给DRF配了自定义权限类比如某些接口先用JWTAuthorization做身份校验预检请求带不带token都无所谓但真实跨域请求必须带token。如果你发现真实请求过了预检还是报401那大概率是前端请求头里没有正常附带Authorization倒是跨域问题之外的联调问题。DRF里如果写了自定义异常处理函数exception_handler要确保它对预检请求的异常兜底友好。否则预检请求可能走到自定义异常里返回一个JSON错误状态码非2xx浏览器照样拦。最省心的做法是在全局异常处理的最前面加一个分支识别到OPTIONS和Access-Control-Request-Method时就返回空响应。6.3 Nginx反向代理与CORS的关系很多Django项目上线后前端和后端共用同一个域名靠Nginx路由分发比如/api/开头走Django其余走静态前端。这种架构下“跨域”其实被代理抹平了因为浏览器视角中只有https://example.com这一个源不存在跨域问题。但如果前端和后端分属不同域名比如前端静态资源在https://app.example.com后端API在https://api.example.comNginx只作为后端的反向代理那么CORS处理依然要靠Django层。Nginx本身不负责生成CORS头但它可能把Django返回的响应头再压缩、改写、丢弃。排查时要注意看Nginx配置里有没有proxy_hide_header、add_header之类指令如果加上了一层access-control-allow-origin又和Django层的配置不一致会出现重复头或覆盖的情况。有次线上就是两个头都配了浏览器拿到两个不同值的Access-Control-Allow-Origin直接判定无效。这时候建议把跨域头的控制权集中在Django层Nginx层全部透传免得两头打架。7. 避坑清单与排障心法跨域问题说大不大说小不小但它总是出现在最关键的联调阶段。我把自己实战中积累的几条经验整理成清单给还没踩过坑的人提前排雷生产环境CORS_ALLOW_ALL_ORIGINS一辈子别开白名单写得再难受也要维护。CORS_ALLOW_CREDENTIALSTrue时CORS_ALLOWED_ORIGINS禁止使用通配符*。前后端联调时改完CORS配置后让前端用无痕窗口重新加载避免浏览器缓存误导判断。遇到“Postman成功、浏览器失败”先假设后端响应头不齐全用curl模拟预检请求核对响应头。OPTIONS预检请求不需要走视图逻辑让它尽早返回空响应别被认证中间件拦截。前端开了withCredentials后后端Access-Control-Allow-Headers必须显式包含实际用到的头别偷懒只写*。多个反向代理层时CORS头只允许由最靠近后端的Django层来设置代理层全透传。最后再分享一个实际应用里的小技巧。django-cors-headers支持通过CORS_URLS_REGEX针对特定路径开启跨域权限比如CORS_URLS_REGEX r^/api/.*$这么配的好处是登录接口和公开组件所在的路径可以直接限定在/api/下的URL非API路径完全不暴露减少CORS白名单开放的攻击面。我上一版项目里就这么干的结果后来排查线上问题时一眼就能从URL正则判断某个接口到底走没走CORS逻辑省了不少时间。跨域这个问题说到底是你用浏览器遵守规则的方式去和后端“谈判”谁可以访问谁。理解清楚同源策略、预检请求、CORS响应头这三个核心再配合好用的中间件和一套调试方法基本上就能把所有相关报错连根拔起。不要被那一堆红色报错吓住架构上的东西如果跑不通大多是你还没找到那个缺失的响应头而已。