CORS and OPTIONS Preflight at the Edge
Learn NGINX In Action - Part 026
Production-grade CORS handling in NGINX, including OPTIONS preflight, origin allowlists, credentials, Vary, edge policy boundaries, and failure modes.
Part 026 — CORS dan OPTIONS Preflight di Edge tanpa Membuka Permukaan Serangan
CORS sering diperlakukan seperti masalah header:
Tambahkan Access-Control-Allow-Origin, selesai.
Itu cara berpikir yang berbahaya.
CORS adalah kontrak antara browser, origin frontend, dan server. NGINX hanya salah satu tempat untuk menegakkan kontrak itu. Jika CORS dipasang tanpa model security yang jelas, hasilnya bisa berupa data leakage, credential exposure, cache poisoning, atau API yang terlihat aman di curl tetapi gagal di browser.
1. Tujuan Part Ini
Setelah bagian ini, kamu harus bisa:
- memahami CORS sebagai browser-enforced policy, bukan authorization;
- membedakan simple request dan preflight request;
- menangani
OPTIONSsecara aman di NGINX; - membuat allowlist origin dengan
map, bukan wildcard sembarangan; - memahami kenapa
Access-Control-Allow-Origin: *tidak cocok dengan credentials; - menambahkan
Vary: Originsaat response bergantung pada origin; - menghindari
if+add_headerfootgun; - memutuskan kapan CORS lebih baik di app, gateway, atau NGINX;
- membuat test matrix untuk browser-like CORS behavior;
- menghindari cache dan CDN interaction yang salah.
2. CORS Bukan Auth
CORS menjawab pertanyaan:
Apakah browser boleh mengekspos response dari origin B
ke JavaScript yang berjalan di origin A?
CORS tidak menjawab:
Apakah user ini boleh membaca data?
Apakah token valid?
Apakah tenant ini boleh akses resource?
Apakah request berasal dari attacker?
Jadi ini salah:
Kami aman karena hanya allow origin frontend kami.
Yang benar:
CORS mengurangi permukaan browser cross-origin access,
tetapi authorization tetap harus ditegakkan di API.
Attacker tetap bisa memanggil API dari curl, server-side script, Postman, atau backend mereka sendiri. CORS hanya membatasi browser.
3. Origin: Unit Utama CORS
Origin adalah kombinasi:
scheme + host + port
Contoh berbeda origin:
| URL | Origin |
|---|---|
https://app.example.com | https://app.example.com |
http://app.example.com | berbeda karena scheme |
https://app.example.com:8443 | berbeda karena port |
https://api.example.com | berbeda karena host |
CORS bekerja dengan header Origin dari browser.
Contoh request:
GET /api/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Server bisa menjawab:
Access-Control-Allow-Origin: https://app.example.com
Jika browser menilai response sesuai policy, JavaScript boleh membaca response.
4. Simple Request vs Preflight Request
Browser tidak selalu mengirim preflight.
Simple-ish request
Contoh:
GET /api/products HTTP/1.1
Origin: https://app.example.com
Server harus menambahkan CORS response header pada actual response:
Access-Control-Allow-Origin: https://app.example.com
Preflight request
Browser mengirim OPTIONS sebelum actual request jika request dianggap non-simple, misalnya memakai method tertentu atau custom header.
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
Server menjawab izin:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: authorization,content-type
Access-Control-Max-Age: 600
Vary: Origin
Baru setelah itu browser mengirim actual POST.
5. Header CORS Utama
| Header | Response untuk | Fungsi |
|---|---|---|
Access-Control-Allow-Origin | preflight + actual | origin yang diizinkan membaca response |
Access-Control-Allow-Methods | preflight | method yang diizinkan |
Access-Control-Allow-Headers | preflight | request header yang diizinkan |
Access-Control-Allow-Credentials | preflight + actual | apakah credentials boleh digunakan |
Access-Control-Expose-Headers | actual | response header tambahan yang boleh dibaca JS |
Access-Control-Max-Age | preflight | durasi cache preflight browser |
Vary: Origin | cache-aware response | response berubah berdasarkan Origin |
6. Credentials: Cookie, Authorization, Client Certificate
Credentials dalam konteks browser bisa mencakup cookie, Authorization header, atau TLS client certificate behavior tertentu.
Jika frontend menggunakan:
fetch('https://api.example.com/me', {
credentials: 'include'
})
Server perlu:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://app.example.com
Yang tidak boleh:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Wildcard origin dan credentials adalah kombinasi berbahaya dan ditolak browser modern.
7. CORS Edge Policy: Kapan di NGINX, Kapan di App?
CORS cocok di NGINX jika:
- origin allowlist statis atau environment-level;
- semua endpoint dalam server block punya policy yang sama;
- preflight bisa dijawab tanpa business logic;
- kamu ingin mengurangi beban upstream untuk
OPTIONS; - API gateway/edge team adalah owner cross-origin policy.
CORS lebih baik di aplikasi jika:
- policy berbeda per tenant/user/app;
- allowed origin berasal dari database;
- method/header allowed tergantung resource;
- audit authorization dan CORS harus digabung;
- response expose headers bergantung pada domain logic.
Rule:
NGINX cocok untuk static edge policy.
Aplikasi cocok untuk dynamic business policy.
8. Anti-Pattern: Wildcard untuk Semua
Buruk:
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers '*' always;
Masalah:
- terlalu luas;
- tidak cocok dengan credentials;
- membuat API terlihat public browser-readable;
- menyembunyikan boundary sebenarnya;
- raw
*untuk headers/methods punya compatibility/security nuance; - tidak menambahkan
Vary: Originjika response sebenarnya dynamic.
Untuk public static assets, wildcard kadang valid. Untuk authenticated API, biasanya tidak.
9. Anti-Pattern: Reflect Origin Tanpa Allowlist
Buruk:
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
Ini berarti origin mana pun yang mengirim Origin akan diterima.
Attacker page:
https://evil.example
Mengirim:
Origin: https://evil.example
NGINX membalas:
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
Jika browser membawa cookie/session, ini bisa membuka data ke origin attacker bila authorization hanya berbasis cookie dan tidak ada CSRF/session protections yang memadai.
Reflect origin hanya aman jika origin sudah melewati allowlist.
10. Production Pattern: Origin Allowlist dengan map
Letakkan mapping di http context.
map $http_origin $cors_allowed_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
"https://partner.example.net" $http_origin;
}
Maknanya:
Jika Origin adalah salah satu yang diizinkan,
set response origin ke origin tersebut.
Jika tidak, kosong.
Lalu gunakan di server/location:
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Vary Origin always;
Namun ada nuance: jika $cors_allowed_origin kosong, NGINX dapat mengirim header kosong tergantung versi/behavior dan directive. Dalam banyak setup, lebih baik memisahkan allowed/denied path secara eksplisit dengan conditional return untuk preflight dan membiarkan actual response tanpa valid CORS header.
11. Production Pattern: API dengan Credentials
Contoh baseline:
# http context
map $http_origin $cors_allowed_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
map $cors_allowed_origin $cors_is_allowed {
default 0;
"" 0;
~.+ 1;
}
server {
listen 443 ssl http2;
server_name api.example.com;
location /api/ {
# Preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'authorization,content-type,x-request-id' always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
add_header Content-Length 0;
return 204;
}
# Actual response CORS headers
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers 'x-request-id' always;
add_header Vary Origin always;
proxy_pass http://api_backend;
}
}
Ini masih punya kelemahan: preflight dari origin yang tidak diizinkan dapat mendapat header kosong. Banyak browser akan menolak, tetapi logs dan policy lebih jelas jika kita menolak eksplisit.
12. Stricter Pattern: Tolak Preflight dari Origin Tidak Dikenal
map $http_origin $cors_allowed_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
map $cors_allowed_origin $cors_origin_allowed {
default 1;
"" 0;
}
server {
listen 443 ssl http2;
server_name api.example.com;
location /api/ {
if ($request_method = OPTIONS) {
if ($cors_origin_allowed = 0) {
return 403;
}
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'authorization,content-type,x-request-id' always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers 'x-request-id' always;
add_header Vary Origin always;
proxy_pass http://api_backend;
}
}
Catatan penting: nested if di NGINX dapat menjadi problematik. Pattern di atas menunjukkan intent, tetapi untuk production besar biasanya lebih baik menghindari nested if dan memindahkan kombinasi state ke map, named location, atau memproses preflight di app/gateway yang lebih ekspresif.
13. Safer NGINX Pattern tanpa Nested if: Pisahkan Method State
NGINX config language tidak sefleksibel bahasa pemrograman biasa. Untuk CORS kompleks, gunakan map sebagai decision table.
map $http_origin $cors_allowed_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
map "$request_method:$cors_allowed_origin" $cors_preflight_status {
default "";
"OPTIONS:" 403;
~^OPTIONS:.+ 204;
}
Lalu:
location /api/ {
if ($cors_preflight_status = 403) {
return 403;
}
if ($cors_preflight_status = 204) {
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'authorization,content-type,x-request-id' always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
add_header Content-Length 0;
return 204;
}
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers 'x-request-id' always;
add_header Vary Origin always;
proxy_pass http://api_backend;
}
Kita masih memakai if, tetapi hanya untuk return, yang jauh lebih aman daripada memakainya sebagai generic routing language.
14. add_header Inheritance Trap
add_header tidak selalu bersifat additive seperti yang banyak orang bayangkan.
Jika kamu punya:
server {
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
location /api/ {
add_header Access-Control-Allow-Origin https://app.example.com always;
proxy_pass http://api;
}
}
Directive add_header di location bisa membuat header dari parent tidak diwariskan, tergantung versi dan inheritance directive yang tersedia.
Akibatnya, menambahkan CORS di location bisa tanpa sengaja menghapus security headers.
Production discipline:
Jika menambah add_header di child context,
review seluruh header yang harus tetap ada di response context tersebut.
Atau gunakan snippet yang mengulang security headers di context yang memerlukan CORS.
15. always: Kenapa Penting
Tanpa always, add_header hanya berlaku untuk status code tertentu.
Untuk CORS actual response, error response juga sering perlu CORS header agar browser bisa membaca error payload. Tanpa itu, frontend hanya melihat CORS error, bukan error API yang sebenarnya.
Gunakan:
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
Trade-off:
- membantu debugging frontend;
- menjaga error contract;
- tetapi jangan membuat unauthorized origin mendapat header yang valid.
16. Vary: Origin: Cache Correctness
Jika response header Access-Control-Allow-Origin bergantung pada request Origin, maka cache harus tahu bahwa response berbeda per origin.
Tambahkan:
add_header Vary Origin always;
Tanpa Vary: Origin, proxy/CDN/browser cache bisa menyimpan response untuk satu origin lalu memberikannya ke origin lain dengan header yang salah.
Jika response juga bergantung pada Accept-Encoding, idealnya Vary harus mencakup keduanya. Hati-hati karena beberapa layer dapat menggabungkan atau mengganti Vary.
17. Preflight Harus Cepat, Tapi Tidak Boleh Bohong
Preflight idealnya dijawab di edge karena:
- tidak perlu membangunkan upstream;
- mengurangi latency;
- mengurangi noise log aplikasi;
- mudah dicache browser dengan
Access-Control-Max-Age.
Tetapi preflight tidak boleh mengizinkan sesuatu yang actual request akan tolak secara struktural.
Buruk:
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Padahal endpoint hanya mendukung GET.
Browser akan mengirim actual DELETE, lalu API mengembalikan 405. Ini bukan security breach langsung, tapi policy edge menjadi tidak akurat.
Untuk API besar, pertimbangkan:
- CORS global longgar tapi auth ketat;
- CORS per route di app;
- generated NGINX config dari OpenAPI spec;
- gateway policy engine.
18. Allow-Headers: Jangan Reflect Sembarangan
Buruk:
add_header Access-Control-Allow-Headers $http_access_control_request_headers always;
Ini merefleksikan semua header yang diminta browser.
Lebih aman:
add_header Access-Control-Allow-Headers 'authorization,content-type,x-request-id,x-csrf-token' always;
Jika benar-benar perlu dynamic allowlist, gunakan policy yang membatasi header.
Header seperti authorization punya implikasi lebih besar daripada x-request-id. Jangan satukan semua tanpa review.
19. Allow-Methods: Jangan Klaim Semua Jika Tidak Semua Didukung
Untuk read-only public API:
add_header Access-Control-Allow-Methods 'GET,HEAD,OPTIONS' always;
Untuk authenticated CRUD API:
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
Jika endpoint heterogen, global method list bisa misleading. Pilih antara:
- location per API group;
- app-level CORS;
- generated edge config.
20. Access-Control-Max-Age: Cache Preflight dengan Sadar
Contoh:
add_header Access-Control-Max-Age 600 always;
Trade-off:
| Nilai | Pro | Kontra |
|---|---|---|
| rendah | cepat propagate policy change | lebih banyak preflight |
| tinggi | latency lebih rendah | rollback policy lebih lambat di browser |
Untuk sistem dengan policy sering berubah, jangan terlalu tinggi.
Untuk stable public API, nilai lebih tinggi bisa masuk akal.
21. CORS untuk Public Static Assets
Untuk font, image, wasm, atau public asset, wildcard bisa valid.
location /assets/ {
root /srv/www;
try_files $uri =404;
add_header Access-Control-Allow-Origin * always;
add_header Cross-Origin-Resource-Policy cross-origin always;
}
Tetapi jangan copy pola ini ke authenticated API.
Static asset policy dan API policy harus dipisahkan.
22. CORS untuk Fonts
Browser sering membutuhkan CORS untuk font cross-origin.
location ~* \.(woff2|woff|ttf|otf)$ {
root /srv/www;
try_files $uri =404;
add_header Access-Control-Allow-Origin * always;
expires 1y;
add_header Cache-Control "public, immutable" always;
}
Jika font hanya untuk domain internal tertentu, gunakan allowlist origin, bukan wildcard.
23. CORS dan CSRF: Jangan Campur
CORS dan CSRF berbeda.
CORS membatasi browser membaca response cross-origin.
CSRF membatasi browser mengirim request yang memanfaatkan ambient credentials seperti cookie.
Jika API memakai cookie session:
- CORS allowlist tetap perlu;
- SameSite cookie policy perlu;
- CSRF token mungkin perlu;
- state-changing method harus protected;
- Authorization tetap harus di backend.
Jangan menganggap CORS menggantikan CSRF defense.
24. CORS dan Authorization Header
Jika frontend mengirim bearer token:
Authorization: Bearer <token>
Preflight biasanya akan meminta:
Access-Control-Request-Headers: authorization
Server perlu:
Access-Control-Allow-Headers: authorization,content-type
Tetapi mengizinkan header authorization secara CORS tidak memvalidasi token. Itu hanya memberi izin browser untuk mengirim actual request dengan header tersebut.
25. CORS dan Reverse Proxy
Jika NGINX berada di depan upstream yang juga menambahkan CORS header, bisa terjadi duplicate/conflicting headers.
Contoh buruk:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Origin: *
Browser behavior bisa gagal atau tidak sesuai harapan.
Pilihan desain:
Option A: CORS hanya di NGINX; upstream tidak menulis CORS.
Option B: CORS hanya di app; NGINX tidak menulis CORS.
Option C: NGINX handles preflight, app handles actual response. Harus sangat disiplin.
Jika NGINX menjadi owner CORS, sembunyikan upstream CORS header:
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Methods;
Lalu tambahkan policy edge sendiri.
26. CORS dan Error Response
Tanpa always, error dari upstream mungkin tidak membawa CORS header.
Frontend melihat:
CORS error
Padahal server mengirim:
401 Unauthorized
Untuk developer experience dan observability frontend, actual error response dari allowed origin sebaiknya tetap membawa CORS header.
add_header Access-Control-Allow-Origin $cors_allowed_origin always;
add_header Access-Control-Allow-Credentials true always;
Tetapi jangan menambahkan valid CORS header untuk origin yang tidak diizinkan.
27. CORS dan CDN/Cache
Jika API response bisa dicache dan CORS origin dynamic, wajib pikirkan cache key.
Minimal:
add_header Vary Origin always;
Jika memakai NGINX proxy cache:
proxy_cache_key "$scheme$request_method$host$request_uri$http_origin";
Tetapi memasukkan origin ke cache key dapat memperbanyak cache cardinality. Untuk public resources yang aman untuk semua origin, gunakan wildcard agar cache tidak terfragmentasi.
Decision:
| Resource | ACAO | Cache Key |
|---|---|---|
| public immutable asset | * | tanpa origin |
| public API but origin-restricted | reflected allowlist | include/Vary: Origin |
| authenticated API | biasanya no shared cache | origin-aware, private/no-store |
28. CORS dan SameSite Cookie
Jika cookie session dipakai cross-site, browser cookie policy juga menentukan apakah cookie dikirim.
Umumnya perlu:
Set-Cookie: session=...; Secure; HttpOnly; SameSite=None
Tapi ini bukan konfigurasi CORS semata. NGINX bisa meneruskan atau memodifikasi cookie header, tetapi keputusan session security biasanya milik aplikasi/auth layer.
Jangan mencoba menyelesaikan cookie/session security hanya dengan CORS header.
29. Handling OPTIONS untuk Non-CORS
Tidak semua OPTIONS adalah CORS preflight.
CORS preflight biasanya memiliki:
Origin: ...
Access-Control-Request-Method: ...
Jika request OPTIONS tidak punya header tersebut, itu mungkin health check, client introspection, atau generic HTTP options.
Jangan selalu menjawab semua OPTIONS dengan CORS response jika API membutuhkan behavior lain.
Pattern lebih ketat:
map "$request_method:$http_origin:$http_access_control_request_method" $is_cors_preflight {
default 0;
~^OPTIONS:https?://.+:.+ 1;
}
30. Logging CORS
Tambahkan field untuk debugging:
log_format api_json escape=json
'{'
'"ts":"$time_iso8601",'
'"method":"$request_method",'
'"host":"$host",'
'"uri":"$uri",'
'"origin":"$http_origin",'
'"acr_method":"$http_access_control_request_method",'
'"acr_headers":"$http_access_control_request_headers",'
'"cors_allowed_origin":"$cors_allowed_origin",'
'"status":$status,'
'"request_id":"$request_id"'
'}';
Saat frontend berkata “CORS error”, log ini membantu membedakan:
- origin tidak allowed;
- method tidak allowed;
- header tidak allowed;
- actual response error tanpa CORS header;
- CDN menghapus/mengubah header;
- upstream mengirim duplicate CORS header.
31. Test dengan curl: Preflight
curl -i -X OPTIONS 'https://api.example.com/api/orders' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'
Expected:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
Access-Control-Allow-Headers: authorization,content-type,x-request-id
Access-Control-Max-Age: 600
Vary: Origin
Denied origin:
curl -i -X OPTIONS 'https://api.example.com/api/orders' \
-H 'Origin: https://evil.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization,content-type'
Expected:
HTTP/1.1 403 Forbidden
Atau response tanpa valid CORS allow headers, tergantung policy. Untuk observability, 403 lebih jelas.
32. Test dengan curl: Actual Request
curl -i 'https://api.example.com/api/me' \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer test'
Expected:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Denied origin:
curl -i 'https://api.example.com/api/me' \
-H 'Origin: https://evil.example' \
-H 'Authorization: Bearer test'
Expected options:
- no
Access-Control-Allow-Origin; browser blocks JS access; - or explicit 403 at edge if policy says deny early.
Choose one and make it consistent.
33. Browser Test
curl is not the browser. It does not enforce CORS.
Minimal browser test:
<script>
fetch('https://api.example.com/api/me', {
method: 'GET',
credentials: 'include',
headers: {
'X-Request-ID': crypto.randomUUID()
}
})
.then(async r => {
console.log('status', r.status);
console.log('x-request-id', r.headers.get('x-request-id'));
console.log(await r.text());
})
.catch(e => console.error('fetch failed', e));
</script>
Test from:
- allowed origin;
- denied origin;
- HTTP vs HTTPS;
- port variant;
- subdomain variant;
- credential include vs omit.
34. Production Config Example: Static + API Split
# http context
map $http_origin $api_cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
listen 443 ssl http2;
server_name example.com;
# Public immutable assets: broad CORS is acceptable.
location /assets/ {
root /srv/www/app;
try_files $uri =404;
add_header Access-Control-Allow-Origin * always;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# API: origin allowlist and credentials.
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $api_cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods 'GET,POST,PUT,PATCH,DELETE,OPTIONS' always;
add_header Access-Control-Allow-Headers 'authorization,content-type,x-request-id,x-csrf-token' always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
add_header Content-Length 0;
return 204;
}
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Methods;
add_header Access-Control-Allow-Origin $api_cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers 'x-request-id' always;
add_header Vary Origin always;
proxy_set_header Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_pass http://api_backend;
}
}
Review before production:
- Should denied origins receive 403 or no CORS header?
- Should
OPTIONSbe proxied for some endpoints? - Are security headers preserved after adding CORS headers?
- Is
Varycorrect? - Are upstream CORS headers hidden?
- Are credentials actually required?
35. Better Pattern for Large API: Generate CORS from API Contract
If you have OpenAPI specs, route ownership, and method definitions, generate CORS policy instead of hand-writing global method lists.
Pipeline:
Generated config can answer:
- allowed origins per environment;
- allowed methods per path group;
- allowed headers per API family;
- max-age per stability class;
- expose headers per response contract.
This is platform engineering, not manual header copy-paste.
36. CORS Failure Modes
| Symptom | Likely Cause |
|---|---|
| Browser says CORS error, curl works | Missing/invalid CORS header |
| Preflight 404/405 | OPTIONS proxied to app that doesn't handle it |
| Preflight passes, actual fails | Missing CORS headers on actual response/error |
| Works without credentials, fails with credentials | wildcard origin or missing Allow-Credentials |
| Works for one origin, fails for another | allowlist mismatch or missing port/scheme |
| Random cache behavior | missing Vary: Origin |
| Duplicate ACAO header | both app and NGINX set CORS |
| Header not present on 401/500 | missing always |
| Security headers disappear | add_header inheritance trap |
37. Incident Playbook: “CORS Broken”
- Reproduce preflight with
curl -i -X OPTIONS. - Reproduce actual request with
curl -iandOriginheader. - Compare allowed origin exact string: scheme, host, port.
- Check method in
Access-Control-Request-Method. - Check requested headers casing/list.
- Check whether response has duplicate CORS headers.
- Check whether status is 401/403/500 and header missing because no
always. - Check CDN/load balancer modifies headers.
- Check NGINX effective config with
nginx -T. - Check browser devtools Network tab for preflight and actual request separately.
- Confirm whether credentials are used by frontend.
- Confirm
Vary: Originand cache behavior.
38. Review Checklist
Sebelum merge CORS config:
- Apakah CORS memang perlu di NGINX, bukan app?
- Apakah API memakai credentials?
- Apakah wildcard origin benar-benar aman untuk resource ini?
- Apakah origin allowlist exact mencakup scheme dan port?
- Apakah denied origin behavior eksplisit?
- Apakah preflight dan actual response sama-sama punya policy?
- Apakah error response dari allowed origin tetap membawa CORS header?
- Apakah
Vary: Originditambahkan bila origin dynamic? - Apakah
add_headermenghapus inherited security headers? - Apakah upstream CORS header disembunyikan atau dibiarkan sengaja?
- Apakah
Access-Control-Allow-Headersterlalu luas? - Apakah
Access-Control-Max-Agesesuai risiko rollback? - Apakah OPTIONS non-CORS punya behavior yang benar?
- Apakah ada test browser, bukan hanya curl?
39. Decision Matrix
| Scenario | Recommended CORS Strategy |
|---|---|
| Public static assets | Access-Control-Allow-Origin: * |
| Public font files | usually wildcard, cache long |
| Authenticated API with cookies | exact allowlist + credentials + CSRF/session protections |
| Bearer-token API for known SPA | exact allowlist + allow authorization |
| Multi-tenant dynamic origins | app/gateway dynamic policy, not static NGINX map unless generated |
| Partner API | explicit partner origin allowlist, low max-age during onboarding |
| Internal admin UI | strict origin list, no wildcard, credentials carefully reviewed |
| Highly dynamic per-user policy | application-level CORS |
| CDN-cached API | Vary: Origin and cache-key review |
40. Final Mental Model
NGINX hanya membantu di titik policy edge. Ia tidak menggantikan authorization, CSRF protection, token validation, tenant isolation, atau audit domain.
41. Ringkasan
CORS adalah browser-enforced access policy, bukan authentication dan bukan authorization.
Untuk public static assets, wildcard bisa masuk akal.
Untuk authenticated API, gunakan exact origin allowlist, Access-Control-Allow-Credentials: true hanya jika benar-benar perlu, dan jangan gabungkan credentials dengan wildcard origin.
Preflight OPTIONS bisa dijawab di NGINX untuk efisiensi, tetapi policy harus benar. Jangan mengizinkan method/header yang tidak sesuai dengan API contract.
Jika response CORS bergantung pada Origin, tambahkan Vary: Origin dan review cache behavior.
Jika app dan NGINX sama-sama menulis CORS header, pilih owner tunggal atau sembunyikan header upstream secara eksplisit.
CORS config yang matang bukan kumpulan header. Ia adalah contract boundary antara browser, frontend, edge, upstream, cache, dan security model.
42. Referensi Resmi
- MDN CORS Guide: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
- MDN
Access-Control-Allow-Credentials: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials - MDN
Access-Control-Allow-Methods: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods - MDN
Access-Control-Allow-Headers: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers - NGINX
ngx_http_headers_module: https://nginx.org/en/docs/http/ngx_http_headers_module.html - NGINX
ngx_http_map_module: https://nginx.org/en/docs/http/ngx_http_map_module.html - NGINX
ngx_http_rewrite_module: https://nginx.org/en/docs/http/ngx_http_rewrite_module.html
You just completed lesson 26 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.