Cookie session yang aman di Express.js bukan sekadar menyalakan middleware session lalu selesai. Agar session tahan terhadap pencurian cookie, session fixation, dan serangan CSRF, Anda perlu mengatur atribut cookie dengan benar, menyimpan session di server-side store, meregenerasi session setelah login, serta memvalidasi request yang mengubah state.

Untuk aplikasi web tradisional atau SPA yang berjalan di domain yang sama, session cookie sering lebih sederhana dan lebih aman secara operasional dibanding JWT yang disimpan di browser. Session ID tetap kecil, mudah diinvalidasi di server, dan rotasi kredensial lebih mudah dikendalikan. Tantangannya ada pada konfigurasi: HttpOnly, Secure, SameSite, expiry, reverse proxy, dan proteksi CSRF harus disusun sebagai satu paket.

Kapan memilih session cookie dibanding JWT

Session cookie biasanya lebih cocok jika:

  • Aplikasi adalah web app biasa, dashboard internal, atau SPA yang mengakses backend pada origin yang sama atau masih dalam satu kontrol domain.
  • Anda ingin proses logout dan invalidasi sesi dilakukan seketika di server.
  • Anda tidak ingin memaparkan token akses ke JavaScript browser.
  • Anda membutuhkan kontrol session per perangkat, expiry idle, atau revocation yang mudah.

JWT lebih masuk akal jika:

  • Anda benar-benar membutuhkan token stateless untuk komunikasi antar layanan atau klien non-browser.
  • Ada arsitektur yang menuntut token dibawa ke banyak resource server yang berbeda.
  • Anda siap menangani trade-off seperti revocation yang lebih rumit, ukuran token lebih besar, dan risiko penyimpanan token di sisi klien.

Untuk banyak aplikasi Express.js berbasis browser, pendekatan yang paling aman dan mudah dikelola adalah: session ID acak di cookie HttpOnly + data session di server-side store.

Fondasi session yang aman di Express.js

Gunakan session store server-side, jangan MemoryStore untuk produksi

Di produksi, jangan menyimpan session di memory bawaan proses Node.js. Memory store hanya cocok untuk development karena tidak tahan restart, tidak cocok untuk multi-instance, dan bisa menyebabkan masalah memori.

Pilih store server-side seperti Redis atau database yang mendukung TTL/expiry. Yang penting bukan nama produknya, melainkan karakteristik berikut:

  • Mendukung expiry yang konsisten.
  • Bisa dipakai lintas instance aplikasi.
  • Mudah dihapus saat logout atau revocation.
  • Punya latency yang wajar untuk traffic aplikasi Anda.

Atribut cookie yang wajib dipahami

Beberapa atribut cookie menentukan apakah browser akan menyimpan, mengirim, atau memblokir cookie Anda.

  • HttpOnly: mencegah cookie dibaca JavaScript melalui document.cookie. Ini mengurangi dampak XSS terhadap pencurian session cookie, walau tidak menghilangkan risiko XSS itu sendiri.
  • Secure: cookie hanya dikirim lewat HTTPS. Untuk produksi, ini sebaiknya selalu aktif.
  • SameSite: mengontrol apakah cookie ikut terkirim dalam request lintas situs. Ini kunci untuk mitigasi CSRF.
  • Domain: menentukan domain mana yang boleh menerima cookie. Jangan terlalu luas jika tidak perlu.
  • Path: membatasi jalur URL tempat cookie berlaku.
  • Max-Age/Expires: menentukan umur cookie. Sesuaikan dengan kebutuhan UX dan keamanan.

Memilih nilai SameSite

SameSite adalah salah satu kontrol paling penting untuk cookie session.

  • Lax: cocok untuk banyak aplikasi web biasa. Cookie umumnya tidak ikut pada request lintas situs yang bersifat berbahaya seperti POST dari form pihak ketiga, tetapi masih lebih nyaman untuk navigasi normal.
  • Strict: lebih ketat, namun bisa mengganggu UX, misalnya ketika user datang dari link eksternal lalu ternyata sesi tidak ikut terbawa dalam konteks tertentu.
  • None: dipakai jika cookie memang harus dikirim lintas situs, misalnya skenario cross-site tertentu. Nilai ini harus dipasangkan dengan Secure.

Jika aplikasi Anda berada di satu situs yang sama dan tidak punya kebutuhan cross-site, mulai dari SameSite=Lax biasanya merupakan pilihan praktis yang aman.

Konfigurasi Express yang realistis

Berikut contoh konfigurasi ringkas menggunakan middleware session. Contoh ini fokus pada prinsip, bukan pada store tertentu.

const express = require('express');
const session = require('express-session');

const app = express();

app.set('trust proxy', 1); // penting jika di belakang reverse proxy
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

app.use(session({
  name: 'sid',
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 1000 * 60 * 30
  },
  store: sessionStore
}));

Penjelasan penting:

  • saveUninitialized: false mencegah session kosong dibuat untuk semua pengunjung, sehingga mengurangi session tidak perlu dan membantu kepatuhan privasi.
  • resave: false menghindari penulisan ulang session yang tidak berubah, tergantung perilaku store yang dipakai.
  • rolling: true dapat dipakai jika Anda ingin memperbarui umur cookie pada setiap request aktif. Ini meningkatkan UX untuk session idle timeout, tetapi menambah penulisan cookie dan perlu dipadukan dengan expiry di store.
  • trust proxy dibutuhkan jika Express berjalan di belakang load balancer, ingress, atau reverse proxy terminasi TLS. Tanpa ini, aplikasi bisa salah mendeteksi koneksi sebagai non-HTTPS dan cookie Secure gagal bekerja sesuai harapan.

Domain, path, dan scope cookie

Prinsipnya: beri scope sesempit mungkin.

  • Jika hanya app.example.com yang memerlukan session, jangan set domain ke .example.com tanpa alasan kuat.
  • Jika cookie hanya relevan untuk area tertentu, gunakan path yang lebih sempit. Namun untuk session login umum, / biasanya paling praktis.
  • Membuat domain terlalu luas memperbesar permukaan risiko antar-subdomain.

Expiry cookie vs expiry session store

Jangan hanya mengandalkan expiry di browser. Session server-side juga harus punya masa berlaku. Jika cookie sudah hilang tetapi data session tetap hidup terlalu lama di store, Anda membuang resource. Sebaliknya, jika cookie masih ada tetapi session di store sudah expired, user akan terlihat seperti logout mendadak.

Praktik umum:

  • Gunakan idle timeout untuk session aktif pengguna biasa.
  • Pertimbangkan absolute timeout untuk membatasi sesi yang terlalu lama, terutama pada aplikasi sensitif.
  • Selaraskan TTL di session store dengan kebijakan cookie.

Mitigasi session fixation dan rotasi session ID

Regenerasi session setelah login

Session fixation terjadi ketika penyerang berhasil membuat korban memakai session ID yang sudah diketahui sebelumnya. Setelah korban login, penyerang bisa memakai ID yang sama jika aplikasi tidak mengganti session ID.

Mitigasinya jelas: regenerasi session segera setelah autentikasi berhasil.

app.post('/login', async (req, res, next) => {
  try {
    const user = await verifyCredentials(req.body.email, req.body.password);
    if (!user) return res.status(401).json({ error: 'Kredensial tidak valid' });

    req.session.regenerate((err) => {
      if (err) return next(err);

      req.session.user = {
        id: user.id,
        role: user.role
      };

      req.session.authenticatedAt = Date.now();
      res.json({ ok: true });
    });
  } catch (err) {
    next(err);
  }
});

Kenapa ini efektif? Karena session anonim sebelum login dibuang dan diganti dengan session ID baru yang tidak diketahui pihak lain.

Kapan melakukan rotasi session ID lagi

Selain setelah login, rotasi session ID juga masuk akal saat:

  • Privilege user berubah, misalnya dari user biasa menjadi admin setelah re-authentication.
  • Peristiwa keamanan penting terjadi, misalnya reset password atau perubahan faktor autentikasi.
  • Anda ingin mengurangi masa pakai session ID untuk sesi yang lama.

Namun jangan merotasi terlalu agresif pada setiap request tanpa alasan. Itu menambah kompleksitas sinkronisasi session store, bisa memunculkan race condition pada request paralel, dan berpotensi mengganggu UX.

Strategi rotasi yang praktis

Pendekatan yang sering masuk akal:

  • Regenerasi saat login berhasil.
  • Regenerasi saat privilege meningkat.
  • Gunakan timeout idle yang wajar.
  • Untuk aplikasi sensitif, tambahkan absolute timeout dan minta login ulang setelah periode tertentu.

Proteksi CSRF untuk request yang mengubah state

