Chapter CSRF sudah memperlihatkan bagaimana situs penyerang bisa memaksa browser pengguna mengirim permintaan bersyarat cookie ke aplikasi kita, dan chapter form security memperkenalkan token CSRF sebagai penangkalnya. Kini saatnya membedah mekanisme tersebut sampai ke implementasinya: bagaimana token dibuat agar tidak bisa ditebak, di mana token disimpan, bagaimana token disematkan pada formulir maupun permintaan AJAX, dan mengapa proses verifikasinya harus memakai perbandingan yang aman terhadap serangan waktu. Pada akhir chapter kita akan memiliki alur lengkap yang bisa diuji dari pembuatan token hingga validasi submit.
Mengapa Cookie saja Tidak Cukup #
Inti masalah CSRF adalah cookie dikirim otomatis oleh browser untuk setiap permintaan ke domain kita, tanpa peduli halaman mana yang memicu permintaan tersebut. Token CSRF membalik situasi dengan menyisipkan rahasia yang hanya diketahui oleh dua pihak: sesi pengguna di server dan halaman formulir kita. Saat situs penyerang memaksa browser mengirim permintaan palsu, halaman penyerang tidak bisa menyertakan token tersebut karena same-origin policy melarang skrip lintas domain membaca konten halaman kita — rahasia itu memang tidak pernah sampai ke browser penyerang.
Dari gambaran tersebut lahir dua syarat wajib sebuah token CSRF. Pertama, token harus tidak terduga — bukan kombinasi waktu, user ID, atau angka acak yang bisa direproduksi penyerang. Kedua, token harus terikat pada sesi pengguna sehingga verifikasi cukup membandingkan nilai yang dikirim dengan nilai yang tersimpan. Seluruh contoh pada chapter ini dirancang memenuhi kedua syarat tersebut.
Menghasilkan Token dengan random_bytes #
PHP menyediakan generator acak kriptografis lewat fungsi random_bytes() — sumber keacakan dari sistem operasi yang tidak bisa diprediksi bahkan oleh penyerang yang mengamati output-nya. Konvensi yang umum dipakai adalah mengambil 32 byte acak lalu mengubahnya menjadi string heksadesimal dengan bin2hex():
<?php
$token = bin2hex(random_bytes(32));
echo 'Panjang token: ' . strlen($token) . " karakter\n";
echo 'Hanya berisi heksadesimal: ' . (ctype_xdigit($token) ? 'ya' : 'tidak') . "\n";Output yang Dihasilkan:
Panjang token: 64 karakter
Hanya berisi heksadesimal: yaDua baris output di atas sengaja hanya menampilkan struktur token, bukan nilainya — nilai token memang berbeda pada setiap eksekusi. Setiap byte acak direpresentasikan dua karakter heksadesimal, sehingga 32 byte menghasilkan tepat 64 karakter; panjang ini memberi ruang 256 bit entropi, jauh melampaui kemampuan menebak mana pun. Fungsi ctype_xdigit() yang mengembalikan ya menegaskan seluruh isinya hanya karakter 0-9 dan a-f, aman disematkan di atribut HTML tanpa perlu escaping tambahan.
Menyimpan Token pada Sesi #
Token harus hidup selama sesi pengguna, jadi tempat penyimpanannya adalah $_SESSION. Agar logika dapat diuji tanpa server, fungsi berikut menerima array sesi by reference — perilakunya identik dengan memanipulasi $_SESSION langsung:
<?php
function ensureCsrfToken(array &$session): string
{
if (empty($session['csrf_token'])) {
$session['csrf_token'] = bin2hex(random_bytes(32));
}
return $session['csrf_token'];
}
$session = [];
$tokenPertama = ensureCsrfToken($session);
$tokenKedua = ensureCsrfToken($session);
echo 'Token tersimpan di sesi: ' . (isset($session['csrf_token']) ? 'ya' : 'tidak') . "\n";
echo 'Panggilan kedua mengembalikan token yang sama: ' . ($tokenPertama === $tokenKedua ? 'ya' : 'tidak') . "\n";
echo 'Panjang token: ' . strlen($tokenPertama) . " karakter\n";Output yang Dihasilkan:
Token tersimpan di sesi: ya
Panggilan kedua mengembalikan token yang sama: ya
Panjang token: 64 karakterMari kita bedah perilaku fungsi tersebut:
array &$session— tanda&membuat perubahan di dalam fungsi menulis kembali ke array milik pemanggil, persis seperti menulis ke$_SESSIONdi aplikasi nyata.empty($session['csrf_token'])— token hanya dibuat satu kali per sesi; pemanggilan berikutnya mengembalikan nilai yang sama, terbukti dari baris output kedua.- Rotasi saat login — pada aplikasi nyata, panggil
session_regenerate_id(true)lalu kosongkancsrf_tokensaat pengguna berhasil login atau logout, agar token lama tidak terbawa ke sesi baru.
Menyematkan Token pada Formulir #
Token yang tersimpan di sesi harus sampai ke browser sebagai bagian dari formulir, dan caranya adalah field tersembunyi. Fungsi berikut merender field tersebut dalam bentuk yang aman untuk disisipkan ke HTML:
<?php
function csrfInputField(string $token): string
{
$tokenAman = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="csrf_token" value="' . $tokenAman . '">';
}
echo csrfInputField(str_repeat('ab', 32)) . "\n";Output yang Dihasilkan:
<input type="hidden" name="csrf_token" value="abababababababababababababababababababababababababababababababab">Contoh di atas memakai token tiruan abab... sepanjang 64 karakter agar output stabil dan mudah diperiksa. Pemanggilan htmlspecialchars() tetap dipertahankan sebagai kebiasaan defensif — kalaupun suatu saat sumber token berubah menjadi nilai yang mengandung karakter spesial HTML, atribut value tidak akan rusak. Pada formulir nyata, fungsi ini dipanggil bersama token dari sesi: echo csrfInputField(ensureCsrfToken($_SESSION));.
Memvalidasi dengan hash_equals #
Sisi server memvalidasi setiap permintaan yang mengubah state — POST, PUT, DELETE — dengan membandingkan token yang dikirim terhadap token di sesi. Untuk perbandingan ini PHP menyediakan hash_equals(), dan berikut implementasi validasinya:
<?php
function validateCsrfToken(string $submitted, array $session): bool
{
$stored = $session['csrf_token'] ?? '';
if ($stored === '' || $submitted === '') {
return false;
}
return hash_equals($stored, $submitted);
}
$session = ['csrf_token' => str_repeat('ab', 32)];
echo 'Token cocok: ' . (validateCsrfToken(str_repeat('ab', 32), $session) ? 'true' : 'false') . "\n";
echo 'Token berbeda: ' . (validateCsrfToken(str_repeat('cd', 32), $session) ? 'true' : 'false') . "\n";
echo 'Sesi tanpa token: ' . (validateCsrfToken(str_repeat('ab', 32), []) ? 'true' : 'false') . "\n";Output yang Dihasilkan:
Token cocok: true
Token berbeda: false
Sesi tanpa token: falseMari kita telaah keputusan penting pada fungsi di atas:
?? ''dan pemeriksaan kosong — sesi tanpa token (misalnya sesi kedaluwarsa) langsung ditolak tanpa perbandingan, bukan memicu warning akses key tak terdefinisi.hash_equals($stored, $submitted)— membandingkan dua string dalam waktu konstan, aman dari timing attack.
Mengapa tidak cukup memakai operator ===? Perbandingan string biasa berhenti pada byte pertama yang berbeda, sehingga lama waktunya membocorkan berapa banyak karakter awal yang kebetulan sama. Penyerang yang bisa mengukur waktu respons berulang kali secara teori bisa menyusun token byte demi byte. Fungsi hash_equals() membandingkan seluruh string dengan waktu yang tidak dipengaruhi posisi byte yang berbeda, menutup jalur kebocoran tersebut — murah untuk dipakai, jadi tidak ada alasan melewatkannya.
Token per Sesi vs Token per Permintaan #
Dua strategi penyimpanan yang paling umum dapat dibandingkan berikut ini:
| Aspek | Token per sesi | Token per permintaan |
|---|---|---|
| Masa pakai | Satu token selama sesi hidup | Token baru setiap kali formulir dirender |
| Ketahanan replay | Bisa dipakai ulang dalam sesi yang sama | Sekali pakai — token hangus setelah submit |
| Dampak multi-tab | Aman — semua tab berbagi token | Rawan gagal bila dua tab membuka formulir sama |
| Kompleksitas | Rendah, cukup satu fungsi | Perlu penyimpanan sementara daftar token |
Untuk sebagian besar aplikasi, token per sesi dengan rotasi saat login sudah memadai karena tujuan utamanya menangkal situs penyerang yang tidak bisa membaca token sama sekali. Token per permintaan layak dipertimbangkan pada alur sensitif seperti transfer dana, tempat replay di dalam sesi yang sama juga ingin dicegah — dengan konsekuensi menangani kasus multi-tab secara eksplisit.
Token untuk Permintaan AJAX #
Formulir klasik mengirim token lewat field tersembunyi, sedangkan permintaan fetch() lebih alami memakai header kustom X-CSRF-Token. Halaman menyimpan token pada tag meta, lalu JavaScript membacanya dan melampirkannya ke setiap permintaan:
<meta name="csrf-token" content="<?php echo htmlspecialchars(
ensureCsrfToken($_SESSION),
ENT_QUOTES,
'UTF-8'
); ?>">
<script>
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/simpan-profil', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token
},
credentials: 'same-origin',
body: JSON.stringify({ nama: 'Budi' })
});
</script>Di sisi server, header tersebut muncul di $_SERVER['HTTP_X_CSRF_TOKEN']. Fungsi validasi berikut menerima kedua kanal sekaligus — field POST sebagai fallback dan header AJAX sebagai jalur utama:
<?php
function validateCsrfFromRequest(array $post, array $server, array $session): bool
{
$submitted = $server['HTTP_X_CSRF_TOKEN'] ?? ($post['csrf_token'] ?? '');
$stored = $session['csrf_token'] ?? '';
if ($stored === '' || $submitted === '') {
return false;
}
return hash_equals($stored, $submitted);
}
$session = ['csrf_token' => str_repeat('ab', 32)];
echo 'Form klasik: ' . (validateCsrfFromRequest(['csrf_token' => str_repeat('ab', 32)], [], $session) ? 'true' : 'false') . "\n";
echo 'Header AJAX: ' . (validateCsrfFromRequest([], ['HTTP_X_CSRF_TOKEN' => str_repeat('ab', 32)], $session) ? 'true' : 'false') . "\n";
echo 'Tanpa token: ' . (validateCsrfFromRequest([], [], $session) ? 'true' : 'false') . "\n";Output yang Dihasilkan:
Form klasik: true
Header AJAX: true
Tanpa token: falseOperator ?? pada baris pertama memilih header bila ada, jika tidak jatuh ke field POST — sehingga satu fungsi yang sama menangani formulir klasik dan pemanggilan fetch(). Opsi credentials: 'same-origin' pada JavaScript memastikan cookie sesi tetap terkirim bersama permintaan; tanpa cookie, validasi token tidak punya sesi untuk dibandingkan.
Sinergi dengan SameSite #
Lapisan pelindung tambahan bisa dipasang pada cookie sesi itu sendiri melalui atribut SameSite, yang memerintahkan browser menahan cookie pada permintaan lintas situs:
<?php
function sessionCookieParams(): array
{
return [
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
];
}
foreach (sessionCookieParams() as $name => $value) {
echo "$name: " . var_export($value, true) . "\n";
}Output yang Dihasilkan:
lifetime: 0
path: '/'
secure: true
httponly: true
samesite: 'Lax'Nilai Lax mengizinkan cookie hanya untuk navigasi tingkat atas (mengikuti tautan ke situs kita), tetapi menahannya pada permintaan POST lintas situs — genrenya tepat melawan CSRF. Atribut tersebut diterapkan sebelum sesi dimulai:
<?php
session_set_cookie_params(sessionCookieParams());
session_start();Ringkasan #
Alur lengkapnya kini tersambung: random_bytes(32) menghasilkan token yang tak terduga, $_SESSION menyimpannya sepanjang sesi, field tersembunyi atau header X-CSRF-Token menyampaikannya ke server, dan hash_equals() memverifikasinya dalam waktu konstan. Kombinasi dengan cookie SameSite=Lax dan rotasi token saat login membuat pertahanan berlapis yang tangguh. Dengan CSRF tertangani, satu kelas serangan besar masih menyisakan kekhawatiran — injeksi skrip ke halaman kita sendiri — dan lapisan kedua untuk menghadapinya dibahas pada chapter Content Security Policy
.
Referensi #
- php.net — random_bytes()
- php.net — hash_equals()
- OWASP — Cross-Site Request Forgery
- OWASP — CSRF Prevention Cheat Sheet
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
contoh.php
Unduh berkas<?php
/* contoh.php - alur lengkap token CSRF tanpa server nyata.
* Jalankan dengan: php contoh.php
*/
function ensureCsrfToken(array &$session): string
{
if (empty($session['csrf_token'])) {
$session['csrf_token'] = bin2hex(random_bytes(32));
}
return $session['csrf_token'];
}
function csrfInputField(string $token): string
{
$tokenAman = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="csrf_token" value="' . $tokenAman . '">';
}
function validateCsrfToken(string $submitted, array $session): bool
{
$stored = $session['csrf_token'] ?? '';
if ($stored === '' || $submitted === '') {
return false;
}
return hash_equals($stored, $submitted);
}
// 1. Pengguna membuka formulir: token dibuat dan disimpan di sesi
$session = [];
$token = ensureCsrfToken($session);
// 2. Formulir dirender dengan field tersembunyi
echo "Field formulir:\n" . csrfInputField($token) . "\n";
echo 'Panjang token: ' . strlen($token) . " karakter\n";
// 3. Submit sah: token yang dikirim sama dengan token di sesi
echo 'Submit dengan token cocok: ' . (validateCsrfToken($token, $session) ? 'diterima' : 'ditolak') . "\n";
// 4. Submit palsu dari situs penyerang: token tidak dikenal
echo 'Submit dengan token asing: ' . (validateCsrfToken(str_repeat('cd', 32), $session) ? 'diterima' : 'ditolak') . "\n";