Tarayıcı konsolunda "has been blocked by CORS policy" yazıyor. Ön yüzünüz (front-end) http://localhost:5173 üzerinden http://localhost:8000 adresindeki API'ye bağlanıyor ya da canlı ortamda https://app.example.com sayfası https://api.example.com adresine istek atıyor; üstelik aynı istek curl ve Postman ile sorunsuz çalışıyor. Sorunun özü bu karşıtlıkta: CORS (Cross-Origin Resource Sharing, kökenler arası kaynak paylaşımı) kuralını tarayıcı uygular, izni ise isteği yanıtlayan sunucu verir. Bu rehberde hata mesajını nasıl okuyacağınızı, eksik başlığı (header) DevTools ve curl ile nasıl bulacağınızı ve CORS hatasının Express, Django, FastAPI, Laravel, Spring Boot, ASP.NET Core, Nginx ve Apache tarafında güvenli ve kalıcı olarak nasıl çözüleceğini adım adım anlatıyoruz.
Kısa cevap: API sunucusu Access-Control-Allow-Origin başlığında sayfanızın kökenini birebir döndürmeli, OPTIONS ön kontrol isteğine (preflight) kullandığınız yöntem ve başlıkları listeleyen 2xx bir yanıt vermeli; çerez gönderiyorsanız * yerine açık bir kökenle birlikte Access-Control-Allow-Credentials: true da eklemelidir.
Chrome ve Firefox'ta CORS hata mesajları
Chrome ve Edge gibi Chromium tabanlı tarayıcılar mesajı aşağıdaki biçimde yazar. "CORS policy:" ifadesinden sonra gelen kısım neyin başarısız olduğunu söyler:
Kod:
Access to fetch at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to XMLHttpRequest at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to fetch at 'https://api.example.com/v1/me' from origin 'https://app.example.com' has been blocked by CORS policy: 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'.
En sık karşılaşılan, ilk satırdaki access-control-allow-origin hatasıdır: yanıtta bu başlık hiç yoktur. Firefox aynı sorunları kendi cümleleriyle bildirir:
Kod:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/v1/orders. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/v1/orders. (Reason: CORS preflight response did not succeed). Status code: 401.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at ‘https://api.example.com/v1/me’. (Reason: Credential is not supported if the CORS header ‘Access-Control-Allow-Origin’ is ‘*’).
JavaScript tarafında ise yalnızca genel bir hata yakalarsınız: Chrome'da TypeError: Failed to fetch, Firefox'ta TypeError: NetworkError when attempting to fetch resource., axios'ta AxiosError: Network Error. Tarayıcı asıl nedeni betiklerden bilerek gizler ve yalnızca konsola yazar. Chrome 135 ve öncesi, fetch ile yapılan isteklerde eksik başlık mesajının sonuna "If an opaque response serves your needs, set the request's mode to 'no-cors'..." önerisini de eklerdi; bu öneri hiçbir şeyi çözmez (aşağıdaki geliştirme ortamı bölümüne bakın).
Arka planda ne oluyor?
- Tarayıcı isteği (gerekiyorsa önce bir ön kontrol isteğini) gönderir, yanıtı alır ve CORS başlıklarını denetler. Başlıklar sayfanızın kökenine izin vermiyorsa yanıt kodunuza hiç ulaşmaz.
- Ön kontrol başarısız olursa asıl istek hiç gönderilmez. Basit bir istek denetime takıldığında ise sunucu isteği çoktan almış, hatta işlemiş olabilir. CORS, isteği kimin gönderebileceğini değil, yanıtı kimin okuyabileceğini belirler.
- curl, Postman, mobil uygulamalar ve sunucudan sunucuya çağrılar CORS denetimi yapmaz. "Postman'de çalışıyor" demek bu yüzden CORS ayarınız hakkında hiçbir şey kanıtlamaz.
- Çözüm sunucuda ya da önündeki ters vekil sunucuda (reverse proxy) yapılır: sunucu Access-Control-* başlıklarını göndererek izin verir. Ön yüzdeki hiçbir ayar bu izni kendi başına veremez.
Köken (origin) nedir: şema, alan adı ve port
Köken, şema://alan-adı| Sayfa | İsteğin gittiği adres | Aynı köken mi? | Neden |
|---|---|---|---|
| https://app.example.com | https://app.example.com/api/orders | Evet | Yalnızca yol farklı |
| http://localhost:3000 | http://localhost:8000 | Hayır | Port farklı |
| http://localhost:5173 | http://127.0.0.1:5173 | Hayır | Ana makine adı (host) farklı |
| http://example.com | https://example.com | Hayır | Şema farklı |
| https://www.example.com | https://example.com | Hayır | www ile kök alan adı (apex) ayrı sunucu adlarıdır |
| https://app.example.com | https://api.example.com | Hayır | Alt alan adı farklı, ama site aynı (çerez bölümüne bakın) |
Tarayıcı sayfanın kökenini Origin istek başlığına yol bilgisi ve sondaki eğik çizgi olmadan yazar. Sunucu Access-Control-Allow-Origin başlığında tam olarak bu metni (ya da *) döndürmek zorundadır; ayarlarınıza sonunda eğik çizgiyle yazılmış https://app.example.com/ değeri hiçbir zaman eşleşmez.
Basit istek ve ön kontrollü (preflight) istek farkı
Bir istek ancak aşağıdakilerin hepsi doğruysa "basit" sayılır ve doğrudan gönderilir:- Yöntem GET, HEAD veya POST'tur.
- Kodunuz yalnızca CORS'un serbest bıraktığı (CORS-safelisted) başlıkları ayarlar: Accept, Accept-Language, Content-Language, Content-Type ve tek aralıklı bir Range.
- Content-Type varsa değeri application/x-www-form-urlencoded, multipart/form-data veya text/plain'dir.
- Gövde bir ReadableStream değildir ve XMLHttpRequest yükleme (upload) olaylarına dinleyici eklenmemiştir.
| İstek başlığı (tarayıcı) | Yanıt başlığı (sunucu) | Kural |
|---|---|---|
| Origin | Access-Control-Allow-Origin | Tek değer: isteyen köken ya da *; kimlik bilgili isteklerde * reddedilir |
| Access-Control-Request-Method (ön kontrol) | Access-Control-Allow-Methods | İstenen yöntemi içermeli; GET, HEAD ve POST her zaman serbesttir |
| Access-Control-Request-Headers (ön kontrol) | Access-Control-Allow-Headers | İstenen her başlığı listelemeli; * yalnızca kimlik bilgisi taşımayan isteklerde joker sayılır ve Fetch standardı Authorization'ı bu jokerin dışında tutar |
| Çerez, credentials: 'include' | Access-Control-Allow-Credentials | Geçerli tek değer true'dur ve açık bir köken ister |
| (betik bir yanıt başlığını okur) | Access-Control-Expose-Headers | Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified ve Pragma dışındaki her başlık için gerekir |
| (ön kontrol önbelleği) | Access-Control-Max-Age | Saniye cinsinden; Chromium 7200, Firefox 86400 saniyeyle sınırlar; varsayılan 5'tir |
| (proxy, CDN, tarayıcı önbelleği) | Vary: Origin | Allow-Origin isteğe göre değişiyorsa gönderilmelidir |
Tarayıcılar Access-Control-Request-Headers içindeki başlık adlarını küçük harfe çevirip sıralar (örneğin authorization,content-type). Başlık adlarında büyük-küçük harf ayrımı olmadığından sunucu bunları istediği yazımla listeleyebilir. Joker (*) hakkında bir not: Chrome ve Firefox, kimlik bilgisi taşımayan isteklerde joker değerin Authorization'ı da kapsamasına şimdilik izin veriyor; ancak standart buna izin vermediği için ikisi de kullanımdan kaldırma (deprecation) uyarısı gösteriyor. Bu yüzden Authorization'ı adıyla yazın.
CORS hatasını beş adımda teşhis edin
- DevTools'ta doğru satırı bulun. Ağ (Network) panelini açın, All filtresini seçin ve hatayı yeniden tetikleyin. Ön kontrol satırları Other kategorisinde yer aldığı için Fetch/XHR filtresi onları gizler. Chrome'da başarısız çağrının Status sütununda CORS error yazar; imleci üzerine getirince hata kodu görünür. Bir sütun başlığına sağ tıklayıp Method sütununu ekleyin: ön kontrollü bir çağrı POST + Preflight olarak görünür ve Type değeri preflight olan OPTIONS satırına bağlantı verir. Firefox ise OPTIONS isteğini ayrı bir satır olarak listeler.
- Ön kontrol yanıtının durum koduna bakın. 2xx olmalıdır. 301, 302, 307 veya 308 bir yönlendirme demektir (Chrome: "Redirect is not allowed for a preflight request"). 401 veya 403, kimlik doğrulamanın ya da bir WAF'ın CORS'tan önce devreye girdiğini; 404 veya 405, o yolda OPTIONS'a yanıt veren bir şey olmadığını; 5xx ise uygulamanın hata verdiğini gösterir.
- Yanıt başlıklarını okuyun. Access-Control-Allow-Origin sayfa kökeniyle karakteri karakterine aynı olmalı ve yalnızca bir kez bulunmalı; Allow-Methods ve Allow-Headers tarayıcının istediklerini kapsamalı; çerez söz konusuysa Allow-Credentials true olmalıdır.
- curl ile yeniden üretin. Böylece sunucudaki her değişikliği tarayıcıya gerek kalmadan test edersiniz (komutlar aşağıda).
- Hata yollarını da kontrol edin. 401, 404 ve 500 yanıtları da CORS başlıklarını taşımalıdır; taşımıyorsa tarayıcı gerçek hatanın yerine CORS hatası gösterir.
Bash:
# 1) Ön kontrol isteği, tarayıcının gönderdiği biçimde
curl -i -X OPTIONS https://api.example.com/v1/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
# 2) Asıl istek ve bir hata yanıtı da başlıkları taşımalı
curl -i https://api.example.com/v1/orders -H "Origin: https://app.example.com"
curl -i https://api.example.com/v1/olmayan-yol -H "Origin: https://app.example.com"
Sağlıklı bir ön kontrol yanıtı kabaca şöyle görünür (başlıkların sırası ve harf yazımı sunucuya göre değişir):
Kod:
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE
access-control-allow-headers: Authorization, Content-Type
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin
404 ya da 401 yanıtında access-control-allow-origin yoksa o yolu da düzeltin: Nginx'te always, Apache'de Header always kullanın ve uygulamada CORS ara katmanını (middleware) hata işleyicilerinin önüne alın. Tutarlı hata gövdeleri için REST API tasarımı rehberimize bakabilirsiniz.
Hızlı başvuru: mesaj, neden, çözüm
| Mesajda geçen ifade | Olası neden | Çözüm |
|---|---|---|
| No 'Access-Control-Allow-Origin' header is present | Bu yol ya da köken için CORS ayarı yok veya yanıt, proxy'nin ya da uygulamanın hata sayfası | Kökeni izin listesine ekleyin; hata yanıtlarına da başlık ekleyin |
| Response to preflight request doesn't pass access control check | OPTIONS isteği 401/403/404/405 aldı ya da başlıksız döndü | OPTIONS'ı kimlik doğrulamadan önce, 204 ve Allow-* başlıklarıyla yanıtlayın |
| Redirect is not allowed for a preflight request | http'den https'e, sondaki eğik çizgi ya da giriş sayfası yönlendirmesi | Son adresi doğrudan çağırın |
| must not be the wildcard '*' when the request's credentials mode is 'include' | Kimlik bilgili isteğe Allow-Origin: * ile yanıt verilmiş | İzinli kökeni aynen döndürün, Allow-Credentials: true gönderin |
| contains multiple values ..., but only one is allowed | Başlığı hem proxy hem uygulama ekliyor ya da tek başlıkta birden çok köken var | CORS'u tek katmanda yapın; her yanıtta tek köken gönderin |
| Method PUT is not allowed by Access-Control-Allow-Methods | Yöntem ön kontrol yanıtında yok | İzinli yöntemlere ekleyin |
| Request header field authorization is not allowed by Access-Control-Allow-Headers | Başlık Allow-Headers listesinde yok | Adıyla yazın; kimlik bilgili isteklerde * joker sayılmaz |
| has a value '...' that is not equal to the supplied origin | Sabit yazılmış köken farklı (www, port, http/https, sondaki eğik çizgi) | Köken metnini birebir eşleştirin |
Express, Django, Laravel, Nginx ve diğerleri için CORS ayarı
Örneklerin hepsinde https://app.example.com yerine kendi ön yüz kökeninizi yazın ve localhost kayıtlarını canlı ortama taşımayın. Kod parçaları Ekim 2026'da cors 2.8.6, django-cors-headers 4.9, Starlette CORSMiddleware, Laravel 12 ve 13, Spring Security 7.1, ASP.NET Core 10 ve Vite 8 belgeleri ve kaynak koduyla karşılaştırıldı.Express (Node.js): cors ara katmanı
Kod:
// npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
const allowedOrigins = ['https://app.example.com'];
if (process.env.NODE_ENV !== 'production') allowedOrigins.push('http://localhost:5173');
app.use(cors({
origin: allowedOrigins, // listedeki kökeni aynen döndürür, Vary: Origin ekler
credentials: true, // yalnızca çerez kullanıyorsanız
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Disposition'],
maxAge: 600,
}));
// kimlik doğrulama, hız sınırlama ve route'lar bu satırın altına
Django: django-cors-headers
Python:
# pip install django-cors-headers (settings.py)
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware", # olabildiğince üstte
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
CORS_ALLOWED_ORIGINS = ["https://app.example.com", "http://localhost:5173"]
CORS_URLS_REGEX = r"^/api/.*$"
CORS_ALLOW_CREDENTIALS = True # oturum çerezi başka kökenden
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"] # çerezle POST/PUT/DELETE
FastAPI (Starlette)
Python:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Laravel: config/cors.php
HandleCors ara katmanı Laravel'in global middleware yığınında hazır gelir ve OPTIONS isteklerini yanıtlar. Laravel 11'den beri yapılandırma dosyası varsayılan olarak yayımlanmaz:
Bash:
php artisan config:publish cors
PHP:
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => ['https://app.example.com'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
'exposed_headers' => [],
'max_age' => 600,
'supports_credentials' => true,
];
Spring Boot ve Spring Security
Kod:
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults()) // CORS kimlik doğrulamadan önce çalışır
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
ASP.NET Core: AddCors ve UseCors sırası
Kod:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("Spa", policy => policy
.WithOrigins("https://app.example.com", "http://localhost:5173") // sonda eğik çizgi yok
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials());
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseCors("Spa"); // UseRouting'den sonra, kimlik doğrulamadan önce
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Nginx: map ile izin listesi
Kod:
# http {} bloğu: izin listesi
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"~^https://([a-z0-9-]+\.)?example\.com$" $http_origin; # yalnızca tüm alt alan adları sizin kontrolünüzdeyse
}
server {
# ... listen, server_name, TLS ...
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_pass http://127.0.0.1:8080;
}
}
Apache: .htaccess veya VirtualHost
Kod:
# mod_setenvif ve mod_headers gerekir (mod_rewrite yalnızca OPTIONS yanıtı için)
SetEnvIf Origin "^https://(app|admin)\.example\.com$" CORS_ORIGIN=$0
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" env=CORS_ORIGIN
Header always set Access-Control-Allow-Headers "Authorization, Content-Type" env=CORS_ORIGIN
Header always merge Vary "Origin"
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteCond %{HTTP:Access-Control-Request-Method} .
RewriteRule ^ - [R=204,L]
Kimlik bilgileri ve çerezler (credentials)
Önce gerçekte ne gönderdiğinizi netleştirin. CORS açısından kimlik bilgisi (credentials) çerezler, HTTP kimlik doğrulaması ve TLS istemci sertifikaları demektir. Kodunuzun Authorization başlığına kendi eklediği Bearer belirteci (token) bu anlamda kimlik bilgisi sayılmaz: credentials: 'include' ya da Allow-Credentials gerektirmez, ama Authorization'ın Allow-Headers listesine açıkça yazılması gerekir ve istek ön kontrollü olur.- İstemci: fetch(url, { credentials: 'include' }); axios'ta withCredentials: true (istek başına ya da axios.defaults.withCredentials = true ile); XMLHttpRequest'te xhr.withCredentials = true. fetch'in varsayılan değeri 'same-origin' olduğundan çerezler siz istemedikçe başka bir kökene gitmez.
- Sunucu: Access-Control-Allow-Credentials: true ve kökeni birebir döndüren bir Access-Control-Allow-Origin. Kimlik bilgili isteklerde * değeri Allow-Origin, Allow-Headers, Allow-Methods ve Expose-Headers başlıklarında joker anlamını yitirir (Allow-Origin'de tarayıcı yanıtı doğrudan reddeder).
- Çerezler: farklı köken, farklı site demek değildir. app.example.com ile api.example.com farklı kökenlerdir ama aynı sitedir (aynı kayıt edilebilir alan adı ve şema); CORS kimlik bilgisine izin verdiğinde SameSite=Lax bir çerez de gönderilir. Kayıt edilebilir alan adı Public Suffix List'e göre belirlenir: github.io, vercel.app veya herokuapp.com gibi paylaşımlı platform alan adlarında iki alt alan adı farklı site sayılır. myapp.com'dan api.vendor.io'ya yapılan çağrı ise siteler arasıdır (cross-site): çerezin HTTPS üzerinden SameSite=None; Secure ile ayarlanması gerekir ve yine de üçüncü taraf çerez politikalarına takılabilir. Safari'nin izleme önleme özelliği üçüncü taraf çerezleri varsayılan olarak kısıtlar, Firefox her siteye ayrı bir çerez deposu (cookie jar) ayırır, Chrome ise gizli pencerede ya da kullanıcı isterse bunları engeller.
Geliştirme ortamında proxy ile tek köken
En temiz geliştirme düzeni CORS'a hiç ihtiyaç duymaz: geliştirme sunucusu (dev server) /api altındaki her isteği arka uca iletir, tarayıcı da yalnızca tek bir kökenle konuşur. Vite ile:
Kod:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
Şunlar çözüm değildir:
- mode: 'no-cors'. Yanıt opaktır (opaque response): durum kodu 0'dır, başlıklar ve gövde JavaScript'e kapalıdır. Yalnızca GET, HEAD ve POST'a izin verilir; tarayıcı Authorization ya da application/json değerli Content-Type gibi izinli liste dışındaki başlıkları sessizce düşürür. Yani sunucuya giden istek bile sizin yazdığınız istek değildir.
- Tarayıcı güvenliğini kapatmak. --disable-web-security bayrağı ya da "CORS unblock" eklentileri yalnızca kendi tarayıcınızı değiştirir. Kullanıcılarınız hatayı görmeye devam eder; o tarayıcıda açtığınız diğer her site de aynı köken politikası (same-origin policy) olmadan çalışır.
- Herkese açık CORS proxy'leri. Belirteçleriniz ve verileriniz üçüncü bir taraftan geçer. Herkese açık veriyle kısa bir deneme için kabul edilebilirler; canlı ortam için asla.
CORS hatasını tekrar tekrar getiren tuzaklar
- Ön kontrolü kimlik doğrulama, WAF ya da CDN engelliyor. OPTIONS isteği çerez ve Authorization taşımadığından önce çalışan kimlik doğrulama ara katmanı 401 döndürür. CORS'u en başa alın, WAF ve CDN kurallarında OPTIONS yöntemine izin verin ve testi kendi ağınızın dışından curl ile yapın.
- Yönlendirmeler. http'den https'e geçiş, sondaki eğik çizgi yönlendirmesi (Django'da APPEND_SLASH, Laravel'in varsayılan .htaccess dosyasındaki 301 kuralı, FastAPI'nin 307 ile çalışan redirect_slashes ayarı) ya da giriş sayfasına yapılan 302 yönlendirmesi ön kontrolü doğrudan bozar. Basit isteklerde yönlendirme yanıtının kendisi de CORS başlığı taşımalıdır; köken değiştiren bir yönlendirmeden sonra Origin değeri null olur. Chrome mesajında (redirected from '...') ifadesini görüyorsanız sorun budur.
- Çift başlık. Access-Control-Allow-Origin başlığını hem uygulama hem proxy eklerse Chrome multiple values hatası verir. CORS'u tek katmanda yapılandırın; Nginx'te arka ucun kopyasını proxy_hide_header ile kaldırın.
- Vary: Origin olmadan önbellekleme. Başka bir köken için üretilmiş yanıtı tekrar sunan bir proxy ya da CDN, "not equal to the supplied origin" hatasına yol açar. Allow-Origin değiştiği her durumda Vary: Origin gönderin. Bazı CDN'ler Vary'yi önbellek anahtarına ancak ayarlandığında katar; Cloudflare'de bu iş Temmuz 2026'da eklenen Cache Rules içindeki Vary ayarıyla yapılır. Web fontları gibi kimlik bilgisi gerektirmeyen herkese açık dosyalarda en basit çözüm Access-Control-Allow-Origin: * olur.
- file:// ile açılan sayfalar. Bu sayfaların Origin değeri null'dır. null kökenine asla izin vermeyin (sandbox özniteliği taşıyan iframe'ler ve bazı yönlendirmeler de null gönderir); sayfayı yerel bir web sunucusuyla açın, örneğin python3 -m http.server 8080.
- Chrome'un yerel ağ erişimi kısıtlaması (Local Network Access). Chrome 142'den beri herkese açık bir sitenin özel bir IP'ye (192.168.x.x, 10.x.x.x) ya da localhost'a gönderdiği istekler kullanıcının iznine bağlıdır. İzin verilmezse konsolda "Permission was denied for this request to access the ... address space" yazar (noktaların yerinde local ya da loopback bulunur) ve CORS başlıkları tek başına bunu çözmez. Eski rehberlerdeki Access-Control-Allow-Private-Network başlığı, Chrome'un askıya aldığı Private Network Access önerisine aittir. Yerel ağ ve aynı cihaz (loopback) için ayrı izin istenip istenmediği gibi ayrıntılar Chrome sürümleri arasında hâlâ değişiyor; güncel davranışı Chrome belgelerinden kontrol edin.
- Yalnızca fetch değil. @font-face ile yüklenen web fontları, type="module" betikler ve crossorigin özniteliğiyle canvas'a çizilen görseller de CORS denetiminden geçer.
- CORS kimlik doğrulama değildir. API'nizi herhangi bir istemci çağırabilir; tarayıcı yanıtı sayfaya vermeyi reddetse bile sunucu basit istekleri işler. Kimlik doğrulama, yetkilendirme, CSRF koruması ve hız sınırlarına yine ihtiyacınız var.
Güvenlik kontrol listesi
- Her ortam için birebir eşleşen bir izin listesi (allow-list) tutun; canlı listede localhost bulunmasın.
- Gelen Origin değerini denetlemeden Allow-Credentials: true ile birlikte asla geri yansıtmayın. Şu birleşimlerin her biri tam olarak bunu yapar: Laravel'de allowed_origins ['*'] ile supports_credentials true; FastAPI/Starlette'te allow_origins=["*"] ile allow_credentials=True; django-cors-headers paketinde CORS_ALLOW_ALL_ORIGINS ile CORS_ALLOW_CREDENTIALS; Express cors paketinde origin: true ile credentials: true; ASP.NET Core'da SetIsOriginAllowed(_ => true) ile AllowCredentials; Spring'de allowedOriginPatterns("*") ile allowCredentials(true).
- Düzenli ifadeleri (regex) iki uçtan sabitleyin: ^https://(app|admin)\.example\.com$. /example\.com$/ deseni https://evilexample.com ile de eşleşir; startsWith('https://app.example.com') kontrolü https://app.example.com.evil.net adresini de kabul eder.
- null kökenine izin vermeyin.
- Yalnızca ön yüzün kullandığı yöntem ve başlıklara izin verin; yalnızca gereken yanıt başlıklarını dışarı açın (Expose-Headers).
- Allow-Origin isteğe göre değişiyorsa her zaman Vary: Origin gönderin.
- * değerini yalnızca kimlik bilgisi gerektirmeyen, gerçekten herkese açık kaynaklarda kullanın; kimlik doğrulama yerine ağ konumuna güvenen iç servislerde kullanmayın.
- Çerez kullanıyorsanız CSRF korumasını açık tutun ve reddedilen kökenleri loglayın; yanlış yapılandırmalar böylece erkenden ortaya çıkar.
Sık sorulan sorular
Postman ve curl ile çalışıyor, tarayıcıda neden çalışmıyor?CORS'u yalnızca tarayıcılar uygular. Postman ve curl yanıtı CORS başlıklarına bakmadan gösterir; bu yüzden sunucunuzun doğru yapılandırılıp yapılandırılmadığı hakkında bir şey söylemezler.
CORS hatasını ön yüz kodundan düzeltebilir miyim?
Hayır. Ya çağrıyı bir geliştirme proxy'si ya da reverse proxy ile aynı kökene taşırsınız ya da sunucunun doğru başlıkları göndermesini sağlarsınız. mode: 'no-cors' size yalnızca okuyamayacağınız opak bir yanıt verir.
Access-Control-Allow-Origin: * güvenli mi?
Kimlik bilgisi gerektirmeyen herkese açık veriler için evet; zaten çerezle çalışmaz. Kimlik doğrulama yerine ağ konumuna güvenen iç servislerde kullanmayın: CORS açısından * değeri, kullanıcının ziyaret ettiği her sayfanın yanıtı okumasına izin verir; önünde yalnızca Chrome'un yerel ağ erişimi izni gibi ayrı korumalar kalır.
Bearer token için credentials: 'include' gerekir mi?
Hayır. Bu seçenek çerezleri, HTTP kimlik doğrulamasını ve istemci sertifikalarını kapsar. Kendi eklediğiniz bir belirteç için Allow-Headers listesine Authorization'ı açıkça yazın ve bir ön kontrol isteği bekleyin.
Fazladan giden OPTIONS isteklerini nasıl azaltırım?
Access-Control-Max-Age ile ön kontrol sonucunu tarayıcının önbelleğine aldırın (Chromium bu süreyi 7200, Firefox 86400 saniyeyle sınırlar). Önbellek adres (URL) bazında tutulur; /orders/1 ile /orders/2 ayrı kayıtlardır. Gereksiz özel başlıklardan vazgeçin; uygulamayı ve API'yi tek kökenden sunmak ön kontrolleri tamamen ortadan kaldırır.
Sonuç ve sonraki adımlar
CORS hatası, tarayıcının "sunucu bu sayfanın kökenine izin vermedi" demesidir. Çalışma sırası hep aynıdır: konsol mesajını okuyun, DevTools'ta ön kontrol satırını bulun, curl ile yeniden üretin ve izni sunucuda, tek katmanda, birebir eşleşen bir izin listesiyle verin. Sıradaki adımlar: canlı ve geliştirme köken listelerinizi ayırın, hata yanıtlarının da CORS başlığı taşıdığını curl ile doğrulayın ve yerel geliştirmeyi Vite'in (ya da kullandığınız çatının) proxy'si arkasına alın. Ön yüzü ve API'si birlikte tasarlanması gereken kurumsal bir uygulama planlıyorsanız AIOR'un kurumsal ve endüstriyel yazılım hizmetinde yetkilendirme ve API entegrasyonları, kurulum ve bakımla birlikte tasarlanır. Kendi kurulumunuzda hata devam ederse konsoldaki mesajı olduğu gibi bu konuya yazın.You open the browser console and find "has been blocked by CORS policy". Your front end at http://localhost:5173 is calling an API at http://localhost:8000, or https://app.example.com is calling https://api.example.com in production, and the very same request works in curl and Postman. That contrast explains the error: CORS (Cross-Origin Resource Sharing) is enforced by the browser, and the permission has to come from the server that answers the request. This guide shows how to read the error, find the missing header with DevTools and curl, and fix the CORS error properly in Express, Django, FastAPI, Laravel, Spring Boot, ASP.NET Core, Nginx and Apache, without unsafe shortcuts.
Short answer: the API server must send Access-Control-Allow-Origin with your page's exact origin, answer the OPTIONS preflight with a 2xx status that lists the methods and headers you use, and, if you send cookies, add Access-Control-Allow-Credentials: true together with an explicit origin instead of *.
The CORS error messages in Chrome and Firefox
Chromium-based browsers such as Chrome and Edge print messages in this form. The text after "CORS policy:" tells you what failed:
Kod:
Access to fetch at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to XMLHttpRequest at 'https://api.example.com/v1/orders' from origin 'https://app.example.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to fetch at 'https://api.example.com/v1/me' from origin 'https://app.example.com' has been blocked by CORS policy: 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'.
Firefox reports the same failures in its own words:
Kod:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/v1/orders. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/v1/orders. (Reason: CORS preflight response did not succeed). Status code: 401.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at ‘https://api.example.com/v1/me’. (Reason: Credential is not supported if the CORS header ‘Access-Control-Allow-Origin’ is ‘*’).
Your JavaScript only receives a generic error: TypeError: Failed to fetch in Chrome, TypeError: NetworkError when attempting to fetch resource. in Firefox, or AxiosError: Network Error with axios. The browser hides the reason from scripts on purpose and prints it only to the console. Chrome 135 and earlier also appended "If an opaque response serves your needs, set the request's mode to 'no-cors'..." to the fetch version of the missing-header message; that suggestion does not solve anything (see the development section below).
What actually happens
- The browser sends the request (first a preflight, when one is required), receives the response and checks its CORS headers. If they don't allow your page's origin, your code never sees the response.
- When a preflight fails, the real request is never sent. When a simple request fails the check, the server has already received and possibly executed it. CORS decides who may read a response, not who may send a request.
- curl, Postman, mobile apps and server-to-server calls don't apply CORS at all, so "it works in Postman" proves nothing about your CORS setup.
- The fix lives on the server, or on the reverse proxy in front of it, which opts in by sending Access-Control-* headers. No front-end setting can grant that permission.
Origin basics: scheme, host and port
An origin is the triple scheme://host| Page | Request goes to | Same origin? | Why |
|---|---|---|---|
| https://app.example.com | https://app.example.com/api/orders | Yes | Only the path differs |
| http://localhost:3000 | http://localhost:8000 | No | Different port |
| http://localhost:5173 | http://127.0.0.1:5173 | No | Different host string |
| http://example.com | https://example.com | No | Different scheme |
| https://www.example.com | https://example.com | No | www and the apex domain are different hosts |
| https://app.example.com | https://api.example.com | No | Different subdomain, though still the same site (see the cookies section) |
The browser puts the page's origin in the Origin request header, with no path and no trailing slash. The server has to answer with exactly that string (or *) in Access-Control-Allow-Origin, so a configured value of https://app.example.com/ with a trailing slash never matches.
Simple requests vs preflighted requests
A request counts as "simple" and goes straight out only if all of the following are true:- The method is GET, HEAD or POST.
- Your code sets only CORS-safelisted headers: Accept, Accept-Language, Content-Language, Content-Type and a single-range Range header.
- Content-Type, if present, is application/x-www-form-urlencoded, multipart/form-data or text/plain.
- The body is not a ReadableStream, and no upload listeners are registered on XMLHttpRequest.
| Request header (browser) | Response header (server) | Rule |
|---|---|---|
| Origin | Access-Control-Allow-Origin | Exactly one value: the requesting origin or *; * is rejected for credentialed requests |
| Access-Control-Request-Method (preflight) | Access-Control-Allow-Methods | Must include the method; GET, HEAD and POST are always allowed |
| Access-Control-Request-Headers (preflight) | Access-Control-Allow-Headers | Must list every requested header; * is a wildcard only for requests without credentials, and the Fetch standard excludes Authorization from it |
| Cookies, credentials: 'include' | Access-Control-Allow-Credentials | The only valid value is true, and it needs an explicit origin |
| (a script reads a response header) | Access-Control-Expose-Headers | Needed for anything beyond Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma |
| (preflight caching) | Access-Control-Max-Age | Seconds; Chromium caps it at 7200, Firefox at 86400; the default is 5 |
| (proxies, CDNs, browser cache) | Vary: Origin | Send it whenever Allow-Origin depends on the request |
Browsers lowercase and sort the names in Access-Control-Request-Headers (for example authorization,content-type). Header names are case-insensitive, so the server may list them in any case. One caveat on the * wildcard: Chrome and Firefox still let it cover Authorization on requests without credentials, but because the standard says otherwise, both show a deprecation warning. List Authorization by name.
Diagnose a CORS error in five steps
- Find the right row in DevTools. Open the Network panel, choose the All filter and reproduce the error. Preflight rows belong to the Other category, so the Fetch/XHR filter hides them. In Chrome the failed call shows CORS error in the Status column, and hovering over it reveals the error code. Right-click a column header and add Method: a preflighted call then reads POST + Preflight, with a link to the OPTIONS row whose Type is preflight. Firefox lists the OPTIONS request as a separate row.
- Check the preflight status. It must be 2xx. A 301, 302, 307 or 308 means a redirect (Chrome says "Redirect is not allowed for a preflight request"); 401 or 403 means authentication or a WAF ran before CORS; 404 or 405 means nothing answers OPTIONS on that path; 5xx means the application failed.
- Read the response headers. Access-Control-Allow-Origin must match the page origin character for character and appear exactly once; Allow-Methods and Allow-Headers must cover what the browser asked for; Allow-Credentials must be true when cookies are involved.
- Reproduce it with curl, so you can test each server change without the browser (commands below).
- Check the error paths. 401, 404 and 500 responses must carry the CORS headers too. If they don't, the browser shows a CORS error instead of the real one.
Bash:
# 1) The preflight, exactly as the browser sends it
curl -i -X OPTIONS https://api.example.com/v1/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
# 2) The real request and an error response must carry the headers too
curl -i https://api.example.com/v1/orders -H "Origin: https://app.example.com"
curl -i https://api.example.com/v1/no-such-route -H "Origin: https://app.example.com"
A healthy preflight response looks roughly like this (header order and letter case vary by server):
Kod:
HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE
access-control-allow-headers: Authorization, Content-Type
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin
If the 404 or 401 response lacks access-control-allow-origin, fix that path too: always in Nginx, Header always in Apache, and the CORS middleware ahead of the application's error handlers. For consistent error bodies, see our REST API design guide.
Quick reference: message, cause, fix
| The message contains | Likely cause | Fix |
|---|---|---|
| No 'Access-Control-Allow-Origin' header is present | No CORS configuration for this path or origin, or the response is an error page from the proxy or the app | Add the origin to the allow-list; add the headers to error responses |
| Response to preflight request doesn't pass access control check | OPTIONS got 401/403/404/405 or came back without headers | Answer OPTIONS before authentication, with 204 and the Allow-* headers |
| Redirect is not allowed for a preflight request | http to https, trailing slash or login redirect | Call the final URL directly |
| must not be the wildcard '*' when the request's credentials mode is 'include' | Credentialed request answered with Allow-Origin: * | Echo the allowed origin and send Allow-Credentials: true |
| contains multiple values ..., but only one is allowed | Both the proxy and the app add the header, or several origins sit in one header | Handle CORS in one layer; send one origin per response |
| Method PUT is not allowed by Access-Control-Allow-Methods | The method is missing from the preflight response | Add it to the allowed methods |
| Request header field authorization is not allowed by Access-Control-Allow-Headers | The header is missing from Allow-Headers | List it by name; with credentials, * is not a wildcard |
| has a value '...' that is not equal to the supplied origin | A hard-coded origin differs (www, port, http vs https, trailing slash) | Match the origin string exactly |
CORS configuration for Express, Django, Laravel, Nginx and more
In each example, replace https://app.example.com with your front-end origin and keep localhost entries out of production. The snippets were checked in October 2026 against the documentation and source code of cors 2.8.6, django-cors-headers 4.9, Starlette's CORSMiddleware, Laravel 12 and 13, Spring Security 7.1, ASP.NET Core 10 and Vite 8.Express (Node.js): the cors middleware
Kod:
// npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
const allowedOrigins = ['https://app.example.com'];
if (process.env.NODE_ENV !== 'production') allowedOrigins.push('http://localhost:5173');
app.use(cors({
origin: allowedOrigins, // echoes a listed origin and adds Vary: Origin
credentials: true, // only if you use cookies
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Disposition'],
maxAge: 600,
}));
// authentication, rate limiting and routes go below this line
Django: django-cors-headers
Python:
# pip install django-cors-headers (settings.py)
INSTALLED_APPS = [
# ...
"corsheaders",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware", # as high as possible
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# ...
]
CORS_ALLOWED_ORIGINS = ["https://app.example.com", "http://localhost:5173"]
CORS_URLS_REGEX = r"^/api/.*$"
CORS_ALLOW_CREDENTIALS = True # session cookie across origins
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"] # unsafe methods with cookies
FastAPI (Starlette)
Python:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
Laravel: config/cors.php
The HandleCors middleware is already in Laravel's global middleware stack and answers OPTIONS requests. Since Laravel 11 the configuration file isn't published by default:
Bash:
php artisan config:publish cors
PHP:
// config/cors.php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => ['https://app.example.com'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
'exposed_headers' => [],
'max_age' => 600,
'supports_credentials' => true,
];
Spring Boot with Spring Security
Kod:
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.cors(Customizer.withDefaults()) // CORS runs before authentication
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
ASP.NET Core: AddCors and the UseCors order
Kod:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("Spa", policy => policy
.WithOrigins("https://app.example.com", "http://localhost:5173") // no trailing slash
.WithMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials());
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseRouting();
app.UseCors("Spa"); // after UseRouting, before authentication
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Nginx: map-based allow-list
Kod:
# http {} context: the allow-list
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"~^https://([a-z0-9-]+\.)?example\.com$" $http_origin; # only if you control every subdomain
}
server {
# ... listen, server_name, TLS ...
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_pass http://127.0.0.1:8080;
}
}
Apache: .htaccess or VirtualHost
Kod:
# needs mod_setenvif and mod_headers (mod_rewrite only for the OPTIONS answer)
SetEnvIf Origin "^https://(app|admin)\.example\.com$" CORS_ORIGIN=$0
Header always set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header always set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header always set Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE" env=CORS_ORIGIN
Header always set Access-Control-Allow-Headers "Authorization, Content-Type" env=CORS_ORIGIN
Header always merge Vary "Origin"
RewriteEngine On
RewriteCond %{REQUEST_METHOD} =OPTIONS
RewriteCond %{HTTP:Access-Control-Request-Method} .
RewriteRule ^ - [R=204,L]
Credentials and cookies
Start by deciding what you actually send. For CORS, credentials means cookies, HTTP authentication and TLS client certificates. A Bearer token that your own code puts in an Authorization header is not a credential in this sense: it needs neither credentials: 'include' nor Allow-Credentials, but Authorization must be listed explicitly in Allow-Headers, and it makes the request preflighted.- Client: fetch(url, { credentials: 'include' }); in axios, withCredentials: true (per request or through axios.defaults.withCredentials = true); in XMLHttpRequest, xhr.withCredentials = true. fetch defaults to 'same-origin', so cookies never travel to another origin unless you ask.
- Server: Access-Control-Allow-Credentials: true plus an Access-Control-Allow-Origin that echoes the exact origin. With credentials, * loses its wildcard meaning in Allow-Origin, Allow-Headers, Allow-Methods and Expose-Headers (in Allow-Origin it makes the browser reject the response outright).
- Cookies: cross-origin is not the same as cross-site. app.example.com and api.example.com are different origins but the same site (same registrable domain and scheme), so once CORS allows credentials, a SameSite=Lax cookie is sent. The registrable domain is determined by the Public Suffix List, so on shared platform domains such as github.io, vercel.app or herokuapp.com, two subdomains count as different sites. A call from myapp.com to api.vendor.io is cross-site: the cookie needs SameSite=None; Secure over HTTPS, and it is still subject to third-party cookie policy. Safari's tracking prevention restricts third-party cookies by default, Firefox gives each site its own cookie jar, and Chrome blocks them in Incognito or when users choose to.
In development: one origin through a proxy
The cleanest development setup needs no CORS at all: the dev server forwards everything under /api to the back end, so the browser only ever talks to one origin. With Vite:
Kod:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
These are not fixes:
- mode: 'no-cors'. The response is opaque: status 0, with headers and body hidden from JavaScript. Only GET, HEAD and POST are allowed, and the browser silently drops headers outside the safelist, such as Authorization or a Content-Type of application/json, so the server doesn't even receive the request you wrote.
- Disabling browser security. The --disable-web-security flag or "CORS unblock" extensions change only your own browser. Your users still get the error, and every other site you open in that browser runs without the same-origin policy.
- Public CORS proxies. Your tokens and data pass through a third party. They are acceptable for a quick demo with public data, never for production.
Gotchas that keep CORS errors coming back
- Authentication, a WAF or the CDN blocks the preflight. OPTIONS carries no cookies or Authorization, so authentication middleware that runs first returns 401. Put CORS first, allow the OPTIONS method in WAF and CDN rules, and test with curl from outside your network.
- Redirects. An http to https hop, a trailing-slash redirect (Django's APPEND_SLASH, the 301 rule in Laravel's default .htaccess, FastAPI's redirect_slashes with a 307) or a 302 to a login page breaks the preflight outright. For simple requests the redirect response itself needs CORS headers, and after a cross-origin redirect the Origin becomes null. If Chrome's message contains (redirected from '...'), this is your problem.
- Duplicate headers. When both the app and the proxy add Access-Control-Allow-Origin, Chrome reports multiple values. Configure CORS in one layer, and in Nginx strip the back end's copy with proxy_hide_header.
- Caching without Vary: Origin. A proxy or CDN that replays a response generated for another origin causes "not equal to the supplied origin". Send Vary: Origin whenever Allow-Origin varies. Some CDNs add Vary to the cache key only when told to; on Cloudflare that is the Vary setting in Cache Rules, added in July 2026. For public files that need no credentials, such as web fonts, Access-Control-Allow-Origin: * is the simplest answer.
- Pages opened from file://. Their Origin is null. Never allow null (sandboxed iframes and some redirects send it too); serve the page from a local web server instead, for example python3 -m http.server 8080.
- Chrome's Local Network Access restrictions. Since Chrome 142, a public website that sends requests to a private IP (192.168.x.x, 10.x.x.x) or to localhost needs the user's permission. If it is denied, the console reports "Permission was denied for this request to access the ... address space", with local or loopback in place of the dots, and CORS headers alone won't fix it. The Access-Control-Allow-Private-Network header in older guides belongs to the earlier Private Network Access proposal, which Chrome put on hold. Details such as whether local network and loopback (apps on the same device) are separate permissions are still changing between Chrome versions, so check Chrome's current documentation.
- It isn't just fetch. Web fonts loaded through @font-face, type="module" scripts and images drawn onto a canvas with the crossorigin attribute go through CORS as well.
- CORS is not authentication. Any client can call your API, and the server processes simple requests even when the browser refuses to hand the response to the page. You still need authentication, authorization, CSRF protection and rate limits.
Security checklist
- Keep an exact-match allow-list per environment, and keep localhost out of the production list.
- Never echo an arbitrary Origin together with Allow-Credentials: true. Each of these combinations does exactly that: Laravel allowed_origins ['*'] with supports_credentials true; FastAPI/Starlette allow_origins=["*"] with allow_credentials=True; django-cors-headers CORS_ALLOW_ALL_ORIGINS with CORS_ALLOW_CREDENTIALS; the Express cors package with origin: true and credentials: true; ASP.NET Core SetIsOriginAllowed(_ => true) with AllowCredentials; Spring allowedOriginPatterns("*") with allowCredentials(true).
- Anchor regular expressions at both ends: ^https://(app|admin)\.example\.com$. The pattern /example\.com$/ also matches https://evilexample.com, and a startsWith('https://app.example.com') check accepts https://app.example.com.evil.net.
- Never allow the null origin.
- Allow only the methods and headers your front end uses, and expose only the response headers it needs.
- Send Vary: Origin whenever Allow-Origin depends on the request.
- Use * only for genuinely public resources that need no credentials, never for internal services that rely on network location instead of authentication.
- With cookies, keep CSRF protection enabled, and log rejected origins so misconfigurations show up early.
FAQ
Why does it work in Postman and curl but not in the browser?Only browsers enforce CORS. Postman and curl show the response without looking at the CORS headers, so they say nothing about whether your server is configured correctly.
Can I fix a CORS error in front-end code?
No. Either move the call onto the same origin with a dev-server proxy or a reverse proxy, or have the server send the right headers. mode: 'no-cors' only gives you an opaque response you cannot read.
Is Access-Control-Allow-Origin: * safe?
For public data that needs no credentials, yes, and it doesn't work with cookies anyway. Avoid it on internal services that trust network location rather than authentication: as far as CORS is concerned, * lets any page the user visits read the response, and only separate protections such as Chrome's Local Network Access prompt stand in the way.
Do I need credentials: 'include' for a Bearer token?
No. That option covers cookies, HTTP authentication and client certificates. For a token you add yourself, list Authorization explicitly in Allow-Headers and expect a preflight.
How do I cut down the extra OPTIONS requests?
Let the browser cache the preflight with Access-Control-Max-Age (Chromium caps it at 7200 seconds, Firefox at 86400). The cache is per URL, so /orders/1 and /orders/2 are separate entries. Drop custom headers you don't need; serving the app and the API from one origin removes preflights entirely.