Mengirim email OTP dan reset password yang andal: idempotency, retry, dan keamanan
Praktik terbaik email OTP, 2FA, dan reset password: masa berlaku, idempotency key, retry yang aman, rate limit, dan contoh kode API.
Daftar isi
Email OTP dan reset password adalah email yang paling sering ditunggu pengguna. Kalau tidak sampai, pengguna tidak bisa masuk. Kalau sampai dua kali dengan kode berbeda, pengguna bingung kode mana yang benar. Panduan ini membahas cara merancang alur pengiriman yang cepat, tidak ganda, dan aman.
Prinsip keamanan isi email
- Masa berlaku singkat. OTP umumnya berlaku beberapa menit. Tautan reset password sebaiknya berlaku singkat dan hanya bisa dipakai sekali.
- Simpan hash, bukan nilai asli. Simpan hash dari kode atau token di database, sama seperti password.
- Batasi percobaan. Batasi jumlah verifikasi gagal per kode dan per akun untuk mencegah tebakan brute force.
- Jangan bocorkan keberadaan akun. Halaman "lupa password" sebaiknya menampilkan pesan yang sama, baik email terdaftar maupun tidak.
- Sebutkan konteks. Tulis untuk apa kode itu, berapa lama berlaku, dan apa yang harus dilakukan jika pengguna tidak memintanya.
Masalah klasik: email terkirim dua kali
Bayangkan server Anda memanggil API email, lalu koneksi terputus sebelum respons diterima. Apakah email sudah masuk antrean? Anda tidak tahu. Kalau Anda mengulang tanpa pengaman, pengguna bisa menerima dua email. Untuk OTP, ini berarti dua kode berbeda di inbox yang sama.
Solusinya adalah idempotency key: pengenal unik per tindakan logis yang Anda kirim bersama permintaan. Jika permintaan dengan kunci dan isi yang sama diulang, API mengembalikan pesan yang sudah ada alih-alih mengirim lagi.
const res = await fetch("https://capimail.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CAPIMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Keamanan Akun <keamanan@contoh.com>",
to: [user.email],
subject: "Kode verifikasi Anda",
text: `Kode Anda: ${code}. Berlaku 10 menit. Abaikan email ini jika Anda tidak memintanya.`,
html: `<p>Kode Anda: <strong>${code}</strong></p><p>Berlaku 10 menit.</p>`,
// Satu kunci per permintaan OTP, BUKAN per percobaan HTTP.
idempotency_key: `otp-${otpRequest.id}`,
}),
});Dengan SDK TypeScript resmi, bentuknya lebih ringkas:
import { CapiMail } from "@capimail/sdk";
const capimail = new CapiMail({ apiKey: process.env.CAPIMAIL_API_KEY! });
await capimail.emails.send({
from: "keamanan@contoh.com",
to: [user.email],
subject: "Atur ulang password Anda",
html: `<a href="${resetUrl}">Atur ulang password</a>`,
idempotencyKey: `reset-${resetToken.id}`,
});Kapan harus retry, kapan tidak
| Respons | Arti | Tindakan |
|---|---|---|
202 | Email masuk antrean dengan aman | Selesai. Simpan message_id. |
| Timeout atau koneksi putus | Status tidak diketahui | Ulangi dengan idempotency key yang sama. |
429 | Melebihi batas kirim | Tunggu sesuai header Retry-After, lalu ulangi dengan kunci yang sama. |
503 | Pengiriman sementara dijeda | Ulangi nanti dengan backoff dan kunci yang sama. |
422 | Validasi gagal atau penerima di-suppress | Jangan ulangi. Perbaiki permintaan atau alamatnya. |
401 / 403 | Kunci, scope, atau domain bermasalah | Jangan ulangi. Periksa konfigurasi. |
Gunakan exponential backoff dengan jitter untuk percobaan ulang, dan batasi jumlah percobaan. Jika pengguna menekan "kirim ulang kode", itu adalah tindakan baru: buat kode baru, batalkan kode lama, dan gunakan idempotency key baru.
Diterima antrean bukan berarti terkirim
Respons 202 dari CapiMail berarti email tersimpan dengan aman di antrean, belum berarti sampai di inbox. Status terkirim yang sebenarnya datang dari event penyedia: delivered, hard_bounced, complained, dan seterusnya. Pantau status ini lewat endpoint GET /api/v1/emails/{message_id} atau webhook status pengiriman.
Uji semua skenario tanpa mengirim email sungguhan
Kunci cm_test_ menjalankan siklus lengkap secara simulasi: message_id nyata dan webhook yang ditandatangani, tanpa mengirim email atau memakai kredit. Hasilnya ditentukan oleh bagian lokal alamat penerima pertama:
bounce+otp1@contoh.com -> hard bounce
complaint+otp1@contoh.com -> complaint
fail+otp1@contoh.com -> gagal
apa-saja@contoh.com -> terkirimDetail lengkap ada di dokumentasi API.
Pertanyaan yang sering diajukan
Berapa lama masa berlaku OTP email yang ideal?
Umumnya beberapa menit. Cukup lama untuk pengguna membuka inbox, tetapi cukup singkat untuk membatasi risiko jika kode bocor. Sebutkan masa berlakunya di dalam email.
Apa itu idempotency key pada API email?
Idempotency key adalah pengenal unik per tindakan yang dikirim bersama permintaan. Jika permintaan yang sama diulang dengan kunci yang sama, API mengembalikan pesan yang sudah ada sehingga email tidak terkirim dua kali.
Apakah respons 202 berarti email sudah sampai?
Tidak. Respons 202 berarti email masuk antrean dengan aman. Status terkirim ditentukan oleh event penyedia, misalnya delivered atau bounced.