Jika aplikasi Next.js Anda memakai cookie session untuk login, maka CSRF Protection tetap perlu dipikirkan untuk endpoint yang mengubah data, termasuk Server Actions dan Route Handler. Masalahnya bukan apakah browser bisa membaca respons lintas origin, tetapi apakah browser bisa mengirim request yang sah beserta cookie ke server Anda tanpa sepengetahuan pengguna.

Pendekatan yang paling praktis untuk Next.js: CSRF Protection untuk Server Actions dan Route Handler adalah menggabungkan beberapa lapis mitigasi: cek Origin/Referer, double-submit token untuk request berbasis form atau cookie session, custom header untuk AJAX dari origin yang sama, dan pembatasan CORS agar API tidak diam-diam membuka jalan bagi request lintas situs. Tidak ada satu teknik yang cocok untuk semua jalur request; pilih berdasarkan cara endpoint dipanggil.

Apa yang sebenarnya diserang pada CSRF

CSRF terjadi ketika situs lain membuat browser korban mengirim request ke aplikasi Anda dalam kondisi masih login. Karena cookie session dikelola browser, request tersebut bisa tetap membawa sesi pengguna. Jika endpoint target melakukan mutasi seperti mengubah email, membuat order, menghapus data, atau logout, maka aksi itu dapat berjalan atas nama korban.

Poin pentingnya: same-origin policy tidak mencegah pengiriman request. Kebijakan itu terutama membatasi pembacaan respons. Jadi meskipun penyerang tidak bisa membaca hasilnya, ia tetap bisa memicu aksi yang merugikan bila server mempercayai cookie session tanpa verifikasi tambahan.

Kapan CSRF relevan di Next.js App Router

  • Server Actions yang dipicu form dan menggunakan sesi berbasis cookie.
  • Route Handler seperti app/api/profile/route.ts yang menerima POST, PUT, PATCH, atau DELETE.
  • Endpoint autentikasi seperti ubah password, logout, link akun, atau aktivasi fitur sensitif.
  • Mutasi data yang dipanggil dari client dengan fetch tetapi tetap mengandalkan cookie session.

Mengapa SameSite cookie membantu, tetapi tidak cukup

SameSite pada cookie adalah lapisan penting, tetapi jangan memperlakukannya sebagai satu-satunya perlindungan.

Ringkasan perilaku yang relevan

  • SameSite=Strict paling ketat. Cookie umumnya tidak dikirim pada navigasi lintas situs biasa.
  • SameSite=Lax cukup membantu untuk banyak kasus dan biasanya masih membolehkan beberapa navigasi tingkat atas tertentu.
  • SameSite=None mengizinkan pengiriman lintas situs, biasanya dipakai bila memang perlu konteks pihak ketiga dan harus digabung dengan Secure.

Masalahnya, keputusan keamanan tidak sebaiknya bergantung penuh pada asumsi perilaku cookie di semua alur browser, proxy, embedded webview, atau integrasi lintas domain. Selain itu, tim sering memiliki lebih dari satu cookie, subdomain, atau endpoint legacy yang tidak konsisten atributnya. Karena itu, cek sisi server tetap diperlukan untuk endpoint mutasi.

Prinsip praktis: anggap SameSite sebagai pengurang risiko, bukan pengganti validasi CSRF.

Perbedaan risiko: form POST, fetch client, dan request lintas origin

1) Form POST biasa

Form HTML dari situs lain dapat mengirim POST ke aplikasi Anda tanpa perlu JavaScript. Ini adalah jalur klasik CSRF. Jika endpoint Anda menerima request berbasis cookie dan tidak mengecek asal request atau token, maka risiko tinggi.

2) fetch dari client di origin yang sama

Jika request dikirim dari JavaScript aplikasi Anda sendiri, Anda bisa menambahkan custom header seperti X-CSRF-Token. Ini membantu karena situs penyerang biasanya tidak bisa sembarang mengirim request dengan header non-sederhana tanpa melewati preflight CORS, dan browser akan menahan request jika server tidak mengizinkan origin tersebut.

Namun, custom header hanya efektif untuk jalur yang memang selalu dipanggil lewat JavaScript. Ia tidak melindungi form HTML yang submit langsung tanpa JS.

3) Request lintas origin

Jika Anda dengan sengaja membuka API lintas origin, Anda memperbesar permukaan serangan. Kombinasi yang berbahaya adalah:

  • mengizinkan origin terlalu luas,
  • mengizinkan credentials, dan
  • mengandalkan cookie session untuk autentikasi.

Pada situasi ini, CSRF dan salah konfigurasi CORS bisa saling memperburuk dampak. Jika API memang untuk browser lintas origin, pertimbangkan model autentikasi yang tidak bergantung pada cookie session otomatis, atau perketat daftar origin dan validasi token secara eksplisit.

Pola mitigasi yang realistis untuk App Router

1) Cek Origin dan Referer

Untuk request mutasi, cek header Origin. Jika tidak ada, fallback ke Referer dengan hati-hati. Terima hanya origin yang Anda kenal, misalnya domain produksi dan domain lokal pengembangan.

Kelebihan: sederhana, murah, dan efektif untuk banyak kasus modern.
Kekurangan: ada request tertentu yang mungkin tidak membawa header seperti yang diharapkan, sehingga Anda perlu kebijakan fallback yang jelas.

2) Double-submit token

Server membuat token acak, menyimpannya di cookie non-HttpOnly, lalu klien mengirim token yang sama lewat form field tersembunyi atau header. Server membandingkan nilai di cookie dan body/header. Karena situs lain tidak bisa membaca cookie korban, ia sulit menebak token yang benar.

Kelebihan: cocok untuk form dan AJAX, tidak wajib menyimpan state token di server.
Kekurangan: perlu disiplin implementasi; token harus tersedia ke client, dan Anda tetap harus memikirkan rotasi serta validasi yang konsisten.

3) Custom header untuk AJAX same-origin

Untuk request fetch dari aplikasi Anda sendiri, kirim X-CSRF-Token atau header serupa. Cocok untuk Route Handler yang dipanggil dari komponen client.

Kelebihan: enak untuk SPA-like flow.
Kekurangan: tidak menyelesaikan kasus form submit tanpa JavaScript.

4) Batasi CORS secara ketat

Jika endpoint tidak perlu diakses lintas origin, jangan set header CORS yang membuka akses. Jika memang perlu, whitelist origin secara eksplisit, jangan gunakan wildcard untuk endpoint yang memakai credentials.

Implementasi dasar: utilitas CSRF yang ringkas

Contoh berikut memakai pendekatan double-submit token plus Origin/Referer check. Contoh ini sengaja ringkas agar pola utamanya jelas.

// lib/csrf.ts
import { cookies, headers } from 'next/headers'
import crypto from 'node:crypto'

const CSRF_COOKIE = 'csrf-token'
const ALLOWED_ORIGINS = new Set([
  'http://localhost:3000',
  'https://app.example.com',
])

export function generateCsrfToken() {
  return crypto.randomBytes(32).toString('base64url')
}

export async function setCsrfCookie() {
  const token = generateCsrfToken()
  const store = await cookies()

  store.set(CSRF_COOKIE, token, {
    httpOnly: false,
    secure: true,
    sameSite: 'lax',
    path: '/',
  })

  return token
}

export async function readCsrfCookie() {
  const store = await cookies()
  return store.get(CSRF_COOKIE)?.value ?? null
}

export async function assertAllowedOrigin() {
  const h = await headers()
  const origin = h.get('origin')
  const referer = h.get('referer')

  if (origin) {
    if (!ALLOWED_ORIGINS.has(origin)) {
      throw new Error('Invalid origin')
    }
    return
  }

  if (referer) {
    const url = new URL(referer)
    if (!ALLOWED_ORIGINS.has(url.origin)) {
      throw new Error('Invalid referer')
    }
    return
  }

  throw new Error('Missing origin and referer')
}

export function safeEqual(a: string, b: string) {
  const ab = Buffer.from(a)
  const bb = Buffer.from(b)
  if (ab.length !== bb.length) return false
  return crypto.timingSafeEqual(ab, bb)
}

