Alamat email yang diketik pengguna saat mendaftar pada dasarnya hanyalah klaim yang belum terbukti. Pengguna bisa salah ketik, tanpa sengaja memakai alamat milik orang lain, atau sengaja memakai alamat sekali pakai untuk menghindari tanggung jawab. Verifikasi email adalah mekanisme untuk membuktikan bahwa alamat tersebut benar-benar dapat diakses oleh pengguna yang mendaftar. Pada chapter ini kita akan membangun alur verifikasi lengkap: penanda status pada data pengguna, token sekali pakai, masa berlaku token, jeda pengiriman ulang, serta pembatasan fitur bagi akun yang belum terverifikasi.

Mengapa Verifikasi Email Diperlukan #

Tanpa verifikasi, setiap email yang dikirim aplikasi berisiko mendarat di alamat yang salah. Bayangkan tautan reset password dari chapter sebelumnya terkirim ke alamat orang lain karena pemilik akun salah mengetik satu huruf saat registrasi: orang tersebut bisa membajak akun hanya dengan membuka kotak masuknya. Verifikasi email menutup celah ini sejak awal.

Manfaat konkret verifikasi email antara lain:

  • Mencegah salah ketik alamat — kesalahan ketahuan sebelum akun dipakai serius.
  • Melindungi alur pemulihan akun — tautan reset password hanya dikirim ke alamat yang terbukti milik pengguna.
  • Mempersempit akun spam — pembuatan akun massal menjadi lebih mahal karena setiap akun butuh email nyata.
  • Menjaga keandalan komunikasi — invoice, notifikasi, dan pengumuman sampai ke tujuan yang benar.

Menandai Status Verifikasi pada Data Pengguna #

Status verifikasi paling lazim disimpan sebagai kolom email_verified_at bertipe teks waktu (timestamp) yang bernilai NULL selama belum terverifikasi. Dibandingkan kolom boolean sederhana, kolom waktu memberi informasi kapan verifikasi terjadi — berguna untuk audit dan kebijakan seperti “akun terverifikasi minimal 7 hari boleh menarik dana”. Mari kita lihat wujudnya pada SQLite:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    nama TEXT NOT NULL,
    email_verified_at TEXT
)");

$pdo->prepare("INSERT INTO users (email, nama) VALUES (?, ?)")
    ->execute(["[email protected]", "Budi"]);

$user = $pdo->query("SELECT email, email_verified_at FROM users")
    ->fetch(PDO::FETCH_ASSOC);

echo "Email: {$user['email']}\n";
var_dump($user["email_verified_at"]);
var_dump($user["email_verified_at"] !== null);

Output yang Dihasilkan:

text
Email: [email protected]
NULL
bool(false)

Baris CREATE TABLE mendefinisikan kolom email_verified_at tanpa NOT NULL, sehingga nilai bawaannya adalah NULL. Setelah Budi didaftarkan, var_dump menunjukkan NULL — status “belum terverifikasi” — dan perbandingan !== null mengembalikan bool(false). Pemeriksaan !== null inilah yang nanti kita pakai sebagai syarat di berbagai fitur.

Alur Token Verifikasi #

Cara standar membuktikan kepemilikan alamat adalah mengirim tautan berisi token acak sekali pakai. Token dibuat di server, dikirim ke alamat email yang diklaim, dan hanya orang yang benar-benar bisa membuka kotak masuk alamat tersebut yang dapat mengeklik tautannya. Alur lengkapnya:

  1. Saat registrasi berhasil, sistem membuat token acak dan menyimpan hash token tersebut bersama user_id.
  2. Sistem mengirim email berisi tautan seperti https://example.com/verify?token=<token>.
  3. Pengguna mengeklik tautan; handler verifikasi mencari hash token di database.
  4. Jika hash ditemukan, kolom email_verified_at diisi waktu sekarang dan baris token dihapus.
  5. Percobaan memakai token yang sama untuk kedua kalinya gagal karena barisnya sudah hilang.

Berikut potongan kode sisi server saat token dibuat:

php
<?php
// Sisi server, saat registrasi berhasil:
$token = bin2hex(random_bytes(32));   // token acak, 64 karakter heksadesimal
$tokenHash = hash("sha256", $token);  // hanya hash ini yang disimpan

// $token dikirim kepada pengguna lewat tautan dalam email:
// https://example.com/verify?token=<token>
// Tabel email_verification_tokens menyimpan $tokenHash, user_id, dan expires_at.

Fungsi random_bytes(32) menghasilkan 32 byte acak dari generator yang aman secara kriptografis, lalu bin2hex() mengubahnya menjadi 64 karakter heksadesimal yang aman diletakkan di URL. Kita menyimpan hash("sha256", $token), bukan token mentahnya, sehingga kebocoran isi database tidak langsung membuka tautan verifikasi siapa pun.

Sekarang kita rangkai alur penuhnya dalam satu skrip yang bisa dijalankan — termasuk simulasi “Budi mengeklik tautan di emailnya”:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    nama TEXT NOT NULL,
    email_verified_at TEXT
)");
$pdo->exec("CREATE TABLE email_verification_tokens (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL
)");

function createVerificationToken(PDO $pdo, int $userId): string
{
    $token = bin2hex(random_bytes(32));
    $stmt = $pdo->prepare(
        "INSERT INTO email_verification_tokens (token_hash, user_id) VALUES (?, ?)"
    );
    $stmt->execute([hash("sha256", $token), $userId]);

    return $token; // dikirim lewat email, tidak pernah disimpan mentah
}

function verifyEmail(PDO $pdo, string $token): bool
{
    $tokenHash = hash("sha256", $token);
    $stmt = $pdo->prepare(
        "SELECT user_id FROM email_verification_tokens WHERE token_hash = ?"
    );
    $stmt->execute([$tokenHash]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row === false) {
        return false; // token tidak dikenal atau sudah pernah dipakai
    }

    $pdo->prepare("UPDATE users SET email_verified_at = ? WHERE id = ?")
        ->execute(["2026-01-15 08:05:00", $row["user_id"]]);
    $pdo->prepare("DELETE FROM email_verification_tokens WHERE token_hash = ?")
        ->execute([$tokenHash]);

    return true;
}

// 1. Budi mendaftar; akunnya belum terverifikasi.
$pdo->prepare("INSERT INTO users (email, nama) VALUES (?, ?)")
    ->execute(["[email protected]", "Budi"]);

// 2. Sistem membuat token lalu "mengirim" email berisi tautan.
$token = createVerificationToken($pdo, 1);
echo "Panjang token: " . strlen($token) . " karakter\n";

// 3. Simulasi: Budi mengeklik tautan di emailnya.
var_dump(verifyEmail($pdo, $token));

// 4. Token bersifat sekali pakai; percobaan ulang gagal.
var_dump(verifyEmail($pdo, $token));

echo "email_verified_at: "
    . $pdo->query("SELECT email_verified_at FROM users WHERE id = 1")->fetchColumn()
    . "\n";

Output yang Dihasilkan:

text
Panjang token: 64 karakter
bool(true)
bool(false)
email_verified_at: 2026-01-15 08:05:00

Mari kita telaah bagian-bagian pentingnya:

  1. createVerificationToken() membuat token acak, menyimpan hash SHA-256 dari token tersebut, lalu mengembalikan token mentah ke pemanggil — nilai itulah yang ditempelkan pada tautan email.
  2. verifyEmail() meng-hash token yang datang dari URL, mencari hash tersebut di tabel token. Hash tidak bisa dibalik, jadi pencarian selalu dimulai dari token mentah yang diklik pengguna.
  3. Setelah baris token ditemukan, fungsi mengisi email_verified_at dengan waktu verifikasi, menghapus baris token, dan mengembalikan true.
  4. Percobaan kedua dengan token yang sama mengembalikan false karena baris token sudah terhapus — inilah sifat sekali pakai.
  5. Kolom token_hash menjadi primary key, sehingga database menolak duplikasi dan pencarian berjalan cepat lewat index.

Masa Berlaku Token #

Token tanpa batas waktu menjadi liability: tautan verifikasi yang bocor (misalnya diteruskan ke milis) tetap sah selamanya. Kebiasaan yang baik adalah memberi jendela singkat — 1 sampai 24 jam — lewat kolom expires_at. Mari kita uji pemeriksaannya dengan waktu beku agar hasilnya deterministik:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE verification_tokens (
    token_hash TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL,
    expires_at TEXT NOT NULL
)");

function isTokenMasihSah(PDO $pdo, string $tokenHash, string $now): bool
{
    $stmt = $pdo->prepare(
        "SELECT expires_at FROM verification_tokens WHERE token_hash = ?"
    );
    $stmt->execute([$tokenHash]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    return $row !== false && $row["expires_at"] > $now;
}

// Demo memakai token tetap agar output contoh ini deterministik.
// Produksi: $token = bin2hex(random_bytes(32)); lalu simpan hash-nya.
$tokenHash = hash("sha256", "demo-token-masa-berlaku");
$pdo->prepare("INSERT INTO verification_tokens VALUES (?, ?, ?)")
    ->execute([$tokenHash, 1, "2026-01-15 09:00:00"]);

var_dump(isTokenMasihSah($pdo, $tokenHash, "2026-01-15 08:59:59"));
var_dump(isTokenMasihSah($pdo, $tokenHash, "2026-01-15 09:00:00"));
var_dump(isTokenMasihSah($pdo, $tokenHash, "2026-01-16 08:00:00"));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(false)

Perbandingan $row["expires_at"] > $now membandingkan dua string, bukan dua objek waktu. Perbandingan ini tetap benar karena format YYYY-MM-DD HH:MM:SS terurut secara leksikografis sama dengan urutan kronologisnya. Perhatikan bahwa pembandingnya > (strict), sehingga pada detik yang persis sama dengan expires_at token sudah dianggap kedaluwarsa — token sah sampai sebelum pukul 09:00:00. Di kode produksi dengan zona waktu campuran, gunakan DateTimeImmutable dan bandingkan getTimestamp() agar aman dari jebakan zona waktu.

Jeda Pengiriman Ulang #

Tombol “kirim ulang email verifikasi” adalah fitur yang wajar, tetapi tanpa pengaman fitur tersebut bisa dipakai untuk membanjiri satu alamat email dengan ratusan email — pola yang dikenal sebagai email bombing. Solusinya adalah cooldown: catat waktu pengiriman terakhir, dan tolak pengiriman baru sebelum jeda minimal berlalu. Waktu beku membuat perilaku fungsi ini mudah diuji:

php
<?php
function bolehKirimUlang(?string $terakhirKirim, string $now, int $cooldownDetik = 900): bool
{
    if ($terakhirKirim === null) {
        return true; // email verifikasi belum pernah dikirim
    }

    $waktuKirim = new DateTimeImmutable($terakhirKirim, new DateTimeZone("Asia/Jakarta"));
    $waktuSekarang = new DateTimeImmutable($now, new DateTimeZone("Asia/Jakarta"));

    return ($waktuSekarang->getTimestamp() - $waktuKirim->getTimestamp()) >= $cooldownDetik;
}

// 900 detik = 15 menit masa jeda pengiriman ulang.
var_dump(bolehKirimUlang(null, "2026-01-15 08:00:00"));
var_dump(bolehKirimUlang("2026-01-15 07:55:00", "2026-01-15 08:00:00"));
var_dump(bolehKirimUlang("2026-01-15 07:40:00", "2026-01-15 08:00:00"));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(true)

Nilai null pada parameter pertama berarti email belum pernah dikirim, jadi pengiriman pertama selalu diizinkan. Selisih 5 menit (300 detik) masih di bawah cooldown 900 detik sehingga ditolak, sedangkan selisih 20 menit (1200 detik) sudah melewati batas dan diizinkan. Simpan terakhirKirim di kolom verification_sent_at pada tabel user, dan perbarui nilainya setiap kali email benar-benar terkirim.

Membatasi Fitur hingga Terverifikasi #

Verifikasi tidak harus memblokir seluruh aplikasi. Strategi yang seimbang adalah membiarkan aktivitas berisiko rendah (membaca artikel, mengubah nama tampilan) agar pengalaman pengguna tidak menyiksa, lalu mengunci aksi sensitif — penarikan dana, publikasi konten, perubahan alamat email — hingga status terverifikasi terpasang. Pemeriksaannya sederhana:

php
<?php
function aksiSensitifDiizinkan(array $user): bool
{
    return $user["email_verified_at"] !== null;
}

$budi = ["nama" => "Budi", "email_verified_at" => null];
$siti = ["nama" => "Siti", "email_verified_at" => "2026-01-15 08:05:00"];

foreach ([$budi, $siti] as $user) {
    $status = $user["email_verified_at"] === null ? "belum" : "sudah";
    echo "{$user['nama']} ($status terverifikasi) boleh tarik dana: "
        . (aksiSensitifDiizinkan($user) ? "ya" : "tidak") . "\n";
}

Output yang Dihasilkan:

text
Budi (belum terverifikasi) boleh tarik dana: tidak
Siti (sudah terverifikasi) boleh tarik dana: ya

Fungsi aksiSensitifDiizinkan() memeriksa satu hal saja: apakah email_verified_at sudah terisi. Budi yang belum mengeklik tautan ditolak, sedangkan Siti yang sudah terverifikasi lolos. Di aplikasi nyata, gabungkan pemeriksaan ini ke dalam guard yang sama dengan pemeriksaan login, dan tampilkan pesan yang menjelaskan alasan penolakan beserta tautan kirim ulang — bukan sekadar error 403 yang membingungkan.

Referensi #

Dokumentasi resmi untuk pendalaman lebih lanjut:

Ringkasan #

Verifikasi email menuntaskan klaim kepemilikan alamat dengan token acak sekali pakai yang dikirim lewat tautan, disimpan di database hanya dalam bentuk hash, dan berlaku dalam jendela waktu singkat. Kolom email_verified_at menjadi sumber kebenaran tunggal yang dipakai untuk mengunci aksi sensitif, sementara cooldown mencegah fitur kirim ulang disalahgunakan. Dengan ini, pertanyaan “siapa penggunanya dan apakah kontaknya sah?” sudah terjawab tuntas. Pertanyaan berikutnya adalah “apa yang boleh dilakukan pengguna tersebut?” — kita jawab dengan model peran pada chapter Role-Based Access Control .