This episode explains why a TOTP secret must not be stored in plaintext, how to encrypt it with AES-256-GCM using a key from the MFA_ENCRYPTION_KEY environment variable, plus the practices of one secret per user and logging that never includes a secret or a code.

The TOTP secret is the digital key to a user's account. If it leaks, an attacker can compute codes identical to the user's Google Authenticator — the difference from a leaked password is that the secret can't be changed by the user, and there's even less awareness of how to handle it. Episode 7 covers secure secret storage.
You'll learn to encrypt the Base32 secret with AES-256-GCM, manage the encryption key via the MFA_ENCRYPTION_KEY environment variable, and apply correct storage and logging practices. By the end of the episode, no one can read the secret in the database without the key.
A secret stored raw is just as dangerous as a raw password. If the database leaks — via SQL injection, a lost backup, or unauthorized access — an attacker can immediately provision the account on their own Google Authenticator and log in as the user. Encryption ensures that even leaked data can't be used without the key that only the application holds.
An important note: encryption protects data at rest, not data in use. Once a secret is decrypted in memory for verification, it's vulnerable to process compromise — which is why the secret must also never appear in logs.
Secret encryption is one layer in a defense-in-depth strategy. Above it sit rate limiting (episode 10), HTTPS transport (episode 13), and short sessions (episode 15). Each layer raises the cost of an attack; an encrypted secret ensures that a database leak alone is not enough to get in.
AES-256-GCM is symmetric encryption with a 32-byte key that also provides authentication — corrupted ciphertext is rejected during decryption instead of silently producing damaged data. The GCM mode generates a random initial vector (IV) per encryption that must be stored alongside the ciphertext. Example encrypt and decrypt functions:
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');
}Note that crypto.createCipheriv requires a new IV for every encryption — reusing an IV with the same key is a common and dangerous cryptographic mistake. Store the iv and tag along with the data, because both are needed for decryption.
The 32-byte encryption key is stored as hex in an environment variable:
MFA_ENCRYPTION_KEY=9a3c2f4e1d0b7c6a5f8e9d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9a0b
SESSION_SECRET=umumkanPanjangAcakTidakMudahDitebak
DATABASE_URL=postgresql://user:pass@localhost:5432/devvnullNever put these values in source control. Use a secret manager or KMS in production (episode 20), and keep a key backup in a separate location — losing MFA_ENCRYPTION_KEY means all 2FA secrets can no longer be decrypted.
A simple rule: files ending in .env* go into .gitignore, and MFA_ENCRYPTION_KEY must never appear in logs, committed documentation, or the README. Rotate the key only with a strategy that supports two keys simultaneously, so users can be migrated one by one without a mass lockout.
Info
Encryption key rotation requires two active keys: the new key for new data and the old key to decrypt data that hasn't been migrated yet. The migration runs gradually, account by account, so no user gets locked out because their data is encrypted with a key that's no longer recognized.
The encrypted secret is stored as a single string combining the IV, the authentication tag, and the ciphertext. An example totp_secret_encrypted column value looks like three hex segments separated by colons — iv, tag, and data. All three values must be stored intact because decryption needs all of them at once.
Use a TEXT or VARCHAR column type that's long enough. Avoid storing the secret in a readable form while debugging the database, and make sure database backups are encrypted like the rest of the data.
Each user holds exactly one active secret. Regenerating a secret — for example when the user suspects their device was lost — must replace the old secret and revoke all previous authenticator sessions. This reset and rotation flow is covered in full in episode 11.
Always require strong proof of identity for a 2FA reset, such as re-entering the password or using a still-valid recovery code.
Application logs are often the most underestimated leak source. The rule to remember: never log a Base32 secret, a TOTP code, or a full provisioning URI. Only log events like mfa_enabled, mfa_disabled, and mfa_failed without sensitive data. Episode 20 will design safe observability for this.
An example of a safe log line: mfa_enabled user=uuid — enough for an audit without touching secret data. The complete observability flow is covered in episode 20.
Episode 7 explained why a TOTP secret must be encrypted, how AES-256-GCM encryption works with a key from MFA_ENCRYPTION_KEY, and the practices of one secret per user with secret-free logs.
The key takeaways:
In the next episode, episode 8, we will cover the login flow with a 2FA challenge — separating the /login and /login/mfa endpoints, marking the pendingMfa status, and making sure protected routes can't be bypassed before the 2FA challenge completes.