Magic link adalah login tanpa password: kamu mengetik email, server mengirim link sekali pakai ke inbox-mu, dan link itu yang membuatmu login. Bayangin tiket konser yang dikirim kurir ke kotak suratmu. Cuma orang yang bisa membuka kotak surat itu yang memegangnya, tiketnya berlaku sebentar, dan petugas pintu menyobeknya begitu kamu masuk. Petugasnya juga gak menyimpan salinan tiket, cuma sidik jarinya, jadi daftar di mejanya gak bisa dipakai untuk masuk.
🧪 Tebakan cepat: database Portal Sekolah bocor, dan baris magic link Siti yang belum dipakai ikut terbaca, lengkap dengan kolom
token_hash. Bisakah isi kolom itu dipakai untuk login sebagai Siti? Tebak dulu, jawabannya ada di bagian "Simpan hash-nya, bukan token-nya".
Alurnya, dari minta link sampai login
Siti sedang di komputer lab sekolah. Ini percakapan HTTP-nya di Portal Sekolah, persis yang ditampilkan Lab (token di Lab acak, jadi punyamu beda):
POST /login/magic HTTP/1.1
Host: portal.sekolah.test
{"email": "[email protected]"}
HTTP/1.1 200 OK
{"message": "Kalau email itu terdaftar, link login sudah dikirim."}
POST /login/magic/verify HTTP/1.1
Host: portal.sekolah.test
{"token": "tpmgXDRJ3orPtbFOFHTJ7hsTekLTOsYF9t7y0VOpiC0"}
HTTP/1.1 200 OK
Set-Cookie: sid=68d8fe15f44bf1c846cb2b4c0fce005b; Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=Lax
{"message": "Login berhasil", "user": "Siti"}
- Server membuat token acak, menyimpan hash-nya bersama
user_iddan waktu kedaluwarsa, lalu mengirim email berisihttps://portal.sekolah.test/login/magic?token=tpmgXDRJ3orPtbFOFHTJ7hsTekLTOsYF9t7y0VOpiC0. Token aslinya cuma ada di email. - Link itu membuka halaman kecil bertombol Masuk, dan tombolnya mengirim token ke
POST /login/magic/verify. Server menghitung hash-nya, mencari barisnya, lalu memeriksa: sudah dipakai? sudah kedaluwarsa? - Lolos: barisnya ditandai terpakai, lalu server membuat sesi baru, sama seperti login di lesson Session-based Authentication.
Kenapa link-nya gak langsung me-login-kan lewat GET? Banyak filter email kantor dan aplikasi chat membuka link secara otomatis untuk memeriksa isinya. Kalau GET sudah memakai token, link-nya hangus sebelum Siti sempat mengklik. Halaman bertombol yang mengirim POST memastikan yang memakai token adalah orangnya.
Membuat token: 32 byte acak
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function sha256Hex(text) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
}
const MINUTE = 60 * 1000;
const magicLinks = new Map(); // token_hash -> { userId, expiresAt, usedAt }
async function issueLink(user, now) {
const token = base64url(crypto.getRandomValues(new Uint8Array(32))); // 256 bit acak
magicLinks.set(await sha256Hex(token), { userId: user.id, expiresAt: now + 15 * MINUTE, usedAt: null });
return `https://portal.sekolah.test/login/magic?token=${token}`; // isi email
}
crypto.getRandomValues(new Uint8Array(32)): 32 byte (256 bit) dari generator acak kriptografis. Gak bisa ditebak. Jangan pakaiMath.random, dan jangan merangkai token dari email dan waktu.base64url: Base64 yang aman ditaruh di URL:+jadi-,/jadi_, tanpa=di ujung. 32 byte jadi 43 karakter.sha256Hex: SHA-256 bawaan browser dan Node lewatcrypto.subtle, hasilnya 64 karakter hex. Yang masuk tabel cuma ini.expiresAt: 15 menit dari sekarang, dalam milidetik.
Simpan hash-nya, bukan token-nya
Token tpmgXDRJ... tadi punya SHA-256 60646dddaef6c2c0718dd0d3ed7def94bcf04a867cc572f770f5def580b12f15. Ini isi tabel magic_links setelah Siti minta link pukul 07.00:
| token_hash | user_id | expires_at | used_at |
|---|---|---|---|
60646ddd...80b12f15 |
1 | 07.15 | kosong |
Saat token datang, server menghitung SHA-256-nya, lalu mencari baris dengan hash itu. Kalau yang dikirim justru isi kolom token_hash, server menghitung SHA-256 dari hash itu, hasilnya d48b5176..., dan gak ada baris yang cocok. Jadi isi database yang bocor gak bisa dipakai login. Itu jawaban tebakannya. Login butuh token mentahnya, dan token mentah cuma ada di email Siti.
Kenapa SHA-256 cukup, padahal password butuh hash yang sengaja lambat (lesson Password Hashing)? Password dipilih manusia dan bisa ditebak, jadi tiap tebakan harus dibuat mahal. Token ini 256 bit acak, gak ada daftar tebakan yang masuk akal. Hash yang cepat juga membuat pencarian langsung lewat index kolom token_hash, tanpa mengecek baris satu per satu.
Memeriksa link: tiga alasan menolak
async function verifyLink(token, now) {
const row = magicLinks.get(await sha256Hex(token));
if (!row) return { ok: false, reason: "not_found" };
if (row.usedAt !== null) return { ok: false, reason: "already_used" };
if (now >= row.expiresAt) return { ok: false, reason: "expired" };
row.usedAt = now; // sekali pakai: ditandai sebelum menjawab
return { ok: true, userId: row.userId };
}
Urutannya: gak ketemu dulu, lalu sudah terpakai, lalu kedaluwarsa. Jejak dari Lab:
| Jam | Kejadian | Hasil |
|---|---|---|
| 07.00 | Siti minta link, berlaku sampai 07.15 | email terkirim |
| 07.03 | link dibuka | ok: true, sesi baru |
| 07.04 | link yang sama dibuka lagi | already_used |
| 07.05 | Siti minta link baru, berlaku sampai 07.20 | email terkirim |
| 07.20 | link kedua dibuka | expired, karena tepat di batas sudah kedaluwarsa |
| 07.21 | yang dikirim isi token_hash |
not_found |
- Sekali pakai: link yang masih berlaku setelah dipakai bisa dipakai lagi oleh siapa pun yang menemukannya nanti, dari inbox, riwayat browser, atau email yang diteruskan.
usedAtmencegahnya. - Umur pendek: link yang tertinggal di inbox berbulan-bulan adalah kunci cadangan yang lupa dicabut. 15 menit cukup untuk membuka email.
- Ke browser, ketiga penolakan dijawab sama:
401dengan "Link login sudah gak berlaku. Minta link baru." Alasan rincinya ditulis di log server.
Di latihan, token-nya 32 byte acak dari crypto.getRandomValues, ditulis sebagai hex (64 karakter), dan waktunya angka milidetik biasa. issue("[email protected]", 1000) mengembalikan token yang berlaku sampai 1000 + 15 * 60 * 1000, dan verify yang sah mengembalikan { ok: true, email }. Urutan pemeriksaan dan tiga alasannya sama dengan verifyLink di atas. Bedanya dengan aplikasi sungguhan: latihan menyimpan token-nya apa adanya di Map, sedangkan aplikasi sungguhan cuma menyimpan SHA-256-nya.
Email terdaftar atau gak, jawabannya sama
[email protected] sudah gak terdaftar: akun alumni itu dihapus Bu Dewi di lesson pertama. Saat dia minta link, server menjawab 200 dengan pesan yang sama persis dengan jawaban untuk Siti. Bedanya cuma di dalam server: gak ada baris baru, gak ada email. Kalau jawabannya beda, misalnya "Email tidak terdaftar", form ini jadi alat untuk mengecek email mana yang punya akun. Prinsipnya sama dengan pesan login gagal di lesson Password Hashing. Kirim emailnya lewat queue, supaya waktu jawabannya juga gak beda jauh.
Dibuka di perangkat lain
Siti minta link di komputer lab, tapi membuka email di HP. Yang login adalah HP, perangkat yang membuka link, karena di sanalah Set-Cookie diterima. Komputer lab tetap belum login. Itu disengaja. Kalau server ikut me-login-kan perangkat yang meminta link hanya karena link-nya diklik di tempat lain, siapa pun yang mengetik email Siti di perangkatnya bisa ikut masuk begitu Siti mengklik. Mau login di komputer lab? Buka email di komputer itu.
Kelebihan dan kelemahannya
Gak ada password yang bisa lupa, bocor, atau dipakai ulang di situs lain. Tapi seluruh keamanan akun bergantung pada keamanan email-nya: siapa pun yang bisa membuka inbox Siti bisa login sebagai Siti. Untuk akun penting seperti admin milik Bu Dewi, tambahkan faktor kedua (lesson Multi-factor Authentication) atau pakai passkey (lesson Passwordless & Passkeys).
Di Laravel: signed URL
// routes/web.php: GET menampilkan tombol Masuk, tombolnya POST ke URL yang sama
Route::get('/login/magic/{user}', [MagicLinkController::class, 'confirm'])->name('login.magic')->middleware('signed');
Route::post('/login/magic/{user}', [MagicLinkController::class, 'login'])->middleware('signed');
// Saat Siti minta link, dan email-nya ketemu di tabel users
$url = URL::temporarySignedRoute('login.magic', now()->addMinutes(15), ['user' => $user->id]);
Mail::to($user)->queue(new MagicLinkMail($url)); // Mailable buatanmu
// MagicLinkController@login
if (! Cache::add('magic-used:'.$request->query('signature'), true, now()->addMinutes(15))) {
abort(403, 'Link login sudah dipakai.');
}
Auth::login($user);
$request->session()->regenerate();
URL::temporarySignedRoutemembuat URL sepertihttps://portal.sekolah.test/login/magic/1?expires=1768176900&signature=...:expiresdalam detik Unix,signatureadalah HMAC-SHA256 dari URL itu denganAPP_KEY. Ubah satu karakter, signature-nya gak cocok lagi.- Middleware
signedmenolak URL yang diubah atau sudah lewatexpiresdengan403 Invalid signature. - Signed URL gak tahu apakah link-nya sudah dipakai.
Cache::addcuma menulis kalau kuncinya belum ada, jadi klik kedua mendapatfalsedan ditolak. Itu yang membuatnya sekali pakai. Auth::loginlalusession()->regenerate(): sesi baru, persis seperti di lesson Session-based Authentication. RoutePOST /login/magicyang mengirim email juga diberithrottle, dibahas di lesson Rate Limiting untuk Autentikasi.
⚠️ Jebakan yang sering ketemu
- Menyimpan token mentah. Database bocor berarti semua link yang masih berlaku ikut bocor. Simpan SHA-256-nya saja.
- Lupa menandai terpakai. Klik kedua harus ditolak dengan
already_used, jadi status terpakainya harus benar-benar tersimpan ke barisnya. - Login langsung di
GET. Pemindai link di email memakainya duluan. Pakai halaman konfirmasi danPOST. - bcrypt untuk token, lalu mencocokkan baris satu per satu. Lambat dan gak perlu. Token acak 256 bit cukup SHA-256, dicari lewat index.
TL;DR: Magic link: email masuk, server mengirim link berisi token acak 32 byte (base64url, 43 karakter), dan menyimpan SHA-256-nya saja bersama user_id, expires_at (15 menit), dan used_at. Saat link dipakai lewat halaman konfirmasi dan POST, server meng-hash token yang datang, lalu menolak berurutan: not_found, already_used, expired (tepat di batas sudah kedaluwarsa). Yang sah ditandai terpakai dulu, baru dibuatkan sesi baru. Jawaban permintaan link selalu sama, terdaftar atau gak. Yang login adalah perangkat yang membuka link. Hash yang bocor gak bisa dipakai login. Kelemahannya: keamanan akun bergantung pada keamanan email. Di Laravel: URL::temporarySignedRoute dan middleware signed, ditambah catatan terpakai seperti Cache::add, karena signed URL sendiri bisa dipakai berkali-kali sampai expires.