API sering dibuat terburu-buru karena aplikasi mobile harus segera rilis. Login dengan token sudah jalan, data tampil di aplikasi, dan semua terlihat beres. Masalahnya baru ketahuan berbulan-bulan kemudian: seseorang mengganti angka /api/orders/1021 menjadi 1022 dan bisa melihat pesanan pelanggan lain lengkap dengan alamat dan nomor teleponnya. Tidak ada yang "dibobol"; API-nya memang tidak pernah memeriksa kepemilikan data.
Daftar OWASP API Security Top 10 menempatkan masalah semacam ini di posisi pertama. Artikel ini membahas celah API yang paling sering ditemukan di aplikasi bisnis dan cara menutupnya di Laravel, lengkap dengan kode. Jika Anda belum punya autentikasi token, mulai dulu dari tutorial membuat REST API dengan Laravel Sanctum.
1. Periksa Kepemilikan Data di Setiap Endpoint
Celah ini disebut Broken Object Level Authorization (BOLA). Middleware auth:sanctum hanya memastikan request membawa token yang sah, bukan bahwa pemilik token berhak atas data yang diminta.
// RENTAN — siapa pun yang login bisa membaca pesanan siapa saja
public function show(int $id)
{
return new OrderResource(Order::findOrFail($id));
}
// AMAN — query dimulai dari relasi milik user yang login
public function show(Request $request, int $id)
{
$order = $request->user()->orders()->findOrFail($id); // 404 bila bukan miliknya
return new OrderResource($order);
}
Pola "mulai dari relasi user" lebih sulit terlewat dibanding menambahkan where('user_id', ...) manual di setiap query. Untuk aturan yang lebih rumit, misalnya admin cabang boleh melihat pesanan cabangnya, pakai Policy dan panggil Gate::authorize('view', $order).
Ingat juga bahwa ID berurutan memudahkan penyerang menebak data. Memakai UUID membantu, tetapi bukan pengganti pengecekan kepemilikan.
2. Kirim Hanya Field yang Dibutuhkan
Mengembalikan model mentah seperti return $user; berisiko membocorkan kolom yang tidak seharusnya terlihat, misalnya remember_token, catatan internal, atau harga modal. Aplikasi mobile mungkin tidak menampilkannya, tetapi siapa pun bisa melihat respons JSON lewat proxy atau DevTools. Gunakan API Resource dan tulis field secara eksplisit:
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price,
'stock' => $this->stock,
// harga_modal dan supplier_id sengaja tidak dikirim
'cost' => $this->when($request->user()?->is_admin, $this->harga_modal),
];
}
}
Tambahkan juga protected $hidden = ['password', 'remember_token']; di model sebagai lapisan pengaman bila suatu saat ada developer yang lupa memakai Resource.
3. Validasi Input dan Cegah Mass Assignment
Request API sama tidak tepercayanya dengan form web. Jangan pernah meneruskan $request->all() ke create() atau update(); penyerang bisa menambahkan field seperti "status": "lunas" atau "user_id": 1.
public function update(Request $request, int $id)
{
$order = $request->user()->orders()->findOrFail($id);
$data = $request->validate([
'catatan' => ['nullable', 'string', 'max:500'],
'alamat_kirim' => ['required', 'string', 'max:255'],
]);
$order->update($data); // status, total, user_id tidak bisa diubah lewat endpoint ini
return new OrderResource($order);
}
4. Batasi Jumlah Data per Request
Parameter per_page tanpa batas memungkinkan satu request menarik puluhan ribu baris sekaligus. Selain membebani server, ini memudahkan pencurian data massal.
$perPage = min(max((int) $request->input('per_page', 20), 1), 100);
return ProductResource::collection(Product::paginate($perPage));
5. Rate Limiting per Pengguna dan per Endpoint
Di Laravel 11, throttling API diaktifkan di bootstrap/app.php, sedangkan batasnya didefinisikan di AppServiceProvider:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleApi(); // memakai limiter bernama "api"
})
// app/Providers/AppServiceProvider.php
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Endpoint mahal atau sensitif diberi batas sendiri
RateLimiter::for('otp', fn (Request $request) => Limit::perMinute(3)->by($request->ip()));
Route::post('/otp/send', [OtpController::class, 'send'])->middleware('throttle:otp');
Endpoint yang mengirim SMS, WhatsApp, atau email wajib diberi batas ketat, karena setiap request bisa langsung menjadi biaya.
6. Atur CORS Seperlunya
CORS menentukan domain mana yang boleh memanggil API Anda dari browser. Nilai 'allowed_origins' => ['*'] memang membuat error CORS hilang, tetapi juga mengizinkan situs mana pun memanggil API dari browser pengunjungnya. Di Laravel 11, terbitkan file konfigurasinya lalu isi domain frontend Anda saja:
php artisan config:publish cors
// config/cors.php
'paths' => ['api/*'],
'allowed_origins' => ['https://app.tokoanda.com'],
'supports_credentials' => false,
CORS hanya berlaku untuk browser. Aplikasi mobile dan server tidak terpengaruh, jadi CORS bukan pengganti autentikasi.
7. Verifikasi Signature Webhook
Endpoint webhook dari payment gateway atau layanan pengiriman biasanya tidak memakai login. Tanpa verifikasi, siapa pun bisa mengirim request palsu "pembayaran berhasil" dan pesanan langsung berstatus lunas. Hampir semua penyedia mengirim signature HMAC; verifikasi sebelum memproses apa pun:
public function handle(Request $request)
{
$expected = hash_hmac('sha256', $request->getContent(), config('services.partner.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('X-Signature'))) {
abort(401, 'Signature tidak valid');
}
// Proses idempoten: abaikan event yang sudah pernah diproses
$event = WebhookEvent::firstOrCreate(['event_id' => $request->input('id')]);
if (! $event->wasRecentlyCreated) {
return response()->noContent();
}
// ... update status pesanan
}
Gunakan hash_equals(), bukan ==, agar perbandingan tidak bocor lewat perbedaan waktu eksekusi. Nama header dan algoritma signature berbeda tiap penyedia, jadi cocokkan dengan dokumentasinya. Contoh penerapan dengan Midtrans ada di artikel integrasi payment gateway Midtrans di Laravel.
8. Kelola Token dengan Disiplin
- Beri masa berlaku. Token yang tidak pernah kedaluwarsa tetap bisa dipakai bertahun-tahun setelah ponsel karyawan hilang.
- Satu token per perangkat. Pengguna bisa mencabut akses satu perangkat tanpa logout dari semuanya.
- Batasi kemampuan token. Token untuk integrasi laporan cukup bisa membaca, tidak perlu bisa menghapus.
- Jangan taruh token di URL. Query string tercatat di log server dan riwayat browser. Kirim lewat header
Authorization: Bearer ....
9. Pesan Error yang Konsisten dan Tidak Membocorkan Detail
Pastikan APP_DEBUG=false di produksi agar error 500 tidak mengirim stack trace dalam JSON. Gunakan juga status code yang tepat: 401 untuk belum login, 403 untuk tidak berhak, 404 untuk data yang tidak ada atau bukan milik pengguna, 422 untuk validasi, dan 429 untuk terlalu banyak request. Klien jadi mudah menangani error, dan penyerang tidak mendapat petunjuk tambahan.
Checklist Keamanan API
| Area | Pertanyaan | Cara menguji |
|---|---|---|
| Kepemilikan data | Apakah setiap endpoint {id} memeriksa pemilik? | Pakai token user A untuk membuka ID milik user B: harus 404/403 |
| Field respons | Apakah ada kolom internal yang ikut terkirim? | Periksa JSON mentah di Postman |
| Mass assignment | Bisakah field status/total diubah lewat request? | Tambahkan field "status":"lunas" pada request update |
| Paginasi | Apakah per_page dibatasi? | Kirim per_page=100000 |
| Rate limit | Apakah endpoint login, OTP, dan pencarian dibatasi? | Kirim request beruntun: harus muncul 429 |
| CORS | Apakah origin dibatasi? | Cek header Access-Control-Allow-Origin |
| Webhook | Apakah signature diverifikasi dan event idempoten? | Kirim webhook tanpa signature: harus 401 |
| Token | Apakah token punya masa berlaku dan bisa dicabut? | Cek kolom expires_at di tabel token |
Sebagian besar pengujian di atas bisa dijalankan dengan Postman dalam satu sore. Lakukan sebelum aplikasi mobile dirilis, dan ulangi setiap ada endpoint baru. Lapisan database juga perlu diperiksa; baca panduan mencegah SQL injection dan checklist keamanan Laravel untuk melengkapinya.