How to Configure TOTP Two-Factor Authentication with Encrypted Secrets in Instatic
Instatic implements TOTP 2FA by generating a Base-32 secret, encrypting it with a 256-bit AES-GCM master key, and storing only the ciphertext, IV, and fingerprint, ensuring secrets remain encrypted at rest while allowing cryptographic verification during login.
This guide walks through the complete TOTP two-factor authentication implementation in the CoreBunch/Instatic repository. The system uses a three-layer security architecture that keeps secrets encrypted in the database while still enabling standard time-based code verification compatible with authenticator apps like Google Authenticator or Authy.
Understanding the Three-Layer Security Architecture
The Instatic codebase separates TOTP concerns into distinct layers to isolate cryptographic operations from business logic.
Secret Generation and Provisioning
TOTP enrollment begins in server/auth/mfa.ts, where the generateTotpSecret() function creates a 20-byte random buffer and encodes it to Base-32. The totpProvisioningUri() helper constructs a standard otpauth:// URL suitable for QR-code generation. During this phase, the raw secret is transmitted only to the client for immediate display and is never written to the database in plaintext.
AES-GCM Encryption at Rest
When the user confirms their setup code, the system calls encryptTotpSecret() from server/auth/totpSecrets.ts. This function loads the master key via loadMasterKey() and encrypts the secret using AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) via the encryptSecret utility in server/secrets/encryption.ts. The resulting ciphertext, initialization vector (IV), and a SHA-256 key fingerprint are stored in three distinct columns.
Master Key Management
The encryption key itself is managed by server/secrets/masterKey.ts. In production, you must provide a 256-bit key via the INSTATIC_SECRET_KEY environment variable (base64-encoded). Development environments auto-generate a key to .tmp/secret.key. The system computes a fingerprint from the first 16 hexadecimal characters of the key's SHA-256 hash to detect key rotation events.
Step-by-Step TOTP Enrollment
Implementing TOTP requires coordinating between the client UI and the server-side encryption layer.
Starting the Enrollment Process
Initiate enrollment by POSTing to /admin/api/cms/me/mfa/totp/start. The handler in server/handlers/cms/me.ts returns both the raw secret and the provisioning URI:
import { startCurrentUserTotpSetup } from './cmsAuth';
// Initiate TOTP setup
const { secret, otpauthUrl } = await startCurrentUserTotpSetup();
// Display QR code to user
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(otpauthUrl)}`;
The endpoint generates the secret using generateTotpSecret() but does not persist it. The client displays the QR code so the user can scan it into their authenticator application.
Encrypting and Persisting the Secret
After the user enters the first 6-digit code from their authenticator app, confirm the setup by POSTing to /admin/api/cms/me/mfa/totp/enable with the secret and code:
import { enableCurrentUserTotp } from './cmsAuth';
const userCode = '123456'; // Code from authenticator app
const { user, recoveryCodes } = await enableCurrentUserTotp({
secret,
code: userCode
});
console.log('MFA enabled for', user.email);
console.log('Recovery codes:', recoveryCodes);
On the server, encryptTotpSecret() encrypts the secret and stores three values defined in the database migrations (migrations-pg.ts and migrations-sqlite.ts):
mfa_totp_secret_ciphertext: The AES-GCM encrypted secretmfa_totp_secret_iv: The nonce used for encryptionmfa_totp_secret_key_fingerprint: The master key fingerprint for rotation detection
Handling Master Key Rotation
If you rotate the INSTATIC_SECRET_KEY, existing encrypted secrets will fail decryption because the fingerprint check in decryptTotpSecret() detects the mismatch. The system throws a TotpSecretError with status 409, forcing affected users to re-enroll their MFA devices with the new key.
Verifying TOTP Codes During Login
During authentication, the login handler in server/handlers/cms/auth.ts checks if the user has enabled MFA. If so, it calls verifyUserTotpCode(), which retrieves the encrypted columns from server/repositories/users.ts and executes verifyEncryptedTotpCode():
import { loginCms, verifyCmsMfa } from './cmsAuth';
async function authenticate(email: string, password: string) {
const { mfaRequired } = await loginCms({ email, password });
if (mfaRequired) {
const code = prompt('Enter your MFA code:');
await verifyCmsMfa({ code });
}
}
The verification path decrypts the secret using the current master key, then validates the TOTP code against the current time window using the same algorithm used during generation. If decryption fails due to a key mismatch, the server returns a TotpSecretError (status 500), requiring the administrator to reset the user's MFA.
Administrative Secret Recovery
For maintenance scripts or debugging, you can manually reconstruct and verify encrypted secrets using the internal API:
import { encryptedTotpSecretFromParts, verifyEncryptedTotpCode } from '../server/auth/totpSecrets';
import { getUserById } from '../server/repositories/users';
async function verifyUserCode(userId: string, code: string) {
const row = await getUserById(userId);
const encrypted = encryptedTotpSecretFromParts(
row.mfa_totp_secret_ciphertext,
row.mfa_totp_secret_iv,
row.mfa_totp_secret_key_fingerprint,
);
const valid = await verifyEncryptedTotpCode(encrypted, code);
return valid;
}
Database Schema Requirements
The TOTP implementation requires three columns on the users table, added via non-destructive migrations:
PostgreSQL (server/db/migrations-pg.ts):
// Lines 91-93
mfa_totp_secret_ciphertext: string | null
mfa_totp_secret_iv: string | null
mfa_totp_secret_key_fingerprint: string | null
SQLite (server/db/migrations-sqlite.ts):
// Lines 87-89
mfa_totp_secret_ciphertext: string | null
mfa_totp_secret_iv: string | null
mfa_totp_secret_key_fingerprint: string | null
To disable TOTP for a user, send a DELETE request to /admin/api/cms/me/mfa/totp, which nullifies these columns.
Summary
- Enrollment flow: Generate secrets with
totpProvisioningUri(), encrypt withencryptTotpSecret(), and store ciphertext with IV and fingerprint. - Master key source: Production requires
INSTATIC_SECRET_KEYenvironment variable; development auto-generates to.tmp/secret.key. - Security model: Secrets are encrypted using AES-GCM with a server-wide master key; only ciphertext touches the database.
- Rotation safety: The fingerprint column prevents silent decryption failures when keys rotate, forcing explicit re-enrollment.
- Verification: Login uses
verifyEncryptedTotpCode()to decrypt on-the-fly and validate against the current time window.
Frequently Asked Questions
What happens if I rotate the INSTATIC_SECRET_KEY?
Existing TOTP secrets will become undecryptable. When a user attempts to log in or verify a code, the system compares the stored key fingerprint against the current master key's fingerprint. If they mismatch, the server returns a TotpSecretError (HTTP 409 or 500), and the user must re-enroll their authenticator app with a new secret generated under the current key.
Can I decrypt TOTP secrets for backup purposes?
According to the Instatic source code, you can use encryptedTotpSecretFromParts() combined with verifyEncryptedTotpCode() from server/auth/totpSecrets.ts to validate codes without exposing the plaintext secret. However, the architecture intentionally discourages exporting raw secrets by requiring the master key for every verification.
Why does the system store three separate columns instead of one encrypted blob?
Separating the ciphertext, IV (initialization vector), and key fingerprint allows the system to detect key rotation events without attempting full decryption. The fingerprint column stores the first 16 hex characters of the master key's SHA-256 hash, enabling early-fail detection in decryptTotpSecret() and clear error messaging for administrators.
Is the TOTP secret ever stored in plaintext?
No. The raw Base-32 secret exists only in memory during the initial enrollment window and is transmitted to the client for QR-code display. Once the user confirms their first code, encryptTotpSecret() immediately encrypts the secret with AES-GCM before any database write occurs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →