Sebuah platform pembayaran bisa terlihat sempurna di demo: tombol bayar bekerja, status berubah menjadi lunas, notifikasi terkirim. Lalu seseorang membuka terminal dan mengirim satu request curl ke endpoint callback dengan body yang dia susun sendiri. Kalau endpoint itu percaya begitu saja, pesanan sepuluh juta rupiah berubah menjadi lunas tanpa satu rupiah pun masuk.
Di aplikasi biasa, kesalahan seperti itu berarti bug. Di payment gateway, itu berarti kerugian langsung, sengketa dengan merchant, dan kemungkinan besar urusan hukum.
Karena itu pengujian keamanan payment gateway tidak bisa diperlakukan sebagai checklist yang diisi menjelang rilis. Dia harus jadi bagian dari cara kamu membangun, dengan skenario serangan yang ditulis lebih dulu sebelum fiturnya selesai.
Artikel ini menyusun kiat-kiat itu berdasarkan dokumen security assessment internal yang kami pakai, dirapikan menjadi urutan yang bisa langsung dikerjakan.
Prinsip yang membedakan payment gateway
Sebelum masuk ke daftar uji, ada empat prinsip yang membuat semua pengujian lain masuk akal.
Frontend tidak punya suara. Nominal, status, dan identitas merchant yang dikirim dari browser adalah klaim, bukan fakta. Sumber kebenaran hanya dua: permintaan yang kamu buat ke penyedia pembayaran, dan callback yang tanda tangannya valid.
Uang tidak punya undo. Kesalahan pada data biasa bisa diperbaiki dengan migrasi. Transaksi yang sudah settlement tidak bisa ditarik kembali tanpa prosedur refund yang melibatkan bank.
Setiap operasi harus idempoten. Jaringan putus, pengguna menekan tombol dua kali, dan webhook datang berulang adalah kondisi normal, bukan pengecualian. Operasi yang mengubah uang wajib aman dijalankan lebih dari sekali.
Status adalah mesin, bukan label. Perpindahan dari pending ke paid hanya sah lewat satu jalur, dan jalur itu harus dijaga di server.
Satu: uji manipulasi nominal
Ini pengujian pertama yang harus kamu tulis, karena paling sering ditemukan di integrasi buatan sendiri.
Skenarionya sederhana. Invoice asli bernilai Rp1.000.000. Kamu mengirim permintaan pembayaran dengan amount=1000, dan melihat apa yang terjadi.
Yang diuji bukan cuma apakah server menolak. Yang diuji adalah dari mana server mengambil nominal:
// salah: nominal dari request customer
$amount = $request->integer('amount');
// benar: nominal dari invoice yang tersimpan, berdasarkan referensi
$invoice = Invoice::query()
->where('number', $request->string('invoice_number'))
->lockForUpdate()
->firstOrFail();
$amount = $invoice->outstanding_amount;
Kiat praktisnya: tulis test yang mengirim nominal lebih kecil, lebih besar, nol, negatif, dan nominal dengan desimal aneh. Kelimanya harus ditolak, dan penolakannya harus tercatat di log.
Hal yang sama berlaku untuk mata uang. Menerima currency=USD pada invoice rupiah bukan soal tampilan, itu soal nilai tukar yang tidak pernah kamu hitung.
Dua: uji keunikan dan idempotensi transaksi
Dua transaksi berbeda dengan referensi yang sama adalah bencana akuntansi. Pencegahan paling murah ada di level basis data, bukan di level kode aplikasi:
create unique index payment_ref_unique
on payment_transactions (provider, provider_reference);
Dengan constraint itu, percobaan menyimpan referensi ganda akan gagal, bukan diam-diam membuat baris kedua.
Di atasnya, uji idempotensi operasinya. Kirim callback yang sama dua kali, lalu pastikan:
- status pembayaran tidak berubah dua kali
- tidak ada dua baris transaksi
- saldo merchant tidak bertambah dua kali
- respons kedua tetap sukses, bukan error yang membuat penyedia mengirim ulang lagi
Poin terakhir sering dilupakan. Kalau callback duplikat dijawab dengan 500, penyedia akan menganggap pengiriman gagal dan mencoba lagi, biasanya dengan backoff selama berjam-jam.
Tiga: uji tanda tangan dan replay
Endpoint callback adalah alamat publik. Tanpa verifikasi kriptografis, siapa pun bisa mengumumkan bahwa pembayaran berhasil.
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signatureFromHeader)) {
abort(401, 'Signature tidak valid');
}
Dua detail teknis yang sering salah:
hash_equals dipakai, bukan ===. Perbandingan string biasa bisa membocorkan informasi lewat perbedaan waktu, dan itu cukup untuk menebak tanda tangan sedikit demi sedikit.
Body yang ditandatangani adalah body mentah, bukan hasil json_decode lalu di-encode ulang. Urutan kunci dan spasi bisa berbeda, dan tanda tangannya langsung tidak cocok.
Setelah tanda tangan valid, masih ada dua pengujian lagi:
Replay window. Kirim callback dengan timestamp dua jam lalu, tanda tangan valid. Harus ditolak. Jendela waktu yang wajar biasanya dua sampai lima menit.
Event ID unik. Kirim dua callback berbeda isi tapi dengan event_id yang sama. Yang kedua harus diabaikan, karena itu tanda penyedia mengirim ulang, bukan transaksi baru.
Empat: uji race condition
Ini pengujian yang paling jarang ditulis dan paling mahal akibatnya.
Skenarionya: pengguna menekan tombol bayar, jaringan lambat, dia menekan lagi. Atau dua worker memproses callback yang sama pada milidetik yang berdekatan.
Pola yang salah adalah cek-lalu-tulis:
if ($transaction->status !== 'paid') { // dua request lolos di sini
$transaction->update(['status' => 'paid']);
$merchant->increment('balance', $transaction->net_amount);
}
Dua request bisa sama-sama lolos pengecekan, dan saldo naik dua kali. Perbaikannya ada di level penguncian dan constraint:
DB::transaction(function () use ($reference) {
$transaction = PaymentTransaction::query()
->where('provider_reference', $reference)
->lockForUpdate()
->firstOrFail();
if ($transaction->status === 'paid') {
return; // idempoten, tidak ada perubahan
}
$transaction->markAsPaid();
$transaction->merchant->increment('balance', $transaction->net_amount);
});
Cara mengujinya jangan pakai asumsi. Jalankan dua request paralel dengan curl di background, atau tulis test yang memanggil endpoint dua kali dari dua proses. Setelah itu, periksa satu angka: saldo merchant harus bertambah tepat satu kali.
Lima: uji transisi status
Setiap status punya jalur masuk yang sah. Yang perlu diuji adalah jalur yang tidak sah.
| Dari | Ke | Boleh? |
|---|---|---|
pending | paid | ya, setelah verifikasi |
pending | expired | ya, setelah batas waktu |
paid | refunded | ya, dengan otorisasi |
expired | paid | tidak |
refunded | paid | tidak |
paid | paid | ya, tapi tanpa efek samping |
Kiat praktisnya: tulis matriks ini sebagai test table-driven, satu baris satu kasus. Kalau ada transisi yang lolos padahal seharusnya ditolak, kamu sudah menemukan celah sebelum ada yang memakainya.
Yang juga wajib diuji: perubahan status harus berasal dari server, bukan dari parameter request. Kalau endpoint punya field status yang bisa dikirim client, itu temuan kritis, bukan saran.
Enam: uji otorisasi dan isolasi merchant
Di platform multi-merchant, pertanyaan paling penting bukan "apakah user sudah login", tapi "apakah user ini boleh melihat transaksi ini".
Bentuk serangannya IDOR: mengganti ID transaksi di URL dengan milik merchant lain.
GET /api/transactions/8841 -> milik merchant B
Yang harus terjadi: 403 atau 404, bukan 200 dengan data merchant lain. Kiatnya, tulis test yang membuat dua merchant, dua transaksi, lalu mencoba mengakses silang dengan token masing-masing. Empat kombinasi, semuanya harus jelas hasilnya.
Refund punya pengujian sendiri, karena dia mengeluarkan uang. Yang diuji:
- refund melebihi nominal transaksi harus ditolak
- refund ganda untuk transaksi yang sama harus ditolak atau idempoten
- refund oleh staf tanpa izin harus ditolak
- refund untuk transaksi yang belum settlement harus mengikuti aturan yang kamu tetapkan, dan aturan itu harus diuji
Tujuh: uji autentikasi dan sesi
Bagian ini terasa biasa, tapi tetap menjadi pintu masuk paling sering di aplikasi keuangan.
| Yang diuji | Harapan |
|---|---|
| Penyimpanan sandi | Argon2id atau bcrypt, bukan hash cepat |
| MFA admin | Aktif dan tidak bisa dilewati lewat API |
| Cookie sesi | Secure, HttpOnly, SameSite |
| Brute force | Dibatasi per akun dan per IP |
| Reset sandi | Token sekali pakai, punya masa berlaku |
| Masa berlaku token API | Ada, dan bisa dicabut |
| Logout | Token benar-benar tidak berlaku lagi |
| Enumerasi akun | Pesan error tidak membocorkan akun ada atau tidak |
Dua yang paling sering gagal diuji: pencabutan token setelah logout, dan pembatasan percobaan login yang ternyata hanya berlaku di halaman, tidak di endpoint API.
Delapan: uji API dan webhook keluar
Platform pembayaran tidak cuma menerima callback, dia juga mengirim webhook ke merchant. Arah keluar ini punya risiko sendiri.
SSRF. Merchant mendaftarkan URL webhook http://169.254.169.254/latest/meta-data/ atau http://127.0.0.1:5432. Kalau servermu menurut, kamu baru saja membocorkan jaringan internal. Yang diuji: URL harus publik, skema harus HTTPS, dan resolusi DNS harus diperiksa, termasuk redirect.
Rate limit. Uji dengan 200 request per menit dari satu token. Kalau tidak ada pembatasan, satu integrasi yang salah bisa menghabiskan kapasitas untuk semua merchant.
Kebocoran error. Kirim payload rusak dan lihat responsnya. Stack trace, nama tabel, atau versi framework di body respons adalah temuan.
Validasi input. Nominal, email, nomor telepon, dan ID referensi harus divalidasi dengan aturan yang jelas. Uji juga input yang sangat panjang, karakter unicode, dan angka dengan notasi ilmiah.
Sembilan: uji infrastruktur
Ini bagian yang tidak bisa diuji dari dalam aplikasi, dan sering terlewat karena dianggap urusan orang lain.
- HTTPS wajib. Coba akses lewat
http://, harus redirect, bukan melayani. - TLS 1.2 ke atas. Uji dengan
openssl s_client, pastikan TLS 1.0 dan 1.1 ditolak. - Database tidak boleh publik. Dari mesin luar, coba
nc -vz host 5432. Kalau terbuka, itu temuan kritis, bukan saran. - Secret tidak ada di repo. Jalankan pemindai seperti Gitleaks pada seluruh riwayat, bukan hanya commit terakhir.
- Firewall. Hanya port 80, 443, dan port SSH yang dibutuhkan yang terbuka.
- Backup terenkripsi, dan restore-nya pernah diuji. Backup yang belum pernah direstore bukan backup, itu harapan.
Sepuluh: uji kebersihan data dan log
Data kartu, token, dan kunci API tidak boleh muncul di tempat yang bisa dibaca orang yang salah.
Yang diuji:
- Log aplikasi tidak memuat nomor kartu, CVV, atau token akses penuh.
- Payload mentah yang disimpan untuk audit dienkripsi atau dimasking.
- Kunci API hanya ditampilkan sekali saat dibuat, setelah itu hanya hint.
- Rotasi secret benar-benar bekerja, bukan hanya ada di dokumentasi.
- Data sensitif dienkripsi di level kolom atau disk.
- Akses ke basis data dibatasi dan tercatat.
Satu kebiasaan yang membantu: tambahkan test yang sengaja memicu error pada jalur pembayaran, lalu periksa isi file log. Kalau ada data sensitif di situ, itu temuan yang harus diperbaiki sebelum rilis.
Alat yang dipakai di tiap tahap
Tidak semua pengujian butuh alat khusus, tapi beberapa akan sangat mempercepat pekerjaan.
| Alat | Kapan dipakai |
|---|---|
| Semgrep | analisis statis, mencari pola rawan di kode sendiri |
| OWASP ZAP | pemindaian dinamis endpoint dan form |
| Burp Suite | pengujian manual, terutama memodifikasi request |
| Trivy | memindai image container dan dependency |
| Gitleaks | mencari secret yang bocor di riwayat git |
| Nmap | memeriksa port yang terbuka dari luar |
| Snyk | memantau kerentanan dependency berkelanjutan |
| CrowdSec | deteksi dan mitigasi penyusupan saat produksi |
Urutan pemakaiannya masuk akal dimulai dari yang paling murah: Gitleaks dan Semgrep sebelum rilis pertama, ZAP dan Burp sebelum uji penetrasi, Trivy dan Snyk berkelanjutan, Nmap dan CrowdSec setelah aplikasi hidup.
Gate sebelum produksi
Bagian ini yang membuat assessment punya gigi. Tanpa kriteria lulus yang jelas, setiap temuan akan berakhir sebagai "nanti saja".
| Persyaratan | Kriteria lulus |
|---|---|
| Temuan kritis | nol |
| Temuan tinggi | nol |
| Validasi pembayaran | seluruh test kritis lulus |
| Verifikasi tanda tangan | terbukti menolak yang tidak valid |
| Idempotensi | tidak ada duplikasi saat callback diulang |
| Autentikasi | seluruh endpoint terlindungi |
| Otorisasi | tidak ada akses lintas merchant |
| Restore backup | berhasil diuji di lingkungan bersih |
| Audit log | perubahan status tercatat |
| Persetujuan | reviewer menandatangani |
Jangan menguji skenario serangan di produksi dengan data nyata. Pakai lingkungan staging dengan data sintetis, dan akun uji penyedia pembayaran. Kalau harus menguji di produksi, batasi pada hal yang tidak mengubah uang, misalnya header keamanan dan konfigurasi TLS.
Kiat yang jarang ditulis di panduan
Tulis test negatif lebih dulu. Sebelum fitur pembayaran selesai, tulis test untuk request yang seharusnya ditolak. Ini memaksa kamu memikirkan jalur serangan saat masih murah untuk diperbaiki.
Uji dengan data yang jelek. Nominal 0.1, 999999999, kode referensi dengan spasi, ID merchant milik tenant lain, dan callback dengan field yang hilang. Data rapi tidak menemukan bug.
Periksa yang tidak terlihat di layar. Banyak temuan ada di header respons, isi log, dan metadata file, bukan di halaman.
Jadikan temuan sebagai test permanen. Setiap temuan yang diperbaiki harus berubah menjadi regression test. Assessment yang diulang tanpa test akan menemukan bug yang sama dua kali.
Ukur, jangan nilai sendiri. Untuk severity, gunakan CVSS sebagai titik awal, lalu sesuaikan dengan dampak bisnis. Kebocoran daftar transaksi merchant lebih berbahaya daripada server yang versi patch-nya tertinggal satu, meski keduanya terlihat sama di daftar.
Penutup
Kalau kamu membangun payment gateway, anggap setiap endpoint yang menyentuh uang sebagai tempat yang sedang diuji orang lain, karena memang begitu keadaannya. Yang membedakan platform yang aman bukan jumlah fitur keamanan yang dipasang, tapi kebiasaan membuktikan bahwa setiap jalur penolakan benar-benar menolak.
Mulailah dari lima pengujian yang paling murah dan paling berdampak: manipulasi nominal, idempotensi callback, verifikasi tanda tangan, race condition pada saldo, dan akses lintas merchant. Kalau kelimanya lulus dengan bukti, bukan dengan asumsi, sisanya akan terasa jauh lebih mudah.







