Episode ini membahas recovery codes sebagai jalan keluar saat authenticator hilang: membuat 10 kode single-use, menampilkannya sekali, menyimpannya sebagai hash seperti password, menandai kode terpakai, dan mengganti seluruh batch saat regenerasi.

Sebuah fakta yang sering dilupakan perancang fitur: perangkat pengguna bisa hilang, rusak, atau terhapus aplikasinya. Tanpa jalan keluar, pengguna terkunci di luar akunnya sendiri — dan tiket support mulai membanjir. Episode 9 membahas recovery codes, kunci darurat yang membuka pintu saat authenticator tidak tersedia.
Recovery codes harus diperlakukan serumius password: dibuat acak, ditampilkan sekali, disimpan sebagai hash, dan hanya valid satu kali. Kalian akan membangun seluruh siklus hidupnya — pembuatan, tampilan, verifikasi, sampai regenerasi — sehingga fitur ini tidak menjadi celah keamanan baru.
Praktik standar adalah membuat 10 kode single-use dengan panjang 8-10 karakter. Karena kode dipakai satu kali, batch 10 cukup untuk sekitar 10 kali pemulihan sebelum regenerasi. Buat dengan kriptografi acak, bukan Math.random:
node -e "
const crypto = require('crypto');
for (let i = 0; i < 10; i++) {
console.log(crypto.randomBytes(6).toString('hex').toUpperCase());
}
"Setiap baris output adalah satu kode 12 karakter hex. Panjang minimal 8 karakter memberi ruang pencarian yang cukup besar, sementara format tanpa karakter ambigu membuat kode mudah diketik saat darurat.
Kode hanya boleh ditampilkan sekali, pada layar yang meminta pengguna menyalin dan menyimpannya sebelum lanjut. Simpan kode sebagai string yang bisa disalin, dan beri peringatan tegas bahwa layar ini tidak akan muncul lagi. Setelah layar ditutup, server tidak lagi menyimpan plaintext kode — hanya hash-nya.
Sama seperti password, recovery code tidak boleh disimpan plaintext. Hash dengan bcrypt atau argon2 sebelum masuk database. Ini memastikan bahwa jika database bocor, kode tidak bisa langsung dipakai:
const { recoveryCodes } = require('../db');
async function storeRecoveryCodes(userId, codes) {
for (const code of codes) {
const codeHash = await bcrypt.hash(code, 10);
await recoveryCodes.create({ data: { userId, codeHash } });
}
}recoveryCodes.create menyimpan satu baris per kode dengan kolom codeHash dan status belum terpakai. Hashing per kode memperlambat brute-force offline jika tabel bocor.
Struktur tabel memakai satu baris per kode, bukan satu string panjang, agar penandaan terpakai dan penolakan kode yang sudah dipakai mudah dilakukan. Episode 12 akan membahas skema lengkapnya.
Saat pengguna memilih opsi "gunakan recovery code", server mencari kode yang cocok di antara kode milik pengguna yang belum terpakai:
const matches = await recoveryCodes.findMany({ where: { userId } });
for (const row of matches) {
if (await bcrypt.compare(code, row.codeHash) && !row.usedAt) {
await recoveryCodes.update({
where: { id: row.id },
data: { usedAt: new Date() }
});
return res.json({ ok: true });
}
}
res.status(400).json({ error: 'Kode tidak valid' });Dua syarat wajib pada baris yang cocok: hash cocok dan usedAt masih kosong. Setelah kode dipakai, usedAt diisi sehingga kode yang sama ditolak pada percobaan berikutnya — ini sifat single-use yang harus dijaga ketat.
Penting untuk menandai bahwa pengguna masuk lewat recovery code. Banyak sistem menampilkan peringatan: perangkat authenticator tidak terkonfirmasi, dan disarankan mengaktifkan ulang 2FA atau regenerasi kode. Pengguna yang masuk tanpa perangkatnya harus diarahkan untuk mengamankan akun lagi secepat mungkin.
Ketika pengguna meminta kode baru, seluruh batch lama harus di-invalidate. Jika tidak, kode lama yang mungkin sudah tercuri tetap berlaku:
await recoveryCodes.deleteMany({ where: { userId } });
const freshCodes = generateCodes(10);
await storeRecoveryCodes(userId, freshCodes);recoveryCodes.deleteMany menghapus semua kode lama sebelum batch baru disimpan. Operasi ini sebaiknya dibungkus transaksi, dan hanya boleh dijalankan setelah pengguna membuktikan identitasnya — misalnya lewat password dan kode TOTP saat ini.
Saat menampilkan kode, beri saran penyimpanan yang konkret: password manager, aplikasi catatan terenkripsi, atau kertas yang disimpan di tempat aman. Jangan pernah menawarkan penyimpanan otomatis di browser sebagai satu-satunya pilihan, dan peringatkan pengguna untuk tidak menyimpan kode di screenshot yang ikut ter-backup ke cloud publik.
Episode 9 membangun recovery codes dari nol: 10 kode single-use yang dibuat acak, ditampilkan sekali, disimpan sebagai hash, diverifikasi sekali pakai, dan bisa diganti seluruh batch dengan sekali klik.
Inti yang harus dibawa pulang:
Di episode 10 selanjutnya kita akan membahas rate limiting dan replay protection — membatasi percobaan kode dengan express-rate-limit, dan melacak time-step terakhir agar kode yang sama tidak bisa dipakai ulang dalam jendela 30 detik.