export async function assertCsrfToken(submittedToken: string | null) {
  const cookieToken = await readCsrfCookie()
  if (!cookieToken || !submittedToken) {
    throw new Error('Missing CSRF token')
  }
  if (!safeEqual(cookieToken, submittedToken)) {
    throw new Error('Invalid CSRF token')
  }
}

Beberapa catatan penting dari contoh di atas:

  • httpOnly: false diperlukan bila token ingin dibaca JavaScript untuk dikirim sebagai header. Ini normal untuk pola double-submit, karena token CSRF bukan rahasia setara session cookie.
  • Jangan simpan session utama di cookie yang bisa dibaca JavaScript hanya demi CSRF.
  • secure: true sebaiknya dipakai di HTTPS. Saat development lokal, Anda mungkin perlu penyesuaian.
  • sameSite: 'lax' membantu, tetapi bukan satu-satunya kontrol.

Menyetel token dari middleware

Middleware cocok untuk memastikan pengguna selalu punya cookie CSRF saat membuka aplikasi. Tujuannya bukan memvalidasi semua request di middleware, melainkan bootstrap token secara konsisten.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import crypto from 'node:crypto'

export function middleware(req: NextRequest) {
  const res = NextResponse.next()

  const hasToken = req.cookies.get('csrf-token')?.value
  if (!hasToken) {
    res.cookies.set('csrf-token', crypto.randomBytes(32).toString('base64url'), {
      httpOnly: false,
      secure: true,
      sameSite: 'lax',
      path: '/',
    })
  }

  return res
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Trade-off: middleware memudahkan distribusi token, tetapi jangan memaksakan semua logika verifikasi di sini. Server Action dan Route Handler tetap harus memvalidasi sendiri karena merekalah yang tahu konteks mutasi.

Melindungi Server Action

Pada Server Action yang dipicu form, pola paling praktis adalah mengirim token dalam input tersembunyi, lalu memverifikasi origin dan token sebelum menjalankan mutasi.

// app/settings/page.tsx
import { setCsrfCookie } from '@/lib/csrf'
import { updateProfile } from './actions'

export default async function SettingsPage() {
  const csrfToken = await setCsrfCookie()

  return (
    <form action={updateProfile}>
      <input type="hidden" name="csrfToken" value={csrfToken} />
      <input type="text" name="displayName" />
      <button type="submit">Simpan</button>
    </form>
  )
}
// app/settings/actions.ts
'use server'

import { assertAllowedOrigin, assertCsrfToken, setCsrfCookie } from '@/lib/csrf'

export async function updateProfile(formData: FormData) {
  await assertAllowedOrigin()
  await assertCsrfToken(String(formData.get('csrfToken') || ''))

  const displayName = String(formData.get('displayName') || '').trim()
  if (!displayName) {
    return { ok: false, error: 'Nama tidak boleh kosong' }
  }

  // lakukan pengecekan session user lalu mutasi database di sini

  // opsional: rotasi token setelah mutasi berhasil
  await setCsrfCookie()

  return { ok: true }
}

Kenapa ini bekerja: situs penyerang bisa saja membuat form POST, tetapi tidak bisa membaca cookie CSRF korban untuk mengisi field token yang benar. Jika ia menebak token, pemeriksaan akan gagal.

Jebakan umum:

  • Lupa menyertakan token pada semua form mutasi.
  • Hanya memvalidasi token, tetapi tidak memvalidasi origin sama sekali.
  • Merotasi token terlalu agresif sehingga pengguna yang membuka dua tab sering mendapat false positive.

Melindungi Route Handler

Untuk Route Handler yang dipanggil lewat fetch dari client, gunakan kombinasi Origin check dan header token.

// app/api/profile/route.ts
import { NextResponse } from 'next/server'
import { assertAllowedOrigin, assertCsrfToken, setCsrfCookie } from '@/lib/csrf'

export async function POST(req: Request) {
  try {
    await assertAllowedOrigin()

    const csrfHeader = req.headers.get('x-csrf-token')
    await assertCsrfToken(csrfHeader)

    const body = await req.json()
    const displayName = String(body.displayName || '').trim()
    if (!displayName) {
      return NextResponse.json({ error: 'Nama tidak boleh kosong' }, { status: 400 })
    }

    // verifikasi session dari cookie, lalu mutasi database

    const res = NextResponse.json({ ok: true })

    // opsional: rotasi token setelah aksi sensitif
    const nextToken = crypto.randomBytes(32).toString('base64url')
    res.cookies.set('csrf-token', nextToken, {
      httpOnly: false,
      secure: true,
      sameSite: 'lax',
      path: '/',
    })

    return res
  } catch (err) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
  }
}

Contoh client:

// komponen client
function getCookie(name: string) {
  return document.cookie
    .split('; ')
    .find((v) => v.startsWith(`${name}=`))
    ?.split('=')[1]
}

async function saveProfile(displayName: string) {
  const csrfToken = getCookie('csrf-token')

  const res = await fetch('/api/profile', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken || '',
    },
    body: JSON.stringify({ displayName }),
    credentials: 'same-origin',
  })

  if (!res.ok) {
    throw new Error('Request ditolak')
  }
}

Untuk endpoint yang menerima form multipart atau application/x-www-form-urlencoded, Anda bisa mengambil token dari body seperti pada Server Action. Yang penting, jalur validasinya konsisten.

Rotasi token: kapan perlu dan apa risikonya

Rotasi token CSRF setelah mutasi sensitif bisa membatasi token lama dipakai terus-menerus. Ini berguna untuk aksi seperti ubah password, ubah email, atau operasi admin.

Namun, ada trade-off nyata:

  • Rotasi setiap request bisa merusak UX pada multi-tab atau form yang sudah lama terbuka.
  • Tidak pernah rotasi lebih sederhana, tetapi token bertahan lama.

Pendekatan pragmatis: rotasi pada event penting seperti login, refresh session, atau mutasi sensitif; jangan memaksa rotasi pada setiap interaksi kecil.

Rotasi secret vs rotasi token

Jika Anda menandatangani token sendiri, misalnya dengan HMAC, pikirkan strategi key rotation. Simpan daftar secret aktif dan secret sebelumnya untuk masa transisi singkat. Tujuannya agar deploy baru tidak langsung mematahkan semua form yang sedang terbuka.

Jika token hanya acak dan tidak ditandatangani, Anda cukup mengganti nilai cookie token. Ini lebih sederhana, tetapi tetap perlu kebijakan kapan token dibuat ulang.

Handling error 403 yang tidak menyulitkan debugging

Respons 403 Forbidden harus jelas untuk client, tetapi jangan terlalu bocor detail ke penyerang. Praktiknya:

  • Kirim pesan generik ke client seperti Forbidden atau CSRF validation failed.
  • Log internal berisi alasan teknis: origin tidak cocok, token hilang, token mismatch, atau referer invalid.
  • Sertakan request ID agar mudah ditelusuri di log.
function forbidden(reason: string) {
  console.error('CSRF rejected:', reason)
  return new Response(JSON.stringify({ error: 'Forbidden' }), {
    status: 403,
    headers: { 'Content-Type': 'application/json' },
  })
}

Kesalahan umum saat debugging:

  • Cookie CSRF tidak terset karena path/domain salah.
  • Environment lokal HTTP gagal mengirim cookie yang hanya Secure.
  • Frontend lupa mengirim header token.
  • Validasi origin memakai domain yang berbeda dari domain sebenarnya di balik reverse proxy.
  • Aplikasi punya lebih dari satu host resmi, tetapi whitelist hanya berisi satu origin.

Pembatasan CORS yang aman untuk endpoint berbasis cookie

Jika Route Handler tidak perlu diakses dari situs lain, tidak perlu menambahkan header CORS terbuka. Untuk endpoint internal aplikasi, kebijakan paling aman adalah tidak membuka CORS sama sekali.

Jika memang harus menerima request dari origin tertentu:

  • Whitelist origin secara eksplisit.
  • Izinkan method yang memang dibutuhkan saja.
  • Izinkan header spesifik seperti Content-Type dan X-CSRF-Token.
  • Hindari konfigurasi longgar yang mengizinkan origin sembarang dengan credentials.

CORS bukan mekanisme anti-CSRF utama, tetapi konfigurasi yang ketat mencegah aplikasi Anda menciptakan jalur lintas origin yang tidak perlu.

Kapan cukup Origin check saja, dan kapan perlu token

Origin/Referer check saja biasanya cukup bila:

  • endpoint hanya dipanggil lewat JavaScript atau form dari origin aplikasi sendiri,
  • daftar origin Anda kecil dan stabil,
  • Anda tidak punya kebutuhan lintas origin yang rumit.

Tambahkan token bila:

  • endpoint sangat sensitif,
  • Anda ingin perlindungan berlapis terhadap request form klasik,
  • infrastruktur Anda membuat validasi origin kurang konsisten,
  • Anda ingin satu pola yang seragam untuk Server Actions dan Route Handler.

Untuk aplikasi produksi berbasis cookie session, kombinasi Origin check + token adalah pilihan yang paling aman secara praktis.

Checklist pengujian manual

  1. Buka aplikasi dalam kondisi login, lalu pastikan cookie session dan cookie CSRF terset seperti yang diharapkan.
  2. Kirim form normal dari aplikasi sendiri dan pastikan mutasi berhasil.
  3. Ulangi request yang sama tanpa token CSRF; pastikan server mengembalikan 403.
  4. Ubah token menjadi nilai acak; pastikan hasilnya 403.
  5. Uji request dengan Origin yang tidak diizinkan menggunakan alat seperti curl atau proxy; pastikan ditolak.
  6. Jika ada endpoint AJAX, kirim request tanpa header X-CSRF-Token; pastikan ditolak.
  7. Jika Anda merotasi token, buka dua tab dan uji apakah tab lama gagal total atau masih tertangani sesuai kebijakan.
  8. Verifikasi environment lokal, staging, dan produksi karena perbedaan host, HTTPS, dan proxy sering memunculkan bug yang tidak terlihat di satu environment saja.
curl -i -X POST https://app.example.com/api/profile \
  -H 'Origin: https://evil.example' \
  -H 'Content-Type: application/json' \
  -b 'session=...' \
  --data '{"displayName":"hacked"}'

Request seperti di atas seharusnya gagal bila validasi origin aktif.

Jebakan implementasi nyata yang sering terjadi

  • Mengira SameSite sudah cukup. Ini sering benar hanya sampai ada edge case, subdomain lain, atau konfigurasi cookie yang tidak konsisten.
  • Melindungi API JSON tetapi lupa form-based mutation. Padahal form submit adalah jalur klasik CSRF.
  • Menggunakan token tetapi tidak memverifikasi session. CSRF protection bukan pengganti autentikasi dan otorisasi.
  • Menyamakan CORS dengan CSRF. CORS membatasi siapa yang bisa membaca atau mengirim request tertentu dari browser, tetapi bukan validasi niat request pada endpoint mutasi.
  • Whitelist origin terlalu sempit atau terlalu longgar. Keduanya bermasalah: terlalu sempit bikin false positive, terlalu longgar melemahkan perlindungan.

Kesimpulan

Untuk aplikasi App Router yang memakai cookie session, CSRF Protection tetap relevan pada Server Actions dan Route Handler yang melakukan mutasi data. Pendekatan yang paling praktis adalah cek Origin/Referer untuk semua request mutasi, lalu tambahkan double-submit token untuk form dan custom header untuk AJAX.

Jika endpoint tidak perlu lintas origin, jangan buka CORS. Tambahkan rotasi token secara selektif, tangani 403 dengan log yang cukup untuk debugging, dan uji manual skenario lintas tab, token hilang, origin salah, serta perilaku cookie di tiap environment. Dengan pola ini, perlindungan Anda lebih tahan terhadap kesalahan implementasi nyata dibanding hanya mengandalkan SameSite.