CSRF protection adalah pengecekan di server untuk setiap request yang mengubah data: bukan cuma "cookie sesinya ada", tapi juga "request ini memang datang dari halaman kita sendiri". Cookie dikirim browser otomatis, siapa pun yang menyuruh, jadi cookie saja gak membuktikan apa-apa. Bayangin loket kelurahan. Petugas mengenali wajahmu (itu cookie), tapi tetap minta formulir bercap yang tadi dia berikan sendiri di loket (itu CSRF token). Surat titipan dari orang lain, walau atas namamu, gak membawa formulir itu. Cara serangannya dibahas di lesson CSRF (Cross-Site Request Forgery) di track Security. Lesson ini membangun pengecekannya.
🧪 Tebakan cepat: cookie sesi Portal Sekolah sudah
SameSite=Lax. Sebuah form dihttps://kalender.sekolah.test, subdomain lain milik sekolah, mengirimPOSTkehttps://portal.sekolah.test/profile/email. Cookie sesi Siti ikut terkirim atau gak? Tebak dulu, jawabannya ada di bagian "SameSite=Lax: lapis pertama, bukan satu-satunya".
Request mana yang dicek
Aturannya dimulai dari method HTTP:
- Method aman:
GET,HEAD,OPTIONS. Cuma membaca, jadi gak dicek. Syaratnya, routeGETgak pernah mengubah data.GET /logoutatauGET /hapus?id=4melanggar aturan ini. - Method yang mengubah data:
POST,PUT,PATCH,DELETE. Semuanya dicek, gak cumaPOST.
Synchronizer token: satu token per sesi
Pola utamanya synchronizer token. Saat sesi dibuat, server membuat token acak dan menyimpannya di sesi. Setiap form menyertakan token itu di field tersembunyi. Saat form dikirim, server membandingkan token dari form dengan token di sesi. Halaman situs lain gak bisa membaca isi halaman portal (same-origin policy browser), jadi gak tahu token-nya.
function issueCsrfToken(sessionId) {
return "csrf_" + sessionId + "_n1"; // latihan: nonce tetap "n1"; aslinya 32 byte acak
}
function validateCsrfToken(submittedToken, sessionToken) {
if (!submittedToken || !sessionToken) return false; // kosong, null, undefined: tolak
return submittedToken === sessionToken;
}
function isTrustedOrigin(origin, trustedOrigins) {
if (!origin) return false; // gak ada Origin: tolak
return trustedOrigins.includes(origin); // sama persis, bukan "mirip"
}
| Panggilan | Hasil |
|---|---|
issueCsrfToken("sess42") |
"csrf_sess42_n1" |
validateCsrfToken(token, token) |
true |
validateCsrfToken("csrf_evil_n1", token) |
false, token milik sesi lain |
validateCsrfToken(null, token) |
false |
isTrustedOrigin("https://portal.sekolah.test", TRUSTED) |
true |
isTrustedOrigin("https://situs-lain.test", TRUSTED) |
false |
isTrustedOrigin(null, TRUSTED) |
false |
- Token-nya per sesi. Satu token untuk semua user sama saja dengan gak ada token.
!submittedToken || !sessionTokenmenolak nilai kosong duluan. Tanpa itu, dua token kosong ("" === "") dianggap cocok.includesmembandingkan string utuh.startsWith("https://portal.sekolah.test")juga meloloskanhttps://portal.sekolah.test.situs-lain.test.
Origin dan Sec-Fetch-Site: dari mana request-nya
Browser modern menempelkan dua header yang gak bisa diatur oleh halaman: Origin di setiap POST, dan Sec-Fetch-Site, yang isinya same-origin, same-site, cross-site, atau none (diketik langsung di address bar). Middleware buatan sendiri menggabungkan cek asal dan cek token:
const TRUSTED = ["https://portal.sekolah.test"];
const SAFE = ["GET", "HEAD", "OPTIONS"];
function checkCsrf(req, session) {
if (SAFE.includes(req.method)) return "lewat"; // cuma membaca
if (!isTrustedOrigin(req.headers.origin, TRUSTED)) return 403; // asal asing
if (!validateCsrfToken(req.body._token, session.csrfToken)) return 403; // token salah
return "lewat";
}
SameSite=Lax: lapis pertama, bukan satu-satunya
Laravel memasang same_site => 'lax' di config/session.php. Dengan Lax, browser gak menyertakan cookie di POST yang datang dari situs lain, tapi tetap menyertakannya saat user mengklik link (navigasi GET). Kata kuncinya situs: kalender.sekolah.test dan portal.sekolah.test sama-sama di bawah sekolah.test, jadi satu situs, walau beda origin. Request dari kalender itu same-site, dan cookie sesi Siti ikut terkirim. Itu jawaban tebakannya.
Jadi SameSite menahan situs asing, tapi gak menahan subdomain sendiri, dan aturannya sedikit beda di tiap browser. Yang menolak request kalender itu adalah cek berikutnya: Origin-nya gak ada di daftar, dan form-nya gak punya token sesi Siti.
Di Laravel 13: PreventRequestForgery
Laravel 13 menamai middleware-nya PreventRequestForgery (nama lamanya, VerifyCsrfToken dan ValidateCsrfToken, masih ada sebagai alias). Middleware ini sudah terpasang di grup web, dan urutan ceknya:
- Method
GET,HEAD,OPTIONS: lewat. - URI ada di daftar pengecualian: lewat.
Sec-Fetch-Site: same-origin: lewat. Request dari halaman portal sendiri di browser modern sudah aman dari sini.- Token cocok dengan token sesi (
hash_equals): lewat. Token dibaca dari field_token, headerX-CSRF-TOKEN, atau headerX-XSRF-TOKEN. - Selain itu:
419, dengan pesanCSRF token mismatch.untuk request JSON, atau halaman "Page Expired".
<form method="POST" action="/profile/email">
@csrf {{-- jadi <input type="hidden" name="_token" value="..."> --}}
<input name="email" type="email">
<button>Simpan</button>
</form>
// bootstrap/app.php: URI yang dikecualikan, misalnya webhook yang diverifikasi dengan signature
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: ['webhooks/*']);
})
Token sesi Laravel 40 karakter acak. Untuk fetch biasa, taruh csrf_token() di <meta name="csrf-token"> dan kirim di header X-CSRF-TOKEN.
Double-submit cookie untuk API
API yang login-nya pakai cookie, misalnya SPA dengan Laravel Sanctum (lesson Auth di SPA & Mobile), butuh perlindungan juga. Polanya double-submit cookie: server memasang cookie XSRF-TOKEN yang boleh dibaca JavaScript, SPA menyalin isinya ke header X-XSRF-TOKEN, dan server mencocokkannya. Situs lain bisa membuat browser mengirim cookie, tapi gak bisa membaca isinya untuk ditaruh di header. Di Laravel, isi cookie itu adalah token sesi yang dienkripsi, jadi server mencocokkannya dengan sesi, bukan cuma dengan cookie (axios melakukan penyalinannya otomatis).
API yang cuma memakai header Authorization: Bearer gak butuh CSRF token sama sekali: browser gak pernah menempelkan header itu sendiri.
⚠️ Jebakan yang sering ketemu
- Route
GETyang mengubah data. Middleware CSRF melewatkan semuaGET. Logout, hapus, dan transfer harusPOSTatauDELETE. - Cuma mengecek
POST.PUT,PATCH, danDELETEjuga mengubah data. - Membandingkan Origin dengan
startsWithatauincludespada string. Bandingkan string utuh dengan daftar. - Satu token untuk semua user, atau token kosong yang lolos. Token per sesi, dan tolak nilai kosong duluan.
- Mengecualikan grup route besar supaya 419-nya hilang. Tambahkan
@csrfatau header-nya, jangan matikan ceknya.
TL;DR: CSRF protection mengecek setiap request yang mengubah data (POST, PUT, PATCH, DELETE); GET gak dicek, jadi GET gak boleh mengubah data. Synchronizer token: token acak per sesi di form (@csrf jadi _token), dibandingkan dengan token sesi; tolak null dan string kosong, lalu ===. Cek asal: Origin harus sama persis dengan daftar tepercaya (null ditolak), dan Sec-Fetch-Site memberi tahu same-origin, same-site atau cross-site. SameSite=Lax (default Laravel) menahan cookie di POST dari situs lain, tapi subdomain sendiri tetap dapat cookie. Laravel 13 PreventRequestForgery: GET lewat, same-origin lewat, token cocok lewat, selain itu 419. API dengan cookie memakai double-submit (XSRF-TOKEN ke X-XSRF-TOKEN); API yang cuma memakai Authorization: Bearer gak butuh CSRF token.