Jika aplikasi Anda memakai cookie untuk autentikasi, browser akan otomatis mengirim cookie pada request yang sesuai. Itulah sebabnya endpoint seperti POST, PUT, PATCH, dan DELETE perlu perlindungan CSRF.

Lapisan pertama: SameSite

SameSite=Lax atau Strict sudah membantu mengurangi banyak skenario CSRF. Tetapi untuk endpoint sensitif, jangan mengandalkan SameSite saja sebagai satu-satunya pertahanan, terutama jika aplikasi punya kebutuhan kompatibilitas tertentu.

Lapisan kedua: CSRF token

Gunakan token CSRF untuk semua request state-changing. Pola yang umum:

  1. Server membuat token acak dan menyimpannya di session.
  2. Frontend mengirim token tersebut pada form hidden field atau header khusus seperti X-CSRF-Token.
  3. Server memverifikasi bahwa token dari request cocok dengan token di session.

Contoh middleware sederhana:

const crypto = require('crypto');

function ensureCsrfToken(req, res, next) {
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString('hex');
  }
  res.locals.csrfToken = req.session.csrfToken;
  next();
}

function requireCsrf(req, res, next) {
  const method = req.method.toUpperCase();
  const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
  if (safeMethods.includes(method)) return next();

  const token = req.get('x-csrf-token') || req.body._csrf;
  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ error: 'CSRF token tidak valid' });
  }

  next();
}

Middleware ini sengaja sederhana. Di produksi, pastikan alur rendering token ke frontend atau pengiriman ke SPA Anda konsisten, dan pertimbangkan regenerasi token pada event tertentu.

Validasi Origin atau Referer

Untuk request state-changing dari browser, memeriksa header Origin adalah lapisan tambahan yang berguna. Jika Origin ada, Anda bisa menolak request dari origin yang tidak diizinkan. Jika Origin tidak tersedia, sebagian aplikasi memeriksa Referer dengan hati-hati.

Contoh pendekatan defensif:

function requireTrustedOrigin(req, res, next) {
  const method = req.method.toUpperCase();
  if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return next();

  const origin = req.get('origin');
  const allowed = new Set(['https://app.example.com']);

  if (origin && !allowed.has(origin)) {
    return res.status(403).json({ error: 'Origin tidak diizinkan' });
  }

  next();
}

Catatan penting: validasi Origin/Referer adalah pelengkap, bukan pengganti CSRF token. Ada kondisi di mana header ini tidak selalu tersedia atau tidak cocok dijadikan satu-satunya kontrol.

Logout dan invalidasi session yang benar

Logout yang benar tidak cukup hanya menghapus cookie di browser. Anda juga harus menghancurkan session di store server-side.

app.post('/logout', (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);

    res.clearCookie('sid', {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      path: '/'
    });

    res.json({ ok: true });
  });
});

Perhatikan bahwa parameter saat clearCookie sebaiknya konsisten dengan scope cookie saat dibuat, terutama path dan domain bila Anda mengaturnya. Jika tidak cocok, browser bisa gagal menghapus cookie yang dimaksud.

Untuk aplikasi yang lebih sensitif, pertimbangkan juga:

  • Invalidasi semua sesi aktif pengguna setelah reset password.
  • Menyediakan fitur “logout dari semua perangkat”.
  • Mencatat metadata sesi seperti waktu login terakhir dan user agent secara terbatas untuk keperluan audit.

Checklist deployment di belakang reverse proxy dan HTTPS

Banyak masalah cookie session di Express.js bukan berasal dari kode login, melainkan dari deployment.

  • Pastikan aplikasi benar-benar dilayani lewat HTTPS di lingkungan produksi.
  • Jika TLS diterminasi di reverse proxy, aktifkan app.set('trust proxy', 1) atau konfigurasi setara yang sesuai dengan topologi Anda.
  • Gunakan Secure untuk cookie produksi.
  • Jika memakai SameSite=None, pastikan Secure juga aktif, jika tidak cookie akan ditolak browser modern.
  • Selaraskan domain publik aplikasi, proxy, dan konfigurasi cookie. Perbedaan host bisa membuat cookie tidak tersimpan atau tidak terkirim.
  • Jika frontend dan backend berada di origin berbeda, periksa juga konfigurasi CORS dan kredensial request browser. Tanpa pengaturan yang cocok, cookie bisa tidak ikut terkirim.

Kesalahan umum yang membuat cookie tidak terkirim

