karawaci.kode

2026-08-30 · 10 min

CORS yang Benar untuk API Publik dan Private dalam Satu Express/Hono Backend

Satu setup CORS yang salah bisa membuat API publik Anda tidak bisa diakses dari mana pun, atau sebaliknya—membolehkan sembarang origin mengakses endpoint private yang seharusnya terkunci. Saya sudah melihat kedua masalah ini di production, dan keduanya berasal dari asumsi yang sama: CORS itu cukup diset satu kali, berlaku untuk semua endpoint.

Kenapa satu konfigurasi CORS tidak cukup

Hampir semua backend modern punya dua kelompok endpoint dengan kebutuhan yang berbeda:

API publik — diakses oleh frontend pihak ketiga, widget embed, atau mitra yang domain-nya tidak Anda kontrol. Contoh: endpoint harga produk, search publik, status halaman. Untuk ini, Anda ingin origin lebih longgar—mungkin wildcard atau daftar domain mitra.

API private — diakses hanya oleh frontend Anda sendiri. Contoh: endpoint profil user, transaksi, manajemen data. Untuk ini, Anda ingin origin dikunci ketat ke domain produksi Anda dan tidak boleh bocor ke domain lain.

Kesalahan umum: memakai satu cors() middleware di level app.use() dengan satu konfigurasi. Kalau Anda set origin: '*' untuk endpoint publik, seluruh API ikut terbuka. Kalau Anda kunci ke satu domain, widget pihak ketiga tidak bisa bekerja.

Solusinya adalah cors per-route, bukan per-app.

Setup dasar: Express dengan dua tier CORS

// cors-config.ts
import cors from 'cors';
import type { CorsOptions } from 'cors';

const PRIVATE_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '')
  .split(',')
  .map(o => o.trim())
  .filter(Boolean);

// Untuk API private: hanya origin milik kita sendiri, dengan credentials
export const privateCors = cors({
  origin: (origin, callback) => {
    // Allow server-to-server (no origin header) dan origin yang terdaftar
    if (!origin || PRIVATE_ORIGINS.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`Origin ${origin} tidak diizinkan`));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  maxAge: 86400, // cache preflight 24 jam
});

// Untuk API publik: wildcard tanpa credentials
export const publicCors = cors({
  origin: '*',
  methods: ['GET', 'OPTIONS'],
  allowedHeaders: ['Content-Type'],
  maxAge: 86400,
});
// app.ts
import express from 'express';
import { privateCors, publicCors } from './cors-config';

const app = express();
app.use(express.json());

// Tidak ada app.use(cors()) di sini — CORS di-apply per-route

// Rute publik: siapa saja boleh baca, tidak ada credentials
const publicRouter = express.Router();
publicRouter.use(publicCors);

publicRouter.get('/prices', handler.getPrices);
publicRouter.get('/status', handler.getStatus);
publicRouter.get('/search', handler.publicSearch);

// Rute private: hanya origin terdaftar
const privateRouter = express.Router();
privateRouter.use(privateCors);

privateRouter.get('/me', auth.requireSession, handler.getProfile);
privateRouter.post('/transactions', auth.requireSession, handler.createTransaction);
privateRouter.delete('/account', auth.requireSession, handler.deleteAccount);

app.use('/api/v1/public', publicRouter);
app.use('/api/v1', privateRouter);

Preflight OPTIONS ditangani otomatis oleh package cors saat Anda pakai router.use(cors(...)). Pastikan router Anda tidak ada middleware yang memblok OPTIONS sebelum cors dijalankan.

Ekuivalen di Hono

Hono punya built-in CORS middleware yang lebih ergonomis. Pola yang sama berlaku:

// app.ts (Hono)
import { Hono } from 'hono';
import { cors } from 'hono/cors';

const app = new Hono();

const PRIVATE_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map(o => o.trim());

// API publik
const publicApi = new Hono();
publicApi.use('*', cors({
  origin: '*',
  allowMethods: ['GET', 'OPTIONS'],
  allowHeaders: ['Content-Type'],
  maxAge: 86400,
}));

publicApi.get('/prices', getPricesHandler);
publicApi.get('/status', getStatusHandler);

// API private
const privateApi = new Hono();
privateApi.use('*', cors({
  origin: (origin) => {
    if (!origin || PRIVATE_ORIGINS.includes(origin)) return origin ?? '';
    return ''; // string kosong = blok
  },
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
  credentials: true,
  maxAge: 86400,
}));

privateApi.use('*', authMiddleware);
privateApi.get('/me', getMeHandler);
privateApi.post('/transactions', createTransactionHandler);

app.route('/api/v1/public', publicApi);
app.route('/api/v1', privateApi);

export default app;

Satu perbedaan penting Hono vs Express: di Hono, fungsi origin harus mengembalikan string (origin yang diizinkan) atau string kosong untuk blok, bukan callback dengan error. Mengembalikan string kosong akan menyebabkan browser tidak mendapat Access-Control-Allow-Origin yang valid dan request diblok—efeknya sama dengan melempar error di Express.

Menangani Vary: Origin dengan benar

Ini bagian yang paling sering dilewatkan dan menjadi bencana di belakang CDN. Kalau backend Anda mengembalikan Access-Control-Allow-Origin secara dinamis (berdasarkan origin request), Anda wajib mengirim Vary: Origin.

Tanpanya, Cloudflare atau Nginx bisa meng-cache response dari https://app.perusahaan.com dan mengembalikannya ke request dari https://mitra.co.id—yang menyebabkan browser melihat header CORS salah.

Package cors di Express sudah otomatis menambahkan Vary: Origin ketika konfigurasi origin berupa fungsi atau array. Tapi kalau Anda menulis CORS logic manual, tambahkan sendiri:

// Middleware manual—jangan lupa Vary
app.use((req, res, next) => {
  const origin = req.headers.origin ?? '';
  if (PRIVATE_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
  }
  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
    res.setHeader('Access-Control-Max-Age', '86400');
    return res.sendStatus(204);
  }
  next();
});

Di Cloudflare, pastikan cache rule Anda tidak menyimpan response berdasarkan URL saja—sertakan Vary sebagai cache key atau nonaktifkan caching untuk endpoint API.

Environment variable untuk allowed origins

Jangan hardcode domain di kode. Di production, ALLOWED_ORIGINS Anda mungkin berbeda antara staging, production, dan development:

# .env.production
ALLOWED_ORIGINS=https://app.perusahaan.com,https://dashboard.perusahaan.com

# .env.staging
ALLOWED_ORIGINS=https://staging.perusahaan.com,https://preview.perusahaan.com

# .env.development
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

Satu utility kecil untuk parsing dan validasi:

// config/origins.ts
function parseAllowedOrigins(raw: string | undefined): string[] {
  if (!raw) {
    if (process.env.NODE_ENV === 'production') {
      throw new Error('ALLOWED_ORIGINS harus diset di production');
    }
    return ['http://localhost:3000', 'http://localhost:5173'];
  }
  return raw.split(',').map(o => o.trim()).filter(o => {
    try { new URL(o); return true; } catch { return false; }
  });
}

export const ALLOWED_ORIGINS = parseAllowedOrigins(process.env.ALLOWED_ORIGINS);

Memvalidasi format URL saat startup mencegah typo domain (https://app.perusahaan.com dengan trailing space) yang menyebabkan CORS gagal secara diam-diam.

Trade-off yang perlu diakui

Wildcard untuk API publik punya batasan nyata. Begitu endpoint publik Anda perlu menerima API key via Authorization header—misalnya untuk rate limiting per-key atau tracking penggunaan mitra—Anda tidak bisa lagi memakai origin: '*'. Anda harus bergeser ke daftar origin mitra yang terdaftar, dengan credentials: true. Ini artinya setiap mitra harus mendaftarkan domain mereka ke Anda, yang menambah overhead operasional.

maxAge dan caching preflight. maxAge: 86400 berarti browser meng-cache hasil preflight selama 24 jam. Ini bagus untuk performa, tapi kalau Anda mengubah allowedHeaders atau allowMethods, pengguna yang sudah punya cache preflight lama akan terus menggunakan konfigurasi lama selama sehari. Saat migrasi, turunkan maxAge ke 60 detik seminggu sebelum perubahan, baru kembalikan ke 86400 setelahnya.

Server-to-server calls melewati CORS. Kalau backend lain memanggil API Anda, mereka tidak mengirim Origin header sehingga CORS middleware tidak memblok mereka. Autentikasi server-to-server harus dijaga oleh mekanisme lain: JWT dengan audience claim, mutual TLS, atau IP allowlist. CORS bukan kontrol akses—ia hanya browser policy.

Satu hal yang sering salah di monorepo

Di setup monorepo dengan Turborepo di mana frontend dan backend berbeda port saat development, pengembang sering menambah http://localhost:* ke allowed origins dengan regex. Hati-hati: package cors menerima RegExp sebagai nilai origin, tapi /^http:\/\/localhost/ akan membolehkan http://localhost.evil.com juga karena regex tidak mengecek batas domain.

Lebih aman: list explicit port yang dipakai:

origin: [
  'http://localhost:3000',
  'http://localhost:5173',
  'http://localhost:4321',
],

Daftar ini hanya ada di .env.development dan tidak masuk ke production build.

Verdict

Setup yang saya rekomendasikan untuk backend Express/Hono dengan dua tier akses:

  1. Jangan pasang cors() di level app—pakai per-router atau per-route.
  2. API publik tanpa credentials: origin: '*', methods terbatas ke GET saja kecuali memang butuh lebih.
  3. API private dengan credentials: origin sebagai fungsi atau array dari env var, credentials: true, Vary: Origin otomatis.
  4. Validasi env var saat startup—jangan biarkan typo atau domain kosong lolos ke production.
  5. Turunkan maxAge sebelum mengubah allowed headers—jangan kaget kenapa perubahan CORS tidak efektif buat sebagian user.

Klien Jakarta tempat saya membantu audit API mereka punya satu app.use(cors({ origin: '*', credentials: true })) di production—yang sebenarnya tidak berfungsi sama sekali karena browser menolak credentials: true dengan wildcard origin. Mereka tidak sadar selama berbulan-bulan karena Postman tidak pernah mengeluh. Kalau backend Anda ada di kondisi serupa, curl -I -X OPTIONS https://api.domain.com/v1/me -H "Origin: https://app.domain.com" adalah command pertama yang perlu dijalankan sekarang.

Ditulis oleh Reza Pradipta