Cara Mencegah SQL Injection di PHP dan Laravel (Contoh Kode)

Sebuah form pencarian sederhana di halaman admin bisa menjadi pintu masuk untuk membaca seluruh tabel users, termasuk hash password dan nomor telepon pelanggan. Penyebabnya hampir...

Cara Mencegah SQL Injection di PHP dan Laravel (Contoh Kode)

Sebuah form pencarian sederhana di halaman admin bisa menjadi pintu masuk untuk membaca seluruh tabel users, termasuk hash password dan nomor telepon pelanggan. Penyebabnya hampir selalu sama: input pengguna digabungkan langsung ke dalam teks query SQL. Serangan ini disebut SQL injection, dan meskipun sudah dikenal lebih dari dua puluh tahun, ia masih rutin masuk daftar celah keamanan web paling berbahaya.

Kabar baiknya, mencegah SQL injection tidak sulit asalkan Anda paham di mana titik rawannya. Artikel ini membahas contoh kode yang rentan, cara memperbaikinya di PHP murni dan di Laravel, jebakan yang sering lolos dari review, serta cara mengaudit proyek yang sudah berjalan.

Seperti Apa Kode yang Rentan?

Perhatikan fungsi login berikut. Sekilas terlihat wajar, tetapi nilai $email dimasukkan apa adanya ke dalam string SQL:

// RENTAN — jangan ditiru
$email = $_POST['email'];
$sql   = "SELECT * FROM users WHERE email = '$email' LIMIT 1";
$user  = $pdo->query($sql)->fetch();

Jika penyerang mengisi kolom email dengan ' OR '1'='1' -- , query yang dijalankan database berubah menjadi:

SELECT * FROM users WHERE email = '' OR '1'='1' -- ' LIMIT 1

Kondisi '1'='1' selalu benar, dan sisa query dimatikan oleh komentar --. Hasilnya, baris pertama tabel users (sering kali akun admin) dikembalikan tanpa password yang benar. Dengan teknik lanjutan seperti UNION SELECT, penyerang bahkan bisa membaca tabel lain.

Jenis seranganContoh dampak
Bypass loginMasuk sebagai admin tanpa password
Pencurian data (UNION / blind)Email, hash password, data transaksi bocor
Manipulasi dataSaldo, harga, atau status pesanan diubah
Penghapusan dataTabel dikosongkan bila user database punya hak DROP/DELETE luas

Prinsip Utama: Pisahkan Query dan Data

Akar masalah SQL injection adalah database tidak bisa membedakan mana perintah dan mana data, karena keduanya dikirim sebagai satu string. Solusinya adalah prepared statement (parameterized query): struktur query dikirim lebih dulu dengan placeholder, lalu nilainya dikirim terpisah. Apa pun isi input, database memperlakukannya sebagai nilai, bukan bagian dari perintah SQL.

Langkah 1: Prepared Statement di PHP Murni (PDO)

$pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false, // pakai prepared statement asli dari MySQL
]);

$stmt = $pdo->prepare('SELECT id, name, password FROM users WHERE email = ? LIMIT 1');
$stmt->execute([$_POST['email']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user && password_verify($_POST['password'], $user['password'])) {
    // login berhasil
}

Dengan payload yang sama seperti tadi, database mencari pengguna yang emailnya benar-benar berisi teks ' OR '1'='1' -- , dan tentu tidak menemukan apa pun. Perhatikan juga bahwa password dicek dengan password_verify(), bukan dibandingkan di dalam query.

Langkah 2: Di Laravel, Eloquent dan Query Builder Sudah Aman, Kecuali Bagian "Raw"

Eloquent dan Query Builder Laravel memakai binding PDO secara otomatis. Kode seperti ini aman tanpa perlu escape manual:

$user = User::where('email', $request->email)->first();

$orders = DB::table('orders')
    ->where('status', $request->status)
    ->whereIn('id', $request->input('ids', []))
    ->get();

Celah biasanya muncul ketika developer memakai method berakhiran Raw atau DB::raw() lalu menempelkan variabel dengan titik atau kurung kurawal:

// RENTAN
$products = Product::whereRaw("price > {$request->min}")->get();
$rows     = DB::select("SELECT * FROM invoices WHERE customer = '$name'");

// AMAN — gunakan placeholder ? dan array binding
$products = Product::whereRaw('price > ?', [$request->min])->get();
$rows     = DB::select('SELECT * FROM invoices WHERE customer = ?', [$name]);

// Juga berlaku untuk selectRaw, havingRaw, dan orderByRaw
$top = Product::selectRaw('category_id, SUM(qty * price) AS omzet')
    ->whereRaw('created_at >= ?', [$request->from])
    ->groupBy('category_id')
    ->get();

Aturan praktisnya: setiap kali Anda menulis Raw, pastikan tidak ada variabel di dalam string, dan semua nilai masuk lewat array binding.

Langkah 3: Nama Kolom dan Arah Urutan Tidak Bisa Di-bind, Pakai Whitelist

Placeholder hanya berlaku untuk nilai. Nama kolom, nama tabel, dan kata ASC/DESC tidak bisa dikirim sebagai binding. Inilah jebakan yang paling sering lolos: fitur "urutkan berdasarkan kolom" di tabel admin.

// RENTAN — nilai sort langsung dari URL ?sort=...
$data = Product::orderByRaw($request->sort . ' ' . $request->dir)->paginate(20);

// AMAN — hanya izinkan kolom yang memang boleh
$allowed = ['name', 'price', 'stock', 'created_at'];
$sort    = in_array($request->sort, $allowed, true) ? $request->sort : 'created_at';
$dir     = $request->dir === 'asc' ? 'asc' : 'desc';

$data = Product::orderBy($sort, $dir)->paginate(20);

Pola whitelist yang sama dipakai untuk filter dinamis, nama tabel per tenant, atau kolom ekspor CSV yang dipilih pengguna.

Langkah 4: Pencarian LIKE yang Benar

Binding sudah mencegah injection pada pencarian LIKE. Namun karakter % dan _ dari pengguna tetap dibaca sebagai wildcard, sehingga pencarian "50%" bisa menampilkan hasil yang tidak diharapkan dan pencarian %%% bisa memaksa database memindai seluruh tabel. Escape keduanya sebelum dibungkus wildcard:

$q    = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $request->input('q', ''));
$rows = Product::where('name', 'like', "%{$q}%")->limit(50)->get();

Nilai $q di sini tetap dikirim sebagai binding oleh where(), jadi penggabungan dengan tanda persen ini aman.

Langkah 5: Validasi Input sebagai Lapisan Kedua

Validasi bukan pengganti prepared statement, tetapi mempersempit ruang gerak penyerang dan membuat error lebih jelas. ID harus angka, tanggal harus tanggal, status harus salah satu dari daftar:

$data = $request->validate([
    'id'     => ['required', 'integer', 'min:1'],
    'from'   => ['nullable', 'date'],
    'status' => ['nullable', Rule::in(['baru', 'diproses', 'selesai'])],
]);

Pembahasan lengkap aturan validasi ada di artikel validasi form di Laravel dengan rapi.

Langkah 6: Batasi Hak Akses User Database

Jika suatu hari ada celah yang lolos, kerusakannya ditentukan oleh hak akses user database yang dipakai aplikasi. Jangan pernah memakai root di file .env produksi. Buat user khusus dengan hak secukupnya:

CREATE USER 'app_toko'@'localhost' IDENTIFIED BY 'ganti-dengan-password-kuat';
GRANT SELECT, INSERT, UPDATE, DELETE ON toko_db.* TO 'app_toko'@'localhost';
FLUSH PRIVILEGES;

User ini tidak bisa DROP TABLE, membuat user baru, atau membaca database lain di server yang sama. Untuk menjalankan migrasi, gunakan user terpisah yang hanya dipakai saat deploy.

Langkah 7: Jangan Tampilkan Error SQL ke Pengunjung

Pesan error yang memuat potongan query dan nama tabel sangat membantu penyerang menyusun payload. Di produksi, pastikan APP_DEBUG=false sehingga pengunjung hanya melihat halaman error umum, sementara detailnya tetap tercatat di storage/logs/laravel.log.

Cara Mengaudit Proyek yang Sudah Berjalan

Untuk proyek lama atau source code yang baru dibeli, mulai dengan mencari semua titik raw query. Perintah ini menampilkan baris yang perlu diperiksa satu per satu:

grep -rnE "DB::raw|whereRaw|orWhereRaw|selectRaw|havingRaw|orderByRaw|DB::(select|statement|unprepared)\(" app/ routes/

Untuk setiap hasil, ajukan tiga pertanyaan:

PertanyaanJika jawabannya "ya"
Apakah ada variabel di dalam string SQL?Ganti dengan placeholder ? dan array binding
Apakah variabel itu nama kolom atau arah urutan?Terapkan whitelist
Apakah DB::unprepared() menerima input pengguna?Hapus; method ini tidak mendukung binding sama sekali

Setelah diperbaiki, tambahkan feature test yang mengirim payload klasik dan memastikan aplikasi tidak bocor data:

public function test_login_tidak_bisa_dibypass_dengan_sql_injection(): void
{
    User::factory()->create(['email' => 'admin@toko.test']);

    $this->post('/login', [
        'email'    => "' OR '1'='1' -- ",
        'password' => 'apa-saja',
    ]);

    $this->assertGuest();
}

Jika Anda baru mulai menulis test, panduan testing otomatis di Laravel untuk pemula membahas dasarnya.

Kesalahan Umum yang Masih Sering Ditemui

  • Mengandalkan addslashes() atau escape manual. Cara ini rapuh terhadap perbedaan charset dan mudah terlupa di satu titik. Prepared statement jauh lebih andal.
  • Menganggap input dari dropdown aman. Nilai <select> bisa diubah lewat DevTools atau dikirim langsung dengan Postman. Semua input dari request harus diperlakukan sama.
  • Lupa pada data dari sumber "internal". Nilai dari cookie, header, file CSV yang diimpor, atau respons API pihak ketiga juga input yang tidak tepercaya.
  • Memperbaiki satu endpoint saja. Celah serupa biasanya tersebar karena pola kodenya disalin. Selalu lakukan audit menyeluruh dengan perintah grep di atas.

Ringkasan Checklist

  1. Semua nilai masuk lewat binding (Eloquent, Query Builder, atau ? pada method Raw).
  2. Nama kolom dan arah urutan dari pengguna selalu melewati whitelist.
  3. Wildcard % dan _ pada pencarian LIKE di-escape.
  4. Input divalidasi sesuai tipe datanya.
  5. User database produksi bukan root dan hanya punya hak yang dibutuhkan.
  6. APP_DEBUG=false di produksi.
  7. Ada test yang memastikan payload injection tidak berhasil.

SQL injection hanyalah satu bagian dari keamanan aplikasi. Untuk gambaran yang lebih luas, lanjutkan ke 10 best practice keamanan aplikasi web.

Ingin langsung mempraktikkannya?

Buat akun gratis untuk mengunduh contoh aplikasi Laravel, menyimpan tutorial favorit, dan mencoba kursus. Gratis, bisa daftar dengan akun Google.

Daftar Gratis Lihat katalog aplikasi
Yudhi
Ditulis oleh
Yudhi
Founder & Lead Developer, GudangCode

Yudhi adalah founder GudangCode dan developer Laravel dengan pengalaman membangun puluhan sistem informasi bisnis siap pakai — mulai dari POS, HRIS, hingga aplikasi manajemen. Ia menulis panduan dan artikel di GudangCode untuk membantu developer Indonesia menjalankan, memahami, dan men-deploy source code Laravel dengan benar.

Laravel PHP MySQL Sistem Informasi Bisnis
Lihat semua artikel Yudhi
Mencegah Sql Injection Keamanan & Best Practice
Bacaan Paling Dicari
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →