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 pemintaOrigin targetStatus
http://localhost:3000http://localhost:3000Sama origin — bebas
http://localhost:3000http://localhost:4000Beda port — lintas origin
https://tokobuku.examplehttps://api.tokobuku.exampleBeda host — lintas origin
https://tokobuku.examplehttp://tokobuku.exampleBeda 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:

text
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:3000

Mari kita telaah baris-baris pentingnya:

  1. Baris 12–16 (menerima preflight): server membaca Origin peminta beserta dua header keterangan Access-Control-Request-* — persis seperti server nyata membaca pertanyaan yang dikirim browser secara otomatis.
  2. 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, dan Access-Control-Max-Age: 600 yang menyuruh browser menyimpan jawaban ini 600 detik agar preflight tak diulang terus.
  3. 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-key menyatakan permintaan asli nanti membawa dua header itu.
  4. Baris 55–59 (permintaan asli): POST dengan JSON berjalan setelah izin diperoleh. Browser melakukan kedua langkah ini dalam satu pemanggilan fetch — dua permintaan di jaringan, satu baris kode di aplikasi.
  5. Baris 29–32 (izin pada respons asli): Access-Control-Allow-Origin juga 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, sehingga OPTIONS yang dikirim browser dijawab 404 atau 405 — permintaan asli bahkan tidak pernah dikirim. Banyak framework menyediakan middleware CORS yang menangani OPTIONS otomatis.
  • 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 menambahkan Access-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.