Specific MFA implementations: how they work under the hood

The technical mechanics of the most used MFA implementations, TOTP, SMS codes, push, FIDO2 and WebAuthn, passkeys, and magic links, with their flows, specific weaknesses, and fallback patterns. Plus CAPTCHA, and why it isn't authentication but is a useful complement.

The phone in the taxi

Sara López leaves her phone in a taxi on a Thursday night. It held her passkeys, her authenticator app with a dozen TOTP codes, and the SIM her bank sends codes to. On Friday morning she calls the help desk from her laptop and asks the question that every MFA design eventually has to answer: “How do I get back into my accounts?” The answer depends on decisions engineers made months earlier, often without thinking about this moment. If the way back in is weaker than the way in, the attacker who found the phone, or anyone who can impersonate Sara on the phone with the help desk, will take that path.


In the previous article we went through the five categories of factors and mentioned several implementations in passing. Now we open the hood. Every implementation has its own mechanics: a protocol, a flow, specific weaknesses, design decisions that end up in real code. If you’re going to build authentication into an app or audit someone else’s, this is the level of detail you need. Expect RFC references, formulas, pseudocode, and sequence diagrams at protocol level. By the end you should be able to explain how a TOTP code is calculated, sketch the WebAuthn registration and authentication ceremonies, say when SMS codes are acceptable and when they aren’t, and design sensible fallbacks for someone like Sara.


This is a map, not an implementation manual. If you’re about to build authentication for production, also read the RFCs cited along the way and the OWASP Authentication Cheat Sheet.


TOTP and HOTP: the code that changes every 30 seconds

One-time password algorithms have been the workhorse of enterprise MFA since the mid-2000s. They’re simple, open, and work offline.


HOTP, the predecessor

HOTP (HMAC-based One-Time Password, RFC 4226) generates codes from a shared seed and a counter that moves forward each time a code is generated:

HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))
 
where:
  K = secret key (shared seed)
  C = 8-byte counter (increments with each code)

Server and client keep the same counter. When the user presents a code, the server computes it with its own counter, and if they match, it accepts the code and advances the counter. The trouble is that if the counters fall out of step, for instance because the user generated a code and never used it, everything breaks. That’s why almost nobody uses plain HOTP any more.


TOTP, the current standard

TOTP (Time-based One-Time Password, RFC 6238) replaces the counter with time sliced into 30-second windows:

T = floor((current_unix_time - T0) / X)
 
TOTP(K) = HOTP(K, T)
 
where:
  T0 = typically 0 (Unix epoch)
  X  = typically 30 seconds

Server and client share a UTC clock and the same seed, so every 30 seconds they produce the same code. When the user types it, the server computes its own and compares.


sequenceDiagram
  accTitle: TOTP enrollment ceremony
  accDescr: The user activates TOTP; the server generates a 160-bit secret and returns it as an otpauth QR code; the authenticator app scans and stores the secret locally; the user enters a test code and the server verifies it before marking TOTP enrolled.
  actor U as User
  participant App as Auth app
  participant Srv as Server
  U->>Srv: Activates TOTP on the account
  Srv->>Srv: Generates secret K (160 bits)
  Srv->>U: Returns QR with otpauth://totp/...?secret=K
  U->>App: Scans QR (Google Auth, Authy)
  App->>App: Stores K locally
  U->>Srv: Enters test code
  Srv->>Srv: Verifies the code matches
  Srv->>U: TOTP enrolled
Enrollment ties a shared secret to the app through a QR code. After one test code, both sides generate the same 30-second codes.

The otpauth:// URI follows the format Google Authenticator defined, which almost every authenticator app accepts.


Clocks drift, and if the user’s is 15 seconds behind, their code fails on the server. The usual fix is a tolerance window, typically one window either side, which gives about 90 effective seconds:

def validate_totp(K, code_received, current_time):
    for offset in [-1, 0, 1]:
        T = (current_time / 30) + offset
        expected = HOTP(K, T)
        if expected == code_received:
            return True
    return False

Too much tolerance weakens security; too little hurts usability.


TOTP’s weaknesses are well known. Real-time phishing defeats it: a fake site asks for the code, and the attacker replays it on the real site within seconds. If the authenticator app is compromised, for example by malware on the phone, the attacker can extract the seed and generate codes indefinitely, which is why seeds shouldn’t be easy to export. And cloud backups of authenticator apps move the risk to that cloud account: if it falls, every service’s codes fall with it. For Sara, though, a backup is exactly what would make Friday easier, which shows the tension recovery always involves.


