Episode ini menjelaskan mengapa secret TOTP tidak boleh disimpan plaintext, cara mengenkripsinya dengan AES-256-GCM memakai kunci dari environment MFA_ENCRYPTION_KEY, serta praktik satu secret per user dan logging yang tidak pernah menyertakan secret atau kode.

Secret TOTP adalah kunci digital akun pengguna. Jika bocor, penyerang bisa menghitung kode yang sama persis dengan Google Authenticator pengguna — perbedaan dengan bocornya password adalah secret tidak bisa diganti pengguna, dan bahkan lebih jarang kesadaran pengguna untuk menanganinya. Episode 7 membahas penyimpanan secret yang aman.
Kalian akan belajar mengenkripsi secret Base32 dengan AES-256-GCM, mengelola kunci enkripsi lewat variabel environment MFA_ENCRYPTION_KEY, dan menerapkan praktik penyimpanan serta logging yang benar. Di akhir episode, secret di database tidak lagi dapat dibaca siapa pun tanpa kunci.
Secret yang disimpan mentah sama bahayanya dengan password mentah. Jika database bocor — lewat SQL injection, backup yang hilang, atau akses tidak sah — penyerang langsung bisa memprovisioning akun di Google Authenticator miliknya dan login sebagai pengguna. Enkripsi memastikan bahwa bahkan data yang bocor tidak bisa dipakai tanpa kunci yang hanya dimiliki aplikasi.
Catatan penting: enkripsi melindungi saat data berhenti, bukan saat data sedang digunakan. Sekali secret didekripsi di memori untuk verifikasi, secret itu rentan terhadap kompromi proses — itulah mengapa secret juga tidak boleh muncul di log.
Enkripsi secret adalah satu lapis dalam pertahanan berlapis. Di atasnya masih ada rate limiting (episode 10), transport HTTPS (episode 13), dan sesi yang pendek (episode 15). Setiap lapis menaikkan biaya serangan; secret terenkripsi memastikan kebocoran database saja tidak cukup untuk masuk.
AES-256-GCM adalah enkripsi symmetric dengan kunci 32 byte yang juga menyediakan autentikasi — ciphertext yang dirusak akan ditolak saat dekripsi, bukan menghasilkan data rusak yang tidak disadari. Mode GCM menghasilkan initial vector (IV) acak per enkripsi yang harus disimpan bersama ciphertext. Contoh fungsi enkripsi dan dekripsi:
const crypto = require('crypto');
const ALGO = 'aes-256-gcm';
const key = Buffer.from(process.env.MFA_ENCRYPTION_KEY, 'hex');
function encryptSecret(secret) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const enc = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv: iv.toString('hex'), tag: tag.toString('hex'), data: enc.toString('hex') };
}
function decryptSecret(stored) {
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(stored.iv, 'hex'));
decipher.setAuthTag(Buffer.from(stored.tag, 'hex'));
const dec = Buffer.concat([decipher.update(Buffer.from(stored.data, 'hex')), decipher.final()]);
return dec.toString('utf8');
}Perhatikan bahwa crypto.createCipheriv memerlukan IV baru setiap enkripsi — memakai ulang IV dengan kunci yang sama adalah kesalahan kriptografi yang umum dan berbahaya. Simpan iv dan tag bersama data, karena keduanya diperlukan saat dekripsi.
Kunci enkripsi 32 byte disimpan sebagai hex di variabel environment:
MFA_ENCRYPTION_KEY=9a3c2f4e1d0b7c6a5f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9a0b
SESSION_SECRET=umumkanPanjangAcakTidakMudahDitebak
DATABASE_URL=postgresql://user:pass@localhost:5432/devvnullJangan pernah menaruh nilai ini di source control. Gunakan secret manager atau KMS di produksi (episode 20), dan simpan cadangan kunci di lokasi terpisah — kehilangan MFA_ENCRYPTION_KEY berarti semua secret 2FA tidak bisa didekripsi lagi.
Aturan sederhana: file berakhiran .env* masuk ke .gitignore, dan MFA_ENCRYPTION_KEY tidak boleh muncul di log, dokumentasi yang ter-commit, atau README. Putar kunci hanya dengan strategi yang mendukung dua kunci secara bersamaan, agar pengguna bisa dimigrasi satu per satu tanpa lockout massal.
Info
Rotasi kunci enkripsi memerlukan dua kunci aktif: kunci baru untuk data baru dan kunci lama untuk mendekripsi data yang belum dimigrasi. Proses migrasi berjalan bertahap, akun demi akun, sehingga tidak ada pengguna yang terkunci karena datanya dienkripsi dengan kunci yang sudah tidak dikenali.
Secret terenkripsi disimpan sebagai satu string yang menggabungkan IV, tag autentikasi, dan ciphertext. Contoh nilai kolom totp_secret_encrypted berbentuk tiga segmen hex yang dipisahkan titik dua — iv, tag, dan data. Ketiga nilai ini wajib disimpan utuh karena dekripsi membutuhkan semuanya sekaligus.
Gunakan tipe kolom TEXT atau VARCHAR yang cukup panjang. Hindari menyimpan secret dalam bentuk yang bisa dibaca saat proses debugging database, dan pastikan backup database ikut terenkripsi seperti data lainnya.
Setiap user memegang tepat satu secret aktif. Regenerasi secret — misalnya saat pengguna curiga perangkatnya hilang — harus mengganti secret lama dan membatalkan seluruh session autentikator sebelumnya. Alur reset dan rotasi ini dibahas penuh di episode 11.
Pastikan operasi reset 2FA selalu menuntut bukti identitas kuat, misalnya ulang password atau recovery code yang masih valid.
Log aplikasi sering menjadi sumber kebocoran yang paling diremehkan. Aturan yang wajib diingat: jangan pernah mencatat secret Base32, kode TOTP, atau URI provisioning lengkap ke log. Catat hanya peristiwa seperti mfa_enabled, mfa_disabled, dan mfa_failed tanpa data sensitif. Episode 20 akan merancang observability yang aman untuk ini.
Contoh baris log yang aman: mfa_enabled user=uuid — cukup untuk audit tanpa menyentuh data rahasia. Alur observability lengkapnya akan dibahas di episode 20.
Episode 7 menjelaskan mengapa secret TOTP harus terenkripsi, cara kerja enkripsi AES-256-GCM dengan kunci dari MFA_ENCRYPTION_KEY, dan praktik penyimpanan satu secret per user dengan log yang bebas secret.
Inti yang harus dibawa pulang:
Di episode 8 selanjutnya kita akan membahas login flow dengan 2FA challenge — memisahkan endpoint /login dan /login/mfa, menandai status pendingMfa, dan memastikan protected route tidak bisa dilewati sebelum tantangan 2FA selesai.