Two-factor authentication in 2026 is not one thing — it's a family of methods with different threat models, different UX costs, and wildly different recovery stories. SMS used to be the default, NIST restricted it, and SIM swap attacks are up 400% since 2020. TOTP (Google Authenticator, 1Password, Authy) remains the workhorse. WebAuthn and its consumer-facing cousin, passkeys, are now the default on every major consumer product — HubSpot saw login success rates improve 25% and login times drop to a quarter of passwords-plus-2FA after launching passkeys. Here's how to choose between them, how to ship each one correctly, and — most importantly — how to handle account recovery without creating a worse problem than you started with.
The 2026 menu of 2FA methods
Five methods cover almost every SaaS 2FA decision. Each has a distinct threat model and a distinct user experience. Pick by asking what you're defending against, not by picking what's easiest to ship.
| Method | Phishing resistant | Device loss recovery | Friction | Recommended in 2026 |
|---|---|---|---|---|
| SMS OTP | No | Carrier recovery | Low | No — use only as a fallback, if at all |
| Email OTP | No (if email is the first factor) | Email recovery | Low | Fine for low-risk sign-in, not a real second factor |
| TOTP (authenticator app) | No (can be phished) | Recovery codes | Medium | Yes — baseline for every account |
| Push notification (Okta Verify, Duo) | Partially (fatigue attacks) | App recovery | Low | Good for enterprise, number-matching required |
| WebAuthn / passkeys | Yes | Cross-device sync or second passkey | Low (after setup) | Yes — default for new accounts in 2026 |
SMS OTP is now a restricted authenticator under NIST SP 800-63B Rev 4. CISA's Mobile Communications Best Practice Guidance is blunter: do not use SMS as a second factor. SIM swapping, SS7 interception, and number porting attacks make SMS the weakest viable option in the list, and in some jurisdictions it's a compliance problem.
TOTP — the baseline every product should ship
TOTP (RFC 6238) is the six-digit code an authenticator app generates every thirty seconds. It requires no carrier, works offline, and protects against password theft — though it's still phishable. In Node, the ecosystem has moved away from the unmaintained Speakeasy package toward `otpauth`, which has better TypeScript support and active maintenance. The enrollment flow is: generate a random 160-bit secret, store it encrypted alongside the user, render a QR code that encodes it, and ask the user to enter a current code to confirm enrollment.
import * as OTPAuth from "otpauth";
import QRCode from "qrcode";
// 1. Generate secret during enrollment
const secret = new OTPAuth.Secret({ size: 20 });
const totp = new OTPAuth.TOTP({
issuer: "Acme",
label: user.email,
algorithm: "SHA1",
digits: 6,
period: 30,
secret,
});
// 2. Render QR code for the authenticator app
const otpauthUrl = totp.toString();
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
// 3. Store secret ENCRYPTED; require code to confirm enrollment
await db.user.update({
where: { id: user.id },
data: { totpSecret: encrypt(secret.base32), totpEnabled: false },
});
// 4. On verify-enrollment submit, check the code and flip totpEnabled
const valid = totp.validate({ token: submittedCode, window: 1 }) !== null;Two details matter. First, the secret must be encrypted at rest — a database leak with plaintext TOTP secrets is as bad as a password leak. Use a KMS-backed envelope key, not a hard-coded secret in the codebase. Second, enrollment must require the user to submit a current code before 2FA is considered enabled. Without this, a typo in the QR scanner silently bricks the account on the next login.
Recovery codes — the part everyone gets wrong
Recovery codes are single-use, human-readable strings the user saves somewhere offline. They're the safety net when a phone is lost. Generate eight to ten codes at enrollment, hash them before storing (like passwords), and mark each as consumed on use. Make it clear to the user that recovery codes are equivalent to a password — whoever has them can bypass 2FA. Display them once, let the user copy or download them, and require confirmation before closing the screen.
Never log recovery codes, never email them, and never leave them in plaintext in the database. A support engineer reading support tickets should not be able to see a user's recovery codes. If you can't retrieve them in support, you're doing it right.
WebAuthn and passkeys — the 2026 default
WebAuthn is the W3C standard for public-key authentication in the browser. Passkeys are WebAuthn credentials that sync across a user's devices via their platform (iCloud Keychain, Google Password Manager, Windows Hello). To the user: scan a face, tap a key, log in. To the server: a signature verified against a public key you stored at registration. Unlike TOTP, passkeys are phishing-resistant — the credential is scoped to the origin it was registered on, so a phishing site cannot use a passkey registered for your real domain.
In Node, the `@simplewebauthn/server` and `@simplewebauthn/browser` packages handle the wire format, leaving you to manage storage and business logic. Registration is a two-step handshake: the server generates a challenge, the browser creates a credential and returns it, the server verifies and stores the credential's public key.
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
// 1. Server: generate registration options
const options = await generateRegistrationOptions({
rpName: "Acme",
rpID: "acme.com",
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: "none",
authenticatorSelection: {
residentKey: "required", // discoverable credential = passkey
userVerification: "preferred",
authenticatorAttachment: "platform", // or undefined to allow USB keys too
},
excludeCredentials: user.passkeys.map((p) => ({ id: p.credentialId })),
});
await redis.set(`webauthn:challenge:${user.id}`, options.challenge, "EX", 300);
return options;
// 2. Browser: pass options to startRegistration from @simplewebauthn/browser,
// get back a RegistrationResponseJSON, POST it to the server.
// 3. Server: verify the response
const expectedChallenge = await redis.get(`webauthn:challenge:${user.id}`);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: expectedChallenge!,
expectedOrigin: "https://acme.com",
expectedRPID: "acme.com",
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
await db.passkey.create({
data: {
userId: user.id,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
}The authentication flow mirrors registration: generate a challenge, let the browser sign it with the stored credential, verify the signature against the public key on file, and increment the credential counter (to detect cloned authenticators). With `residentKey: 'required'` you're creating a discoverable credential — the user doesn't even need to type a username; the browser offers a list of available passkeys and the server identifies them from the credential ID.
Enrollment flows that don't annoy users
The best time to enroll a second factor is after the user has committed to the product but before they have high-value data in it. For free-tier SaaS, that's usually mid-onboarding — after the first 'aha' moment but before they invite teammates or paste an API key. For paid SaaS, enroll on first login after upgrade. Two enrollment patterns work in 2026:
- Passkey-first: offer passkey enrollment as the primary option, with TOTP available behind a 'more options' link. HubSpot, KAYAK, and Dashlane all use this and report dramatic conversion improvements over SMS-first flows.
- Progressive enforcement: default to passkey on new accounts, require any 2FA for existing accounts on privilege-sensitive operations (API key creation, billing changes, member invites), then require it always after a migration window.
Migration from SMS-only: give users a 60-day window during which SMS remains valid. On each login, promote passkey or TOTP with a 'remind me later' option that caps at three dismissals. After the window, SMS is disabled and users are offered a passkey enrollment or a recovery flow. Announce the timeline in-product, by email, and on your changelog — give enterprise admins a clear date.
Account recovery — the actual hard problem
Every authentication method is one lost phone away from a recovery problem. The 2026 failure mode: a user with a passkey synced to iCloud, then switches to Android. Or enables TOTP on a phone that's now at the bottom of a lake. Recovery is where account takeovers actually happen, because the recovery flow is often weaker than the authentication flow it backs up.
Three anti-patterns to avoid. First, support-engineer-initiated resets with no audit trail — if any member of the support team can disable 2FA on request, social engineering is a viable attack. Second, recovery via email OTP when email was the first factor — you've just removed a factor. Third, recovery flows that don't lock the account during the grace period — attackers initiate recovery, receive the reset token, and lock the legitimate user out.
What works: a tiered recovery ladder. First tier, recovery codes — user self-service, one click. Second tier, a registered backup passkey on a second device. Third tier, a 48-hour cool-off reset that emails the registered address, freezes the account during the window, and notifies every registered session. Fourth tier, manual support recovery with identity verification (ID document, billing match) and a mandatory second approver. The higher the tier, the longer it takes — which is a feature, not a bug.
Session management after 2FA
A user who passed 2FA on Monday should probably not be prompted again on Tuesday — but they should be re-prompted before changing their password, rotating an API key, or disabling 2FA. Step-up authentication is the pattern: the session carries a timestamp of its last strong authentication, and sensitive operations require that timestamp to be within the last ten minutes. If it's not, prompt the user to re-authenticate before proceeding.
Key takeaways
- Passkeys are the 2026 default — phishing resistant, low friction, and proven to improve conversion on real products.
- TOTP is the universal fallback. Ship it alongside passkeys; deprecate SMS.
- Recovery codes are as sensitive as passwords. Hash them, display once, never log them.
- The recovery flow is usually weaker than the auth flow. Tier it, and make higher tiers slower on purpose.
- Use step-up authentication for sensitive operations — a fresh 2FA prompt before API key rotation or 2FA disablement.
- Plan an SMS sunset. Offer a 60-day window, promote the upgrade, and set a hard deadline.