1. Secure aktif tetapi request dianggap HTTP

Ini sering terjadi di belakang load balancer atau ingress. Browser hanya mengirim cookie Secure lewat HTTPS, dan aplikasi perlu mengetahui bahwa koneksi aslinya memang aman. Jika Express tidak percaya pada proxy yang terminasi TLS, perilaku session bisa membingungkan.

2. SameSite terlalu ketat untuk alur aplikasi

Jika Anda memakai Strict, beberapa alur login atau perpindahan dari situs lain bisa terasa rusak. Jangan pilih nilai paling ketat tanpa menguji UX nyata.

3. SameSite=None tanpa Secure

Browser modern umumnya menolak kombinasi ini. Akibatnya cookie tampak “tidak pernah tersimpan”.

4. Domain atau path tidak cocok

Cookie yang dibuat untuk host atau path tertentu tidak akan terkirim di luar scope tersebut. Ini juga sering menyebabkan clearCookie gagal menghapus cookie lama.

5. Session store tidak persisten atau TTL tidak sinkron

User punya cookie yang masih valid, tetapi data session sudah hilang dari store. Dari sisi pengguna, hasilnya terlihat seperti logout acak.

6. Frontend cross-origin tidak mengirim kredensial

Dalam skenario frontend dan backend berbeda origin, browser tidak otomatis mengirim cookie kecuali request memang diatur untuk membawa kredensial, dan server mengizinkannya. Ini bukan masalah Express semata, melainkan interaksi browser, CORS, dan cookie policy.

7. Logout hanya menghapus cookie, tidak menghancurkan session

Jika session di store masih hidup, ada risiko sesi tetap dapat digunakan selama cookie masih tersedia atau dipulihkan.

Trade-off UX vs keamanan

Tidak ada satu konfigurasi yang selalu ideal. Anda perlu menyesuaikan dengan tingkat risiko aplikasi.

  • SameSite=Strict memberi proteksi lebih kuat, tetapi bisa mengorbankan alur pengguna tertentu.
  • Idle timeout pendek memperkecil jendela penyalahgunaan sesi, tetapi meningkatkan frekuensi login ulang.
  • Rotasi session ID lebih sering meningkatkan keamanan pada kondisi tertentu, tetapi menambah kompleksitas dan peluang bug pada request paralel.
  • Absolute timeout baik untuk aplikasi sensitif, tetapi kadang mengganggu pekerjaan pengguna yang berlangsung lama.

Prinsip praktisnya: pilih default yang aman, lalu uji dampaknya pada alur login, checkout, admin panel, dan integrasi frontend Anda.

Rekomendasi konfigurasi minimal yang masuk akal

Jika Anda membutuhkan titik awal yang aman untuk aplikasi web biasa di Express.js:

  • Session ID acak di cookie HttpOnly.
  • Secure=true di produksi.
  • SameSite=Lax untuk default yang seimbang.
  • Session store server-side yang mendukung TTL.
  • Regenerasi session setelah login berhasil.
  • CSRF token untuk semua request yang mengubah state.
  • Validasi Origin sebagai lapisan tambahan pada endpoint sensitif.
  • Logout dengan destroy session + clear cookie.
  • TTL cookie dan session store yang konsisten.
  • Konfigurasi reverse proxy/HTTPS yang benar.

Jika aplikasi Anda benar-benar membutuhkan alur cross-site, uji dengan cermat kombinasi SameSite=None, Secure, CORS, dan kredensial browser. Banyak bug session pada praktiknya muncul dari kombinasi ini, bukan dari middleware session itu sendiri.

Penutup

Mengamankan cookie session yang aman di Express.js berarti menyusun beberapa kontrol sekaligus: atribut cookie yang tepat, store server-side, regenerasi session untuk mencegah fixation, rotasi yang masuk akal, perlindungan CSRF, dan invalidasi sesi yang benar saat logout. Untuk aplikasi web berbasis browser, pendekatan ini sering lebih mudah diaudit dan lebih mudah dioperasikan daripada JWT yang hidup di sisi klien.

Jika Anda mulai dari dasar yang benar, sebagian besar risiko umum pada autentikasi berbasis cookie bisa dikurangi tanpa membuat arsitektur menjadi rumit. Fokus utamanya bukan pada banyaknya library, melainkan pada scope cookie yang tepat, lifecycle session yang jelas, dan deployment HTTPS yang konsisten.