Laravel Signed URL cocok untuk link aksi yang harus valid tanpa login penuh, misalnya verifikasi email, undangan, approve dokumen, atau download privat. Namun, tanda tangan URL saja tidak otomatis membuat link aman dipakai sekali. Jika link bocor, disalin, atau dipakai ulang, aksi yang sama bisa tetap dieksekusi kecuali Anda menambahkan mekanisme konsumsi token, expiry, audit, dan pembatasan percobaan.
Panduan ini fokus pada implementasi yang bisa langsung dipakai di proyek Laravel: perbedaan signed URL biasa vs temporary signed URL, validasi signature, expiry, mitigasi replay, nonce atau tabel token yang dikonsumsi, rate limit, audit log, serta keputusan kapan signed URL sudah cukup dan kapan harus digabung dengan auth atau session tambahan.
Kapan memakai signed URL di Laravel
Signed URL berguna ketika server perlu memastikan bahwa parameter di URL tidak diubah oleh klien. Laravel menambahkan signature berdasarkan route dan parameter, lalu middleware atau validasi request memeriksa bahwa URL masih utuh dan, untuk versi sementara, belum kedaluwarsa.
Contoh use case yang cocok
- Verifikasi email: pengguna menekan link dari email untuk menandai alamat email sebagai terverifikasi.
- Undangan: penerima membuka link undangan ke tim, workspace, atau dokumen tertentu.
- Approve dokumen: approver menerima link untuk menyetujui dokumen dari email.
- Download privat: file hanya boleh diunduh melalui link yang ditandatangani dan memiliki masa berlaku pendek.
Signed URL biasa vs temporary signed URL
- Signed URL biasa: memastikan URL tidak dimodifikasi, tetapi tidak memiliki batas waktu bawaan. Cocok hanya jika link memang boleh hidup lama dan risikonya rendah.
- Temporary signed URL: selain memastikan integritas parameter, juga menambahkan waktu kedaluwarsa. Ini biasanya pilihan yang lebih aman untuk link email dan aksi sensitif.
Intinya: signed URL melindungi integritas URL, bukan otomatis menjadikannya sekali pakai. Untuk aksi yang tidak boleh dipakai ulang, Anda tetap perlu lapisan tambahan.
Ancaman yang perlu Anda antisipasi
1. Replay attack
Jika link yang sama bisa dipakai lebih dari sekali, penyerang atau penerima email dapat mengeksekusi aksi berulang. Ini masalah utama pada approve dokumen, redeem undangan tertentu, atau download yang dibatasi.
2. Token atau link bocor
Link bisa bocor melalui forward email, screenshot, browser history, referer ke pihak ketiga, log reverse proxy, atau chat internal. Begitu bocor, signature masih valid karena memang dibuat oleh server.
3. Brute force atau enumeration
Meskipun signature sulit ditebak, identifier lain seperti ID numerik tetap bisa bocor pola aksesnya. Jika route atau parameter terlalu mudah ditebak dan tidak dibatasi, attacker bisa mencoba berbagai kombinasi URL, terutama jika ada endpoint yang memberikan error berbeda untuk setiap kondisi.
4. Link dipakai ulang setelah sukses
Ini bukan bug Laravel, tetapi bug desain alur bisnis. Signature valid berarti URL sah, bukan berarti aksi belum pernah dijalankan. Solusinya adalah menyimpan status konsumsi atau nonce di server.
Pola dasar implementasi signed URL
Membuat route dengan middleware validasi signature
Untuk endpoint GET yang dieksekusi dari email, gunakan middleware signed agar request dengan signature salah atau parameter yang diubah langsung ditolak.
use App\Http\Controllers\InvitationAcceptController;
use App\Http\Controllers\PrivateDownloadController;
use Illuminate\Support\Facades\Route;
Route::get('/invitations/accept/{invite}', InvitationAcceptController::class)
->name('invitations.accept')
->middleware(['signed', 'throttle:10,1']);
Route::get('/downloads/private/{file}', PrivateDownloadController::class)
->name('downloads.private')
->middleware(['signed', 'throttle:20,1']);Middleware signed cocok untuk validasi awal. Anda tetap bisa menambah pemeriksaan manual di controller bila ingin kontrol yang lebih spesifik, misalnya respons JSON atau audit detail saat signature gagal.
Menghasilkan signed URL dan temporary signed URL
use Illuminate\Support\Facades\URL;
// Signed URL tanpa expiry
$url = URL::signedRoute('invitations.accept', [
'invite' => $invite->id,
'email' => $invite->email,
]);
// Temporary signed URL dengan expiry
$tempUrl = URL::temporarySignedRoute(
'downloads.private',
now()->addMinutes(15),
[
'file' => $file->id,
'disposition' => 'attachment',
]
);Poin pentingnya:
- Masukkan parameter yang memang perlu diikat oleh signature.
- Jangan memasukkan data rahasia mentah ke query string jika tidak perlu.
- Untuk kasus sensitif, pilih temporary signed URL agar ada jendela waktu yang sempit.
Validasi manual bila diperlukan
Selain middleware, Anda bisa memeriksa signature secara manual pada request. Ini berguna jika endpoint memiliki logika bercabang atau Anda ingin pesan error yang berbeda.
use Illuminate\Http\Request;
public function __invoke(Request $request)
{
if (! $request->hasValidSignature()) {
abort(403, 'Link tidak valid atau sudah berubah.');
}
// lanjutkan proses
}Jika URL memiliki expiry dari temporary signed URL, validasi ini juga akan gagal setelah waktu habis.
Membuat link benar-benar sekali pakai
Di sinilah banyak implementasi berhenti terlalu cepat. Signature yang valid tidak mencegah reuse. Untuk aksi sekali pakai, tambahkan state di server yang menandai bahwa link atau token sudah dikonsumsi.
Pendekatan 1: tabel konsumsi token atau nonce
Tambahkan kolom nonce acak dan status konsumsi pada entitas yang dipakai di URL, atau buat tabel terpisah jika satu resource bisa memiliki banyak link aktif.
Schema::create('action_links', function ($table) {
$table->id();
$table->string('action_type');
$table->unsignedBigInteger('subject_id');
$table->string('recipient')->nullable();
$table->string('nonce', 64)->unique();
$table->timestamp('expires_at')->nullable();
$table->timestamp('consumed_at')->nullable();
$table->string('consumed_ip', 45)->nullable();
$table->string('created_by_type')->nullable();
$table->unsignedBigInteger('created_by_id')->nullable();
$table->timestamps();
});Saat membuat URL, kirim nonce sebagai parameter yang ikut ditandatangani.
$link = ActionLink::create([
'action_type' => 'approve_document',
'subject_id' => $document->id,
'recipient' => $approverEmail,
'nonce' => bin2hex(random_bytes(16)),
'expires_at' => now()->addHours(24),
]);
$url = URL::temporarySignedRoute(
'documents.approve',
$link->expires_at,
[
'document' => $document->id,
'nonce' => $link->nonce,
]
);Konsumsi token secara atomik
Untuk mencegah dua klik hampir bersamaan mengeksekusi aksi ganda, tandai token sebagai terpakai dalam transaksi dan lakukan pemeriksaan dengan kondisi yang ketat.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function __invoke(Request $request, Document $document)
{
if (! $request->hasValidSignature()) {
abort(403, 'Signature tidak valid.');
}
$nonce = $request->string('nonce');
DB::transaction(function () use ($document, $nonce, $request) {
$link = DB::table('action_links')
->where('action_type', 'approve_document')
->where('subject_id', $document->id)
->where('nonce', $nonce)
->lockForUpdate()
->first();
if (! $link) {
abort(404);
}
if ($link->consumed_at !== null) {
abort(409, 'Link sudah pernah dipakai.');
}
if ($link->expires_at !== null && now()->gt($link->expires_at)) {
abort(410, 'Link sudah kedaluwarsa.');
}
DB::table('documents')
->where('id', $document->id)
->update([
'approved_at' => now(),
'approved_by_email' => $link->recipient,
]);
DB::table('action_links')
->where('id', $link->id)
->update([
'consumed_at' => now(),
'consumed_ip' => $request->ip(),
'updated_at' => now(),
]);
});
return redirect()->route('documents.show', $document);
}Mengapa perlu transaksi dan lock? Karena tanpa itu, dua request paralel bisa sama-sama lolos pengecekan consumed_at masih null, lalu mengeksekusi aksi dua kali.
Pendekatan 2: status pada resource langsung
Untuk kasus sederhana seperti verifikasi email, status pada resource utama kadang cukup. Misalnya, jika email sudah terverifikasi maka klik berikutnya hanya mengembalikan respons idempotent. Pendekatan ini lebih sederhana, tetapi kurang fleksibel jika Anda butuh banyak link aktif untuk satu resource atau audit per link.
Idempotent vs sekali pakai
Jika aksi aman dijalankan berulang tanpa efek samping tambahan, Anda bisa memilih idempotent success: klik kedua tetap sukses tetapi tidak mengubah apa-apa. Untuk approve dokumen, transfer akses, atau redeem voucher, biasanya Anda perlu strict one-time use dengan penolakan jelas saat link dipakai ulang.
Hardening yang sebaiknya ditambahkan
1. Expiry pendek dan sesuai konteks
Jangan memberi masa hidup lama hanya karena nyaman. Gunakan durasi berdasarkan risiko:
- Download privat: menit, bukan hari.
- Approve dari email: jam atau maksimal 1 hari, tergantung proses bisnis.
- Undangan: bisa lebih panjang, tetapi tetap kombinasikan dengan status invite.
Semakin sensitif aksinya, semakin pendek expiry yang masuk akal.
2. Rate limiting
Tambahkan throttle pada route untuk menahan abuse, replay cepat, atau percobaan otomatis. Ini bukan pengganti signature, tetapi lapisan pengaman tambahan yang murah.
Route::get('/documents/{document}/approve', ApproveDocumentController::class)
->name('documents.approve')
->middleware(['signed', 'throttle:5,1']);Jika perlu, definisikan limiter khusus berdasarkan kombinasi IP, route, atau nonce agar percobaan berulang pada link yang sama cepat dibatasi.
3. Audit log
Untuk aksi sensitif, simpan jejak minimal: kapan link dibuat, siapa targetnya, kapan diakses, hasil validasi, IP, user agent ringkas, dan apakah sukses atau gagal. Audit log memudahkan investigasi insiden dan debugging kasus “link sudah dipakai” atau “signature gagal padahal baru”.
DB::table('audit_logs')->insert([
'event' => 'document.approval_link_used',
'subject_type' => 'document',
'subject_id' => $document->id,
'metadata' => json_encode([
'ip' => $request->ip(),
'nonce' => (string) $request->query('nonce'),
'result' => 'success',
]),
'created_at' => now(),
]);Jika Anda menyimpan metadata sensitif, pastikan kebijakan retensi dan akses log jelas.
4. Jangan bocorkan informasi berlebihan di URL
Hindari parameter yang mengandung data rahasia atau mudah dibaca publik, misalnya nomor dokumen internal yang sensitif, email lengkap jika tidak perlu, atau scope akses yang terlalu besar. Signature mencegah modifikasi, tetapi tidak menyembunyikan isi URL.
5. Gunakan identifier acak jika perlu
Untuk resource sensitif, pertimbangkan identifier acak atau UUID daripada ID berurutan. Ini tidak menggantikan otorisasi, tetapi mengurangi enumeration dan kebocoran pola data.
6. Invalidasi setelah sukses
Setelah aksi berhasil, tandai link sebagai terpakai dan, bila perlu, batalkan semua link sejenis yang masih aktif untuk resource yang sama. Contoh: setelah satu undangan diterima, semua link undangan lama ke email yang sama bisa di-nonaktifkan.
7. Respons error yang aman
Jangan terlalu detail pada respons publik. Bedakan detail untuk log internal, bukan untuk attacker. Misalnya, respons umum “Link tidak valid atau kedaluwarsa” sering lebih aman daripada menjelaskan apakah nonce ada tetapi signature salah.
Kapan signed URL cukup, dan kapan harus digabung auth/session
Signed URL cukup jika
- Aksi berisiko rendah atau memiliki efek terbatas.
- Penerima link memang diharapkan bisa bertindak tanpa login.
- Anda sudah menambahkan expiry, konsumsi token, dan rate limit jika aksinya sekali pakai.
- Dampak jika link bocor masih dapat diterima atau dibatasi oleh desain bisnis.
Perlu digabung dengan auth atau session tambahan jika
- Aksi berdampak tinggi, misalnya perubahan data penting, persetujuan legal, atau akses file yang sangat sensitif.
- Anda harus memastikan identitas pelaku, bukan hanya kepemilikan link.
- Link bisa berpindah tangan dengan mudah, misalnya forward email antar pengguna.
- Regulasi atau audit mensyaratkan bukti autentikasi yang lebih kuat.
Pola gabung yang umum:
- Signed URL + login wajib: link hanya membuka halaman yang sudah tahu target aksinya, tetapi eksekusi tetap butuh session valid.
- Signed URL + re-auth: untuk aksi kritis, minta password ulang atau faktor tambahan.
- Signed URL + cocokkan identitas: setelah login, pastikan user yang login sesuai dengan penerima invite atau approver yang dituju.
Prinsip praktis: jika konsekuensi penyalahgunaan link tinggi, jangan jadikan possession of link sebagai satu-satunya bukti otorisasi.
Contoh pola implementasi per use case
Verifikasi email
Biasanya cukup dengan temporary signed URL dan status verifikasi pada user. Klik kedua bisa dibuat idempotent: jika email sudah terverifikasi, tampilkan pesan sukses yang sama tanpa error keras.
Undangan ke tim
Gunakan temporary signed URL + tabel invite dengan status pending/accepted/revoked/expired. Setelah diterima, ubah status menjadi accepted dan tolak pemakaian ulang. Jika undangan harus terkait email tertentu, cocokkan alamat email penerima setelah user login.
Approve dokumen
Gunakan temporary signed URL + nonce satu kali + audit log. Untuk dokumen sensitif, tambahkan autentikasi atau setidaknya konfirmasi tambahan sebelum final approve. Ini mengurangi risiko email diteruskan ke pihak yang salah.
Download privat
Untuk file berukuran besar atau disajikan lewat storage privat, signed URL dengan expiry pendek sering cukup. Jika file hanya boleh diunduh sekali, simpan status konsumsi per link. Jika file boleh diunduh berkali-kali dalam jendela waktu singkat, Anda bisa melewatkan konsumsi satu kali dan cukup memakai expiry pendek + logging.
Kesalahan umum yang sering terjadi
- Menganggap signed URL otomatis sekali pakai. Ini kesalahan desain paling umum.
- Tidak ada expiry untuk link yang dikirim lewat email.
- Tidak memakai transaksi saat mengonsumsi token, sehingga rawan race condition.
- Parameter penting tidak ikut disign karena dibangun di luar route atau diolah ulang secara tidak konsisten.
- Meletakkan data rahasia di query string.
- Tidak ada audit log sehingga sulit menelusuri misuse.
- Tidak ada revocation saat objek bisnis berubah, misalnya invite dibatalkan tetapi link lama masih bisa diakses.
Debugging saat signature atau expiry gagal
Cek hal berikut
- URL berubah setelah dibuat: tambahan slash, perubahan host, proxy, atau parameter urutan tertentu bisa menyebabkan mismatch.
- Perbedaan domain atau scheme: pembuatan URL di environment berbeda dari akses aktual bisa memengaruhi validasi bila aplikasi berada di belakang proxy.
- Waktu server tidak sinkron: temporary signed URL bergantung pada waktu yang akurat.
- Parameter query dimodifikasi klien: termasuk encoding yang berubah saat melewati sistem pihak ketiga.
Jika kasus banyak terjadi di produksi, log-kan host, path, query, dan hasil validasi secara aman agar Anda bisa membedakan apakah masalah berasal dari expiry, modifikasi URL, atau konfigurasi proxy.
Checklist hardening Laravel Signed URL untuk produksi
- Gunakan temporary signed URL untuk hampir semua link aksi dari email.
- Tambahkan middleware signed pada route.
- Untuk aksi sekali pakai, simpan nonce/token di server dan tandai consumed_at setelah sukses.
- Lakukan konsumsi token secara atomik dengan transaksi dan/atau row lock.
- Terapkan rate limiting pada endpoint.
- Simpan audit log untuk create, access, success, failure, revoke.
- Batasi masa hidup link sesuai risiko bisnis.
- Hindari data sensitif di query string; gunakan identifier minimal.
- Tambahkan revocation saat state bisnis berubah.
- Untuk aksi berisiko tinggi, gabungkan dengan auth/session tambahan.
Penutup
Laravel Signed URL adalah fondasi yang baik untuk link aksi seperti verifikasi email, undangan, approve dokumen, dan download privat, tetapi keamanan nyata datang dari kombinasi beberapa lapisan: signature, expiry, konsumsi token satu kali, audit log, dan rate limit. Jika tujuan Anda adalah link aksi sekali pakai, jangan berhenti di middleware signed. Simpan status di server dan invalidasi link setelah berhasil dipakai.
Dengan pola ini, Anda mendapatkan implementasi yang tidak hanya valid secara framework, tetapi juga tahan terhadap replay, kebocoran link, dan pemakaian ulang yang sering luput pada implementasi awal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!