SMS codes: convenient, indefensible for anything valuable

The SMS flow is the simplest here. You sign in with a username and password, the server generates a six-digit code and passes it to an SMS gateway, the gateway delivers it to your registered number, and you type it back into the app, which checks that it matches and hasn’t expired. A typical lifetime is five to ten minutes.


Its weaknesses are serious. In a SIM swap, an attacker uses social engineering, bribery, or forged documents to get the carrier to move your number to their SIM, which takes minutes; the Twitter CEO’s account was taken over this way in 2019. The SS7 protocol that carriers use to route messages has known flaws that allow interception, demonstrated in the wild. Real-time phishing works just as it does against TOTP. Carrier insiders can read messages. And when a number is released and reassigned, the new owner can receive messages meant for the old one. In Sara’s case, whoever found her phone in the taxi now receives her bank’s codes, at least until she has the SIM blocked.


NIST SP 800-63B discourages SMS codes for AAL2, but they still have a place. Some MFA beats none, so an SMS code is better than a password alone. It reaches users without modern smartphones. And it can serve as a secondary fallback channel. What it shouldn’t be is the main factor for anything involving money, sensitive personal data, or administrator access. Several countries’ banking rules already forbid SMS as the only factor for high-value operations.


Email codes: only as safe as the inbox

Email codes work like SMS codes, through email instead. Their security reduces entirely to the security of the email account. If that account is protected by a password alone, “MFA with an email code” is a single factor in disguise: an attacker with access to the mailbox starts the sign-in, receives the code, and finishes. And if the email is also the “forgot password” channel, the mailbox alone is enough to take over every service registered with it. Email codes are reasonable for low-value apps where TOTP’s friction isn’t justified, as a fallback when the TOTP device is lost, with extra checks, and for confirming an address at sign-up, not for recurring sign-in.


Push notifications

sequenceDiagram
  accTitle: Push-notification authentication flow
  accDescr: After the user signs in with email and password, the IdP asks a push service to notify the registered mobile app; the user approves with one tap; the mobile app confirms with a device signature; the IdP returns a token and the session starts.
  actor U as User
  participant App as Web app
  participant IdP as IdP
  participant PushSvc as Push service
  participant Phone as Mobile app
  U->>App: Sign in with email + password
  App->>IdP: Authenticates first factor
  IdP->>PushSvc: Requests push to registered device
  PushSvc->>Phone: Notification: "Is it you?"
  U->>Phone: Approves (one tap)
  Phone->>IdP: Confirms approval with device signature
  IdP->>App: Token with claims
  App->>U: Session started
One tap to approve, the same convenience that MFA bombing abuses.

The experience is excellent: one tap and you’re in. It also introduced a new risk, MFA fatigue or push bombing. An attacker who already has the password sends notification after notification until the victim, tired, distracted, or half asleep, approves one. That’s how a Lapsus$ attacker got into Uber in 2022.


Modern platforms answer with several measures. Number matching replaces the simple approve button with a number, say 47, that the user has to enter or pick in the app where they’re signing in, which rules out approving by reflex. Showing location and context in the notification (“Sign-in from Tokyo. Is it you?”) lets a user in Madrid spot the mismatch and reject it. Rate limits lock the account and raise an alert if, say, more than three notifications fire in five minutes. And requiring Face ID or a fingerprint inside the authenticator app means a bare tap isn’t enough. Microsoft Authenticator, Duo, and Okta Verify have shipped number matching by default since 2023.


FIDO2 and WebAuthn: the phishing-resistant standard

FIDO2 is the pair of standards, WebAuthn and CTAP, that replaces shared secrets with asymmetric cryptography anchored to the user’s device. It’s the strongest consumer authentication available today. What sets it apart from everything above is that the site’s domain is part of what gets signed. The key doesn’t sign “I’m Sara”; it signs “I’m Sara at bank.com.” If you’re lured to banc.com, one letter off, the signature names the fake domain and the real server rejects it.


WebAuthn has two ceremonies: registration, the first time, and authentication, every sign-in after that.


sequenceDiagram
  accTitle: WebAuthn registration (attestation) ceremony
  accDescr: The server sends a challenge and options; the browser calls navigator.credentials.create; the authenticator asks for a gesture, generates a key pair and stores the private key locally, then returns the public key with a signed attestation; the browser forwards the public key and the server stores it tied to the user.
  participant U as User
  participant Browser as Browser
  participant Auth as Authenticator (YubiKey, TPM, Secure Enclave)
  participant Srv as Server
  Srv->>Browser: Challenge + options
  Browser->>Auth: navigator.credentials.create()
  Auth->>U: Requests gesture (tap, biometric)
  Auth->>Auth: Generates key pair
  Auth->>Auth: Stores private key locally
  Auth->>Browser: Public key + signed attestation
  Browser->>Srv: Sends public key
  Srv->>Srv: Stores public key tied to user
Registration: the private key is generated on the authenticator and never leaves it. The server only ever sees the public key.

The server keeps the public key; the private key never leaves the authenticator. On the next sign-in, the authentication ceremony runs.


sequenceDiagram
  accTitle: WebAuthn authentication (assertion) ceremony
  accDescr: The browser requests a challenge; the server returns a random challenge and the origin; the browser calls navigator.credentials.get including the origin; the authenticator asks for a gesture and signs the challenge plus origin with the private key; the browser sends the signature and the server verifies it with the stored public key before starting the session.
  participant U as User
  participant Browser as Browser
  participant Auth as Authenticator
  participant Srv as Server
  U->>Browser: Starts sign-in
  Browser->>Srv: Requests challenge
  Srv->>Browser: Random challenge + origin
  Browser->>Auth: navigator.credentials.get(), includes origin
  Auth->>U: Requests gesture
  Auth->>Auth: Signs challenge + origin with private key
  Auth->>Browser: Signature
  Browser->>Srv: Sends signature
  Srv->>Srv: Verifies signature with public key
  Srv->>Browser: Session started
Authentication: the signature covers the challenge and the origin, so a look-alike domain can't reuse it.

The step that matters is the signature over the challenge and the origin. If the fake site’s origin is banc.com, the browser passes that to the authenticator and the resulting signature contains banc.com. The real server checks the signature with its stored public key, sees that the signed origin isn’t its own domain, and rejects it. So even if the user is fooled into approving on a fake site, the attacker can’t use that signature on the real one. No one-time code or push notification can say that.


Authenticators come in two kinds. Roaming authenticators are separate devices that connect over USB, NFC, or Bluetooth, such as a YubiKey or Google Titan. Platform authenticators are built into the device: Touch ID, Face ID, Windows Hello, Android biometrics.


Passkeys: WebAuthn synced across devices

Passkeys are the consumer-friendly evolution of WebAuthn, and they fix two problems with the classic version: losing the device meant losing the credential, and every device needed its own registration, which made changing phones painful.


Passkeys are discoverable credentials. The authenticator stores both the private key and a user identifier, so at sign-in the user doesn’t even type an email; the browser simply offers “sign in with Sara López’s passkey.” And the private key is synced through iCloud Keychain, Google Password Manager, or Windows Hello across the same person’s devices, so a passkey created on an iPhone works on the Mac and the iPad without enrolling each one.


flowchart LR
  accTitle: Passkeys synced across a user's devices
  accDescr: A passkey created on an iPhone syncs through an end-to-end encrypted iCloud Keychain to the same user's Mac and iPad, and can fall back to a Windows device via a cross-device QR code.
  iPhone[iPhone<br/>original passkey] --> iCloud[(iCloud Keychain<br/>E2E encrypted)]
  iCloud --> Mac[Mac<br/>synced passkey]
  iCloud --> iPad[iPad<br/>synced passkey]
  iCloud -.fallback.-> Windows[Windows with QR<br/>cross-device]
One passkey on every device, synced end to end through the platform's keychain, with a cross-device QR code as the way out.

This is what saves Sara’s Friday. Her passkeys weren’t only on the phone in the taxi; they were also on her laptop, synced through her cloud account. If she had lost every device, recovering the cloud account would bring the passkeys back, and on a borrowed device she could scan a QR code with another of her devices to use a passkey temporarily.


Passkeys have limits too. Moving between ecosystems is awkward, since passkeys in iCloud don’t export easily to Google. Not every app supports them yet, though that’s changing quickly. And some regulated B2B settings require FIDO2 with a hardware authenticator rather than a synced one, because a compromised cloud account would expose every synced passkey. For consumers, passkeys are the best option in 2026. For administrators and critical enterprise accounts, consider a separate hardware key whose private key never leaves the physical chip.


A magic link is a one-time URL sent to the user’s email that completes the sign-in when clicked. Slack, Notion, Medium, and many modern products use them.


sequenceDiagram
  accTitle: Magic-link login flow
  accDescr: The user enters their email; the server generates a random token tied to the email with a TTL and emails a link containing it; the user clicks the link, which navigates to an auth endpoint; the server validates the token, marks it used so it can't be replayed, and starts the session.
  actor U as User
  participant App as Web app
  participant Srv as Server
  participant Mail as Email
  U->>App: Enters email
  App->>Srv: Requests magic link
  Srv->>Srv: Generates random token
  Srv->>Srv: Ties it to the email + TTL
  Srv->>Mail: Sends link with token
  U->>Mail: Clicks the link
  Mail->>App: Navigates to /auth?token=...
  App->>Srv: Validates token
  Srv->>Srv: Marks token as used (not reusable)
  Srv->>App: Session started
A single-use, short-lived token makes a round trip through the inbox, so the security comes down to the email account's.

A few patterns keep magic links safe. The token is single use, so once consumed it never works again. Its lifetime is short, typically 10 to 15 minutes. It’s long, ideally 256 bits, random, and unpredictable. And optionally, if the link is opened from a different country or device than the one that asked for it, the app asks for extra confirmation. The weaknesses are the same as for email codes: whoever controls the mailbox controls the account, a careless auto-forwarding rule can send the link to another mailbox, and a phishing email can imitate the real one with the attacker’s link (sender verification such as BIMI helps here). A common pattern in modern apps is a magic link as the first factor plus push or TOTP as the second, combining the simplicity of the link with an independent factor.


CAPTCHA: anti-bot, not authentication

CAPTCHA isn’t an authentication factor. It doesn’t prove you’re you; it suggests you’re human, or at least behaving like one. People confuse the two because CAPTCHAs appear on sign-in forms next to the password. What a CAPTCHA does is make automated abuse more expensive: credential stuffing, where an attacker tries millions of leaked credentials; bots registering ten thousand fake accounts in an hour; and brute force when rate limiting falls short.


There are several kinds today. reCAPTCHA v2 is the “I’m not a robot” checkbox, which analyzes mouse movement and behavior. reCAPTCHA v3 is invisible and returns a score between 0.0 and 1.0 without user interaction. hCaptcha is an alternative that pays sites for the training data it collects. Cloudflare Turnstile is a privacy-focused anti-bot check without Google’s tracking. And proof-of-work CAPTCHAs, such as Anubis, make the browser solve a cryptographic puzzle. Google’s reCAPTCHA gathers a lot of behavioral data, which bothers many operators, and Turnstile and hCaptcha position themselves as the privacy-friendly options.


Never rely on a CAPTCHA as your only defense against brute force. The main defense remains rate limiting, progressive lockout, and monitoring for patterns, and a CAPTCHA is never an authentication factor.


Fallback and recovery

However good the main authentication is, people lose devices, forget passwords, and change numbers, as Sara did. You need recovery plans, and they’re the weakest point of many systems, because any path that lets the user back in is also a path an attacker can try.


Recovery patternStrengthsWeaknesses and threats
”Forgot password” by emailUniversal, easyOnly as secure as the email
Backup codes (ten single-use codes)Independent of any deviceUsers rarely store them well
Manual identity proofing (photo + document)RobustExpensive, slow, exposes personal data to the human checker
Trusted device listGood experienceFails if every device is lost
Recovery contact (another person)Works well in family plansNew, needs setting up in advance

The principle behind all of them is simple: the recovery path must not be weaker than the main authentication. If you sign in with a passkey and a biometric but recover with an SMS code, the effective security of the whole system is that of SMS, because an attacker will always take the weakest door. If Sara’s help desk would reset her MFA after a phone call where she gave her date of birth and employee number, everything else about her setup would be decoration.


How to choose: three scenarios

The question that comes up most in practice is “which factor should I implement for my case?” There’s no single answer; it depends on risk, audience, and the team’s maturity. Three typical cases illustrate the reasoning.


A consumer mobile banking app in Central America serves customers of all ages, many with mid-range phones, under local regulation that requires real MFA and with an expectation of a smooth experience. A good design uses a passkey as the main factor where the device supports it, plus TOTP and on-device biometrics, with SMS codes as a fallback only for customers on older phones, and step-up with a transaction PIN for operations over $200. Passkeys remove phishing, the dominant attack in banking; TOTP covers the cases where passkeys aren’t possible yet; and SMS appears only when there’s no alternative.


An internal HR app for 200 employees, maintained by a small team on a small budget, should rely on single sign-on with the corporate identity provider, such as Microsoft Entra ID or Google Workspace, which already enforces MFA. The app shouldn’t implement its own MFA at all. Reusing the company’s existing investment is the efficient choice, and building custom MFA into every internal app duplicates work and multiplies the attack surface.


An administration panel for critical operations, say Kubernetes cluster administration for eight platform engineers with production access, is the case where one compromised credential is a catastrophe. Here a FIDO2 hardware key per engineer is mandatory, and destructive actions such as deleting a cluster or changing IAM policies require step-up plus approval from a second engineer. This is where the cost and friction of a hardware key pay off, and synced passkeys aren’t enough, because you want the private key never to leave a physical chip.


A pattern shows up across projects: teams tend to over-engineer for end users, adding too much friction, and under-engineer for administrators, assuming they know what they’re doing. It should be the other way round. End users tolerate less friction, and administrators need more controls. When you design, treat the two audiences separately.


Which factor for which job

FactorPhishing-resistantVulnerable toGood main factor for
PasswordNoPhishing, leaks, reuseNothing in 2026
TOTPNoReal-time phishingInternal apps where passkeys don’t apply
SMS codeNoSIM swap, SS7Fallback only
Email codeNoCompromised mailboxFallback only
Push notificationPartlyMFA bombing (without number matching)Workforce, with number matching
FIDO2 hardware keyYesPhysical loss, costAdministrators, critical accounts
PasskeyYesCompromised cloud accountConsumers in general
Magic linkNoCompromised mailboxSimple apps, plus a second factor
CAPTCHANot a factorAdvanced botsComplementary anti-bot measure

Recap

MFA implementations aren’t interchangeable; each solves a particular problem and has its own threat model. TOTP and HOTP are simple and offline but fall to real-time phishing. SMS codes are convenient but exposed to SIM swaps and SS7, so not for anything valuable. Email codes are only as safe as the mailbox and work as a bounded fallback. Push is pleasant to use but needs number matching to resist MFA bombing. FIDO2 and WebAuthn are the phishing-resistant standard for critical accounts, and passkeys are their consumer-friendly form, ideal for modern customer identity. Magic links are simple and depend on email. CAPTCHAs fight bots and aren’t authentication. And above all of them sits one rule: the recovery path must never be weaker than the main authentication.



Three questions to test yourself

  1. Design the recovery flow for Sara, who lost the iPhone that held her passkeys and TOTP codes. Which steps would you include, which checks would you make, and why each one?
  2. Your CTO says, “Let’s roll out passkeys and get rid of passwords in a week.” What three warnings would you give about the transition and compatibility?
  3. An app lets users sign in with a magic link and no second factor for every action. Design three step-ups you’d recommend adding and the kinds of operation each would cover.

Frequently asked questions

How is a TOTP code calculated?

TOTP applies HOTP to a time counter: T = floor((current_unix_time - T0) / X), where T0 is usually 0 and X is a 30-second window, and then TOTP(K) = HOTP(K, T) = Truncate(HMAC-SHA-1(K, T)). Server and client share the secret K and a UTC clock, so both compute the same six-digit code every 30 seconds. It's defined in RFC 6238.

Is SMS OTP safe enough for MFA?

Only as a last resort. SMS codes are vulnerable to SIM swapping, SS7 interception, real-time phishing, and recycled numbers, and NIST SP 800-63B has discouraged them for AAL2 since 2017. They're still better than a password alone, so they're acceptable as a secondary fallback or for users without smartphones, but never as the main factor for money, sensitive data, or administrator access.

What makes FIDO2/WebAuthn phishing-resistant?

The site's origin, its domain, is included in what gets signed. The authenticator signs 'I am this user at bank.com,' not just 'I am this user,' so a look-alike domain such as banc.com produces a signature the real server rejects. Even if a user is tricked into approving on a fake site, the attacker can't replay that signature against the real one, which no one-time code or push notification achieves.

What is the difference between a passkey and a FIDO2 hardware key?

Both use WebAuthn's asymmetric cryptography, but a passkey is a discoverable credential synced across a person's devices through iCloud Keychain, Google Password Manager, or Windows Hello, while a FIDO2 hardware key such as a YubiKey or Titan keeps the private key on a physical chip that never syncs. Passkeys suit consumers best; hardware keys suit administrators and regulated accounts, where a compromised cloud account is the bigger worry.

Is CAPTCHA an authentication factor?

No. CAPTCHA shows that you're human, not that you're a specific person, so it's anti-bot tooling, not authentication. It complements sign-in by making credential stuffing and scraping more expensive, but it must never replace a factor or be your only defense against brute force; rate limiting and progressive lockout remain the main controls.