Magic Link Authentication — Auth

Magic link menghilangkan password: user memasukkan email, server mengirim tautan sekali pakai. Klik tautan → langsung login. Slack, Notion, Substack memakai…

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"}
  1. Server membuat token acak, menyimpan hash-nya bersama user_id dan waktu kedaluwarsa, lalu mengirim email berisi https://portal.sekolah.test/login/magic?token=tpmgXDRJ3orPtbFOFHTJ7hsTekLTOsYF9t7y0VOpiC0. Token aslinya cuma ada di email.
  2. 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?
  3. 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
}

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

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();

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari