Skenario yang sering menghantui pemula: aplikasi frontend berjalan di http://localhost:3000, API toko buku berjalan di http://localhost:4000, dan fetch antar keduanya ditolak browser dengan pesan panjang tentang “CORS policy” — padahal permintaan yang sama berhasil mulus ketika diuji lewat terminal. Penjelasannya ada pada satu pilar keamanan web: same-origin policy. Chapter ini menjelaskan mengapa batasan itu ada, bagaimana mekanisme CORS melonggarkannya secara terkendali, dan pola kesalahan yang paling sering muncul.
Satu catatan metode sebelum mulai: penegakan CORS terjadi di browser, bukan di Node. Karena itu contoh kode chapter ini memperlihatkan sisi yang bisa diuji di terminal — percakapan header antara client dan server tiruan — lalu perilaku browsernya dijelaskan sesuai spesifikasi, tanpa simulasi galat palsu.
Same-Origin Policy: Default yang Ketat #
Origin adalah identitas sumber sebuah halaman: gabungan scheme + host + port. http://localhost:3000 dan http://localhost:4000 adalah dua origin berbeda, meski host-nya sama; begitu pula https://tokobuku.example berbeda dengan http://tokobuku.example.
Browser menerapkan aturan same-origin policy: skrip dari satu origin bebas membaca respons dari origin yang sama, tetapi diblokir dari membaca respons origin lain. Aturan ini melindungi pengguna dari skenario nyata: bila Anda sedang login ke panel bank di satu tab, halaman jahat di tab lain tidak boleh diam-diam membaca data akun Anda lewat fetch.
| Origin peminta | Origin target | Status |
|---|---|---|
http://localhost:3000 | http://localhost:3000 | Sama origin — bebas |
http://localhost:3000 | http://localhost:4000 | Beda port — lintas origin |
https://tokobuku.example | https://api.tokobuku.example | Beda host — lintas origin |
https://tokobuku.example | http://tokobuku.example | Beda scheme — lintas origin |
Penting dipahami dengan tepat: policy ini membatasi pembacaan hasil oleh skrip, bukan pengiriman permintaannya. Server lintas origin tetap menerima dan memproses permintaan; browser-lah yang menahan hasilnya agar tak sampai ke skrip. Itu sebabnya galat CORS kadang terlihat aneh — log server mencatat permintaan sukses, sementara console browser menyatakan diblokir.
CORS: Izin yang Dinyatakan Server #
CORS (Cross-Origin Resource Sharing) adalah kesepakatan berbasis header untuk melonggarkan policy itu secara eksplisit. Alurnya: browser otomatis melampirkan header Origin pada setiap permintaan lintas origin, dan server menjawab dengan header Access-Control-Allow-Origin yang menyatakan origin mana yang boleh membaca. Bila nilai izin cocok dengan origin peminta, browser meloloskan hasilnya ke skrip.
Server bisa mengirim satu origin tertentu — Access-Control-Allow-Origin: http://localhost:3000 — atau tanda bintang * yang berarti semua origin, dengan syarat permintaan tidak membawa kredensial seperti cookie.
Preflight: Tanya Dulu, Kirim Kemudian #
Untuk permintaan “sederhana” (misalnya GET tanpa header khusus), browser langsung mengirim lalu memeriksa izin pada balasannya. Tetapi untuk permintaan berpotensi berdampak — POST dengan Content-Type: application/json, PUT, DELETE, atau permintaan dengan header kustom — browser lebih dulu mengirim preflight: permintaan OPTIONS yang bertanya “bolehkah aku melakukan POST dengan header ini?”.
Preflight membawa dua header keterangan — Access-Control-Request-Method dan Access-Control-Request-Headers — dan server menjawab dengan izin terperinci. Karena Node fetch tidak menegakkan CORS, berkas contoh menuliskan langkah browser itu secara manual agar kita bisa melihat balasan servernya:
// preflight-tiruan.mjs
// Sisi server dari percakapan CORS: preflight OPTIONS lalu permintaan asli.
// Node fetch tidak menegakkan CORS (itu tugas browser), jadi di sini kita
// mengirim header Origin/preflight secara manual untuk melihat balasan server.
// Jalankan di terminal: node preflight-tiruan.mjs (tanpa internet).
import http from "node:http";
const server = http.createServer((req, res) => {
const origin = req.headers.origin;
if (req.method === "OPTIONS") {
// Preflight: browser bertanya "bolehkah saya POST dengan header ini?"
console.log("SERVER OPTIONS dari origin :", origin);
console.log("SERVER ingin method :", req.headers["access-control-request-method"]);
console.log("SERVER ingin header :", req.headers["access-control-request-headers"]);
res.writeHead(204, {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type, X-Api-Key",
"Access-Control-Max-Age": "600",
});
res.end();
return;
}
// Permintaan asli setelah preflight disetujui.
console.log("SERVER POST dari origin :", origin);
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": origin,
});
res.end(JSON.stringify({ sumber: "api-toko-tiruan", status: "ok" }));
});
server.listen(0, "127.0.0.1", async () => {
const port = server.address().port;
const dasar = `http://127.0.0.1:${port}`;
const originAplikasi = "http://localhost:3000";
// Di browser, dua langkah di bawah terjadi otomatis — di Node kita tulis manual.
const preflight = await fetch(`${dasar}/produk`, {
method: "OPTIONS",
headers: {
Origin: originAplikasi,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type, x-api-key",
},
});
console.log("preflight ->", preflight.status);
console.log(" boleh origin :", preflight.headers.get("access-control-allow-origin"));
console.log(" boleh method :", preflight.headers.get("access-control-allow-methods"));
console.log(" boleh header :", preflight.headers.get("access-control-allow-headers"));
const asli = await fetch(`${dasar}/produk`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: originAplikasi },
body: JSON.stringify({ produk: "Kamus Bahasa" }),
});
console.log("permintaan asli ->", asli.status, "dengan izin:", asli.headers.get("access-control-allow-origin"));
server.closeAllConnections();
server.close();
});Output yang Dihasilkan:
SERVER OPTIONS dari origin : http://localhost:3000
SERVER ingin method : POST
SERVER ingin header : content-type, x-api-key
preflight -> 204
boleh origin : http://localhost:3000
boleh method : GET, POST
boleh header : Content-Type, X-Api-Key
SERVER POST dari origin : http://localhost:3000
permintaan asli -> 200 dengan izin: http://localhost:3000Mari kita telaah baris-baris pentingnya:
- Baris 12–16 (menerima preflight): server membaca
Originpeminta beserta dua header keteranganAccess-Control-Request-*— persis seperti server nyata membaca pertanyaan yang dikirim browser secara otomatis. - Baris 17–22 (izin dijawab): server merespons
204 No Content(preflight tidak butuh body) dengan empat header izin: origin yang diloloskan, daftar method, daftar header, danAccess-Control-Max-Age: 600yang menyuruh browser menyimpan jawaban ini 600 detik agar preflight tak diulang terus. - Baris 42–49 (preflight manual): di terminal kita menulis sendiri apa yang browser kirim diam-diam. Nilai
Access-Control-Request-Headers: content-type, x-api-keymenyatakan permintaan asli nanti membawa dua header itu. - Baris 55–59 (permintaan asli):
POSTdengan JSON berjalan setelah izin diperoleh. Browser melakukan kedua langkah ini dalam satu pemanggilanfetch— dua permintaan di jaringan, satu baris kode di aplikasi. - Baris 29–32 (izin pada respons asli):
Access-Control-Allow-Originjuga harus ada pada respons final; izin preflight saja tidak cukup, karena browser memeriksa label izin di setiap respons yang akan dibaca skrip.
Pola Kesalahan CORS yang Paling Sering Terjadi #
Satu detail tambahan tentang izin yang menyebut origin persis: bila respons semacam itu tersimpan di cache, cache harus tahu bahwa isi respons bergantung pada header Origin peminta — itulah gunanya header Vary: Origin yang biasanya dikirim bersama izin. Tanpa itu, cache bisa mengabulkan origin A dengan izin yang sebenarnya milik origin B, dan browser menolaknya dengan galat yang membingungkan.
Ketiga pola berikut menjelaskan hampir semua keluhan “CORS gagal” — masing-masing dikenali dari pesan console browser:
- Server tidak mengirim
Access-Control-Allow-Origin. Browser menampilkan galat berbunyi kurang lebih: request has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Perbaikannya selalu di sisi server: tambahkan header izin, bukan memaksa client. - Preflight tidak tertangani. Rute hanya menampung
POST, sehinggaOPTIONSyang dikirim browser dijawab404atau405— permintaan asli bahkan tidak pernah dikirim. Banyak framework menyediakan middleware CORS yang menanganiOPTIONSotomatis. - Bintang bertabrakan dengan kredensial.
Access-Control-Allow-Origin: *tidak boleh dipadukan dengan permintaan berkredensial (cookie). Browser menolak kombinasi itu; server harus menyebut origin persis dan menambahkanAccess-Control-Allow-Credentials: true.
Dengan CORS, peta keamanan percakapan HTTP menjadi lengkap: origin menentukan siapa yang boleh membaca, header menentukan izinnya. Chapter berikutnya beralih ke kontrol waktu — membatalkan permintaan yang tak lagi dibutuhkan dengan AbortController.