← Back to all posts

Alur Reset Password yang Aman: Kirim Link Reset via Email dengan MailAnvil + Express.js

2026-10-12 · MailAnvil Team

Setiap aplikasi dengan login wajib punya alur reset password — dan ini sering diremehkan. Padahal, email reset password adalah email paling sensitif di seluruh aplikasi: kalau nyangkut di spam, user terkunci; kalau link-nya bocor, akun orang lain bisa diambil alih. Bagi fintech kecil seperti e-wallet di Indonesia, ini bukan cuma soal UX — ini soal kepercayaan.

Tutorial ini membangun alur lengkap: user lupa password → menerima link via email → reset password baru. Stack: Express.js + MailAnvil email API.

Kenapa alur ini sering gagal

Tiga kegagalan paling umum yang saya lihat di project-pinjaman developer Indonesia:

  1. Link expired tapi tidak ada pesan yang jelas — user klik link tua, dapat error kosong, hapus aplikasi.
  2. Enumerasi email — endpoint lupa-password menjawab beda untuk "email terdaftar" vs "email tidak ada". Penyerang pakai ini untuk mengintai akun mana yang ada.
  3. Token tidak sekali pakai — link reset yang sudah dipakai masih bisa dipakai lagi. Fatal untuk akun dengan saldo, seperti e-wallet atau platform investasi retail.

Mari kita perbaiki ketiganya.

1. Generate token yang aman

Jangan pakai UUID biasa atau timestamp. Pakai token kriptografis acak:

import crypto from 'node:crypto';

function buatTokenReset() {
  const token = crypto.randomBytes(32).toString('hex'); // 256-bit
  const hash = crypto.createHash('sha256').update(token).digest('hex');
  const expiry = Date.now() + 60 * 60 * 1000; // 1 jam
  return { token, hash, expiry };
}

Simpan hash-nya saja di database, bukan token mentah. Ini pola yang sama dengan penyimpanan API key — kalau database bocor, token tidak bisa dipakai ulang.

await db.execute(
  `INSERT INTO password_resets (email, token_hash, expires_at, used, created_at)
   VALUES (?, ?, ?, 0, datetime('now'))`,
  [email, hash, new Date(expiry).toISOString()]
);

2. Kirim email reset via MailAnvil

const res = await fetch('https://api.mailanvil.com/v1/send', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.MAILANVIL_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `reset-${hash}`, // klik ganda "kirim ulang" tidak dobel kirim
  },
  body: JSON.stringify({
    from: '[email protected]',
    to: [email],
    subject: 'Reset password akun kamu',
    html: `
      <p>Ada permintaan reset password untuk akun kamu.</p>
      <p><a href="https://app.domainkamu.com/reset?token=${token}"
            style="background:#ff801f;color:#000;padding:12px 24px;border-radius:9999px;">
        Reset password</a></p>
      <p style="color:#666;font-size:12px;">Link berlaku 1 jam.
         Kalau kamu tidak meminta ini, abaikan email ini — password kamu tidak berubah.</p>
    `,
  }),
});

Tiga detail penting:

3. Anti-enumerasi di endpoint lupa-password

Responnya harus selalu sama, terlepas dari email ada atau tidak:

app.post('/api/forgot-password', async (req, res) => {
  const { email } = req.body;
  const user = await cariUserByEmail(email);

  if (user) {
    const { token, hash, expiry } = buatTokenReset();
    await simpanToken(user.email, hash, expiry);
    await kirimEmailReset(email, token);
  }
  // email tidak terdaftar → tidak kirim apa-apa, tapi respon sama:
  res.json({ message: 'Kalau email kamu terdaftar, link reset sudah dikirim.' });
});

User yang sah tidak kehilangan apa-apa; penyerang tidak mendapat informasi.

4. Verifikasi + sekali pakai

app.post('/api/reset-password', async (req, res) => {
  const { token, passwordBaru } = req.body;
  const hash = crypto.createHash('sha256').update(token).digest('hex');

  const row = await db.query(
    `SELECT email, expires_at, used FROM password_resets
     WHERE token_hash = ? AND used = 0 AND expires_at > datetime('now')`,
    [hash]
  );

  if (!row || !row.length) {
    return res.status(400).json({
      error: { code: 'invalid_token', message: 'Link sudah kedaluwarsa. Minta link baru.' }
    });
  }

  await updatePassword(row[0].email, await hashPassword(passwordBaru));
  await db.execute(`UPDATE password_resets SET used = 1 WHERE token_hash = ?`, [hash]);
  // bonus: invalidate semua sesi aktif akun ini
  res.json({ message: 'Password berhasil direset. Silakan login.' });
});

Pesan error harus jelas dan menawarkan jalan keluar: "Minta link baru", bukan error kosong.

5. Kegunaan nyata di bisnis Indonesia

Dari sisi biaya: email reset tipis, HTML-nya kecil. Di MailAnvil, 3.000 email pertama gratis, dan paket Pro Rp249rb/bulan untuk 50.000 email — cukup untuk aplikasi dengan puluhan ribu user aktif, dibayar QRIS atau GoPay tanpa kartu kredit.

Checklist sebelum production

Coba sendiri: daftar gratis di mailanvil.com, verifikasi domain, dan alur reset password kamu siap menerima email pertama dalam 5 menit.