MFA Implementation Guide: From SMS to Hardware Keys
Not all MFA is equal. This guide walks through the security tradeoffs of SMS, TOTP, push notifications, and FIDO2 hardware keys, with implementation examples for each.
Multi-factor authentication (MFA) is the single most impactful security control you can implement for user accounts. Microsoft's research found that MFA blocks over 99.9% of automated account compromise attacks. But not all MFA is created equal — the method you choose has a significant impact on both security and usability.
This guide walks through the full spectrum of MFA methods, their vulnerabilities, and how to implement each one correctly.
Why MFA Matters
Password-only authentication fails in predictable ways:
- Credential stuffing: Attackers use databases of leaked username/password pairs from previous breaches.
- Phishing: Users are tricked into entering credentials on fake login pages.
- Password reuse: The same password used on a breached site unlocks accounts elsewhere.
- Brute force: Weak passwords are guessed online or cracked offline.
MFA addresses all of these by requiring something the user has (a device, a hardware key) in addition to something they know (a password).
SMS MFA: Convenient but Weak
SMS-based MFA sends a one-time code to the user's phone number. It's the most widely deployed form of MFA, but it has well-documented weaknesses.
SIM Swapping
An attacker calls a mobile carrier, impersonates the victim using social engineering or by providing information obtained from data brokers, and convinces the carrier to transfer the victim's phone number to an attacker-controlled SIM card. All SMS messages — including MFA codes — now go to the attacker.
High-profile SIM swap attacks have targeted cryptocurrency holders, journalists, and even Twitter's CEO. The carrier's security is only as strong as their social engineering defenses, which are beyond your control.
SS7 Vulnerabilities
The Signaling System 7 (SS7) protocol, which underpins global telephone networks, has known vulnerabilities that allow sophisticated attackers (typically nation-state actors or well-funded criminal groups) to intercept SMS messages in transit.
When SMS MFA Is Acceptable
Despite its weaknesses, SMS MFA is substantially better than no MFA. For consumer applications where TOTP or passkeys aren't feasible for all users, SMS MFA is an acceptable baseline. The threat model matters: SMS adequately defends against credential stuffing and automated attacks. It fails against targeted attacks by motivated adversaries.
Do not use SMS MFA for:
- Admin accounts or privileged access
- Financial applications
- Applications storing sensitive regulated data
- Any account that's a high-value target
TOTP: The Practical Standard
Time-based One-Time Passwords (TOTP, RFC 6238) generate a 6-digit code that changes every 30 seconds. Users store a shared secret in an authenticator app (Google Authenticator, Authy, 1Password, etc.) which generates codes offline.
How TOTP Works
The server and client both know the shared secret and the current time. Both compute HOTP(secret, floor(currentTime / 30)) and produce the same 6-digit code. The server accepts codes from the current time window and typically ±1 window to account for clock skew.
Implementing TOTP
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import crypto from 'crypto';
// Step 1: Generate and store a secret for the user
async function setupTOTP(userId: string): Promise<{
secret: string;
qrCodeDataUrl: string;
backupCodes: string[];
}> {
const secret = authenticator.generateSecret(20); // 20 bytes = 32 base32 chars
// Create the otpauth URI for QR code
const user = await db.users.findOne({ userId });
const otpauthUri = authenticator.keyuri(
user.email,
'YourAppName',
secret
);
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUri);
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(5).toString('hex').match(/.{1,5}/g)!.join('-')
);
const hashedBackupCodes = backupCodes.map(code =>
crypto.createHash('sha256').update(code).digest('hex')
);
// Store secret and backup codes — NOT yet enabled (user must verify first)
await db.users.updateOne(
{ userId },
{
$set: {
totpSecret: secret, // Encrypt this at rest
totpBackupCodes: hashedBackupCodes,
totpEnabled: false, // Enabled after first successful verification
}
}
);
return { secret, qrCodeDataUrl, backupCodes };
}
// Step 2: Verify and enable TOTP
async function verifyAndEnableTOTP(userId: string, token: string): Promise<boolean> {
const user = await db.users.findOne({ userId });
if (!user.totpSecret) {
throw new Error('TOTP setup not initiated');
}
const isValid = authenticator.check(token, user.totpSecret);
if (isValid) {
await db.users.updateOne(
{ userId },
{ $set: { totpEnabled: true } }
);
return true;
}
return false;
}
// Step 3: Verify on login
async function verifyTOTPLogin(userId: string, token: string): Promise<boolean> {
const user = await db.users.findOne({ userId });
if (!user.totpEnabled) return false;
// Check TOTP token
if (authenticator.check(token, user.totpSecret)) {
return true;
}
// Check backup codes
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const backupCodeIndex = user.totpBackupCodes.indexOf(tokenHash);
if (backupCodeIndex !== -1) {
// Remove used backup code
const updatedCodes = [...user.totpBackupCodes];
updatedCodes.splice(backupCodeIndex, 1);
await db.users.updateOne(
{ userId },
{ $set: { totpBackupCodes: updatedCodes } }
);
return true;
}
return false;
}
TOTP Phishing Resistance
TOTP is significantly stronger than SMS but is not phishing-resistant. A real-time phishing proxy can capture TOTP codes as users enter them and relay them to the real site before the 30-second window expires. This is the technique used in several high-profile 2FA bypass attacks, including the 2022 Uber breach.
For high-security applications, move to FIDO2.
MFA Fatigue Attacks
MFA fatigue (also called push spam or push bombing) targets apps that use push notification MFA (like Duo or Microsoft Authenticator). The attacker:
- Obtains valid credentials.
- Repeatedly triggers login attempts, sending push notifications to the victim's phone.
- The victim, confused or annoyed, eventually approves one of the notifications.
This technique was used in the 2022 Cisco breach. Mitigations:
- Use number matching: display a code on the login screen and require the user to enter it in the push notification app.
- Add geographic context to push notifications: "Login attempt from Bucharest, Romania."
- Limit the number of push notifications per login session.
- Implement a "this isn't me" button that triggers a security alert and account lockdown.
FIDO2 / WebAuthn: Phishing-Resistant MFA
FIDO2 and WebAuthn represent the current state of the art in MFA security. They use public-key cryptography and are bound to the specific origin (domain) of the website. This means they're completely immune to phishing — a credential registered for bank.com cannot be used on a phishing site at bank-secure.com.
How WebAuthn Works
- Registration: The authenticator (hardware key, platform authenticator like Face ID / Touch ID) generates a key pair. The public key is stored on the server. The private key never leaves the device.
- Authentication: The server sends a challenge. The authenticator signs it with the private key, binding the signature to the origin (
example.com). The server verifies the signature with the stored public key.
If a phishing site captures the authentication attempt, the signature is bound to the phishing domain and is rejected by the real server.
import { generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
const RP_ID = 'example.com';
const RP_NAME = 'Example App';
const ORIGIN = 'https://example.com';
// Registration: step 1 - generate options
async function beginRegistration(userId: string) {
const user = await db.users.findOne({ userId });
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: userId,
userName: user.email,
attestationType: 'none',
authenticatorSelection: {
userVerification: 'preferred',
residentKey: 'preferred',
},
});
// Store challenge for verification
await db.users.updateOne(
{ userId },
{ $set: { webauthnChallenge: options.challenge } }
);
return options;
}
// Registration: step 2 - verify and store credential
async function completeRegistration(userId: string, response: RegistrationResponseJSON) {
const user = await db.users.findOne({ userId });
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: user.webauthnChallenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (verification.verified && verification.registrationInfo) {
await db.webauthnCredentials.create({
userId,
credentialID: verification.registrationInfo.credentialID,
credentialPublicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
createdAt: new Date(),
});
}
return verification.verified;
}
Hardware Security Keys
Hardware security keys (YubiKey, Google Titan Key) are the gold standard for high-value accounts. They implement FIDO2, require physical presence (button press), and are immune to remote attacks.
For security-critical roles:
- Require hardware keys for all admin accounts.
- Allow platform authenticators (Face ID, Windows Hello) as an alternative for convenience.
- Consider requiring attestation to ensure the authenticator is a real hardware device.
Recovery Codes
Every MFA implementation needs a recovery path for when users lose their second factor. Recovery codes are single-use codes that bypass MFA.
// Generate recovery codes
function generateRecoveryCodes(count = 10): string[] {
return Array.from({ length: count }, () => {
// Format: XXXXX-XXXXX for readability
const bytes = crypto.randomBytes(5);
const hex = bytes.toString('hex');
return `${hex.substring(0, 5)}-${hex.substring(5)}`;
});
}
// Present once on screen, store hashed
async function saveRecoveryCodes(userId: string, codes: string[]) {
const hashedCodes = codes.map(code =>
crypto.createHash('sha256').update(code.replace('-', '')).digest('hex')
);
await db.users.updateOne(
{ userId },
{ $set: { recoveryCodes: hashedCodes } }
);
}
Key recovery code practices:
- Show only once — don't allow re-display.
- Store hashed, not plaintext.
- Make them single-use.
- Notify the user (email/SMS) when a recovery code is used.
- Prompt users to generate new codes after using one.
Enforcing MFA for Admin Accounts
Admin and privileged accounts should have MFA enforced, not just offered:
// Middleware to require MFA for admin operations
function requireMFA(req, res, next) {
const user = req.user;
// Check if user has completed MFA for this session
if (!req.session.mfaVerified) {
return res.status(403).json({
error: 'MFA verification required',
mfaRequired: true,
});
}
// For admin operations, require MFA within the last 15 minutes
const mfaAge = Date.now() - req.session.mfaVerifiedAt;
if (user.role === 'admin' && mfaAge > 15 * 60 * 1000) {
return res.status(403).json({
error: 'Please re-verify your identity for this sensitive operation',
stepUpRequired: true,
});
}
next();
}
MFA Method Comparison
| Method | Phishing Resistant | SIM Swap Resistant | Offline | Usability |
|---|---|---|---|---|
| SMS | No | No | No | High |
| TOTP | No | Yes | Yes | Medium |
| Push notification | No | Yes | No | High |
| FIDO2 (platform) | Yes | Yes | Yes | High |
| FIDO2 (hardware) | Yes | Yes | Yes | Medium |
Choose your MFA method based on the sensitivity of the accounts being protected. For consumer apps, TOTP with SMS fallback is a reasonable baseline. For employee and admin accounts, mandate FIDO2.