Multi-Factor Authentication (MFA)#
AuthHero supports TOTP-based MFA (Time-Based One-Time Password) compatible with Google Authenticator, Authy, 1Password, and any other TOTP app. It also generates 8 backup codes for account recovery.
How It Works#
| Component | Technology |
|---|---|
TOTP Generation | otplib v13 (RFC 6238) |
Secret Storage | AES-256-GCM encryption (decryptable for verification) |
Backup Codes | 8 random 8-char hex codes, stored as argon2 hashes |
Temp Token | 5-minute JWT signed with MFA_TEMP_TOKEN_SECRET |
MFA Setup Flow#
Initiate setup — POST /auth/mfa/setup
The authenticated user calls the setup endpoint. The server generates a TOTP secret, encrypts it with AES-256-GCM, stores it in the MFASecret table with verified: false, and returns the raw secret + otpauth URI + 8 backup codes.
{
"data": {
"secret": "JBSWY3DPEHPK3PXP",
"uri": "otpauth://totp/AuthHero:user@email.com?secret=...&issuer=AuthHero",
"backupCodes": ["a1b2c3d4", "e5f6a7b8", ...]
}
}Display QR code and backup codes
The frontend renders the uri as a QR code (using any QR library) and displays the backup codes with a "Save these codes" prompt. The raw secret is shown as a copyable text alternative for manual entry.
Confirm with a TOTP code — POST /auth/mfa/verify
The user enters the 6-digit code from their authenticator app. The server decrypts the stored TOTP secret, verifies the code, and if valid, sets verified: true, enabledAt: now(), and user.mfaEnabled: true.
The MFA secret is stored unverified after Step 1. If the user abandons setup, MFA remains disabled. Only after Step 3 succeeds is MFA actually enabled on the account.
MFA Login Challenge#
After email/password or OAuth login succeeds for a user with MFA enabled:
Server returns tempToken instead of session
The login response has mfaRequired: true and atempToken (JWT, 5-minute lifetime). No session is created yet.
Frontend redirects to MFA page
The tempToken is stored in Zustand. The user is redirected to /mfa where they enter their 6-digit code.
Challenge endpoint — POST /auth/mfa/challenge
The server decodes the tempToken to extract userId, decrypts the TOTP secret, verifies the code, creates a real session with refresh token cookie, and returns the access token.
const handleMFAChallenge = async (code: string) => {
const { data } = await api.post("/auth/mfa/challenge", {
tempToken: mfaTempToken,
code,
});
setAccessToken(data.data.accessToken);
setMfaTempToken(null);
router.push("/");
};Backup Codes#
Each user gets 8 backup codes when setting up MFA. These are hex strings (8 characters each). They serve as a recovery mechanism when the user loses access to their authenticator app.
| Property | Value |
|---|---|
Format | 8-character hexadecimal (4 bytes) |
Count | 8 codes per user |
Storage | Argon2 hashes in the MFASecret.backupCodes array |
Usage | Each code can only be used ONCE, then permanently deleted |
Accepted where | POST /auth/mfa/challenge and POST /auth/mfa/disable |
After MFA setup or backup code regeneration, the plaintext codes are returned exactly once. They are stored as argon2 hashes and cannot be retrieved. If the user loses all backup codes and their authenticator, they will be locked out.
Regenerating Backup Codes#
Users can regenerate all backup codes at POST /auth/mfa/regenerate-backup-codes. This endpoint requires a valid 6-digit TOTP code (not a backup code) as confirmation. All existing backup codes are replaced with 8 new ones.
Disabling MFA#
An authenticated user can disable MFA via POST /auth/mfa/disable by providing a valid TOTP code or backup code. This:
- Deletes the
MFASecretrecord - Sets
user.mfaEnabled = false - Removes all stored backup codes
Security Details#
TOTP Verification#
TOTP codes are verified using otplib v13 with default settings (30-second window, SHA-1, 6 digits). The library accepts the current code and the codes from one window before and after (±30s) to account for clock drift.
Secret Encryption#
The TOTP secret is encrypted at rest using AES-256-GCM:
// Encrypt
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return iv.toString("hex") + ":" + authTag + ":" + encrypted;
// Decrypt — reverse the process
const [ivHex, authTagHex, encryptedHex] = stored.split(":");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
decrypted += decipher.final("utf8");Unlike passwords (which are hashed one-way), TOTP secrets must be decrypted at runtime to generate/verify codes. AES-256-GCM provides confidentiality + integrity (the auth tag detects tampering).
Rate Limiting#
The MFA challenge endpoint is rate-limited to 5 requests per 5 minutes per IP. Since TOTP codes are only 6 digits (1 million possibilities), rate limiting is critical to prevent brute-force attacks.
Backup Code Security#
Backup codes are verified using argon2 (same algorithm as passwords) with timing-safe comparison. Even if an attacker gains database access, they cannot reverse argon2 hashes to obtain the plaintext codes. Each code is consumed after use (the hash is removed from the array).