{"id":333,"date":"2026-10-02T05:58:00","date_gmt":"2026-10-02T12:58:00","guid":{"rendered":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/02\/phishing-resistant-mfa-totp-push-fido2-passkeys\/"},"modified":"2026-10-20T20:23:53","modified_gmt":"2026-10-21T03:23:53","slug":"phishing-resistant-mfa-totp-push-fido2-passkeys","status":"publish","type":"post","link":"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/02\/phishing-resistant-mfa-totp-push-fido2-passkeys\/","title":{"rendered":"MFA that survives phishing: TOTP, push and FIDO2 passkeys compared"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><em>Part 2 of 4 in the Authentication done right series. Part 1: <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/01\/password-storage-bcrypt-scrypt-argon2id\/\">Password storage in 2026<\/a>.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In the first part of this series I covered how to store passwords so a database leak doesn&#8217;t hand attackers working credentials. This part assumes the password is already gone, because sooner or later it will be. Phished, reused, scraped off an infostealer log. The question is what your second factor does when someone is actively trying to get past it, and the honest answer for most deployed MFA is &#8220;less than you&#8217;d think&#8221;.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Not all second factors are the same control<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1300\" height=\"760\" src=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/phishing-resistant-mfa-totp-push-fido2-passkeys-diagram.png\" alt=\"Two sequence diagrams side by side: a phishing proxy relays a password and TOTP code to the real site and steals the session cookie, while with WebAuthn the origin-bound credential is not offered on the fake domain and the login fails\" class=\"wp-image-332\" srcset=\"https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/phishing-resistant-mfa-totp-push-fido2-passkeys-diagram.png 1300w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/phishing-resistant-mfa-totp-push-fido2-passkeys-diagram-300x175.png 300w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/phishing-resistant-mfa-totp-push-fido2-passkeys-diagram-1024x599.png 1024w, https:\/\/blog.canutethegreat.com\/wp-content\/uploads\/2026\/10\/phishing-resistant-mfa-totp-push-fido2-passkeys-diagram-768x449.png 768w\" sizes=\"auto, (max-width: 1300px) 100vw, 1300px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">People talk about MFA as if it were one thing. In practice the methods fall into two groups. Shared secret and approval based factors (SMS codes, TOTP apps, push notifications) prove the user has a device, but the proof can be relayed by whoever the user is talking to. Public key factors bound to an origin (FIDO2\/WebAuthn security keys and passkeys) produce proof that only works for the site that asked for it. That distinction is what CISA means by phishing resistant MFA in its 2022 guidance, and it&#8217;s what OMB memo M-22-09 required US federal agencies to adopt.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">SMS is the weakest of the first group. SIM swaps and number porting fraud move the phone number to an attacker, and NIST SP 800-63B has long classed SMS and voice as a restricted authenticator. I&#8217;d still take SMS over no second factor, but I wouldn&#8217;t let it protect anything I cared about.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">TOTP and the relay problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">TOTP (RFC 6238, built on HOTP from RFC 4226) derives a six digit code from a shared secret and the current 30 second time step. It&#8217;s offline, cheap and works everywhere. It also has nothing in it that identifies which website the user is typing it into.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s the whole weakness. An adversary-in-the-middle kit like Evilginx sits between the victim and the real login page as a reverse proxy. The victim sees what looks like the real site, types their password and their TOTP code, and the proxy forwards both in real time and keeps the resulting session cookie. The code was valid. It just went to the wrong party.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The 2022 &#8220;0ktapus&#8221; campaign worked this way at scale. SMS messages pointed employees of Twilio and many other companies to lookalike Okta pages that captured credentials and one time codes as they were typed. Cloudflare was targeted by the same campaign, and some of its employees did enter their credentials. The attack still failed there, because Cloudflare required FIDO2 hardware keys and the fake domain couldn&#8217;t get a valid assertion from them. Cloudflare&#8217;s own write-up of that incident is one of the clearest arguments for WebAuthn I know of.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you do run TOTP, at least implement it properly. Accept a small clock skew window and reject codes that have already been used.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from datetime import datetime, timezone\nimport pyotp\n\ndef check_totp(user, code):\n    totp = pyotp.TOTP(user.totp_secret)\n    # accept the current step and one on either side for clock drift\n    if not totp.verify(code, valid_window=1):\n        return False\n    step = totp.timecode(datetime.now(timezone.utc))\n    if user.last_totp_step is not None and step &lt;= user.last_totp_step:\n        return False  # replayed code\n    user.last_totp_step = step\n    user.save()\n    return True<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Rate limit verification attempts too. A six digit code has a million values, which is not a lot if an endpoint lets someone try thousands of codes per account.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Push approval and MFA fatigue<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Push MFA looked like an upgrade because the user doesn&#8217;t type anything. It created a different failure. If an attacker has the password, they can trigger approval prompts over and over until the user taps Approve to make it stop, or until someone calls pretending to be IT and asks them to accept.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s what happened at Uber in September 2022. According to Uber&#8217;s own statement, the attacker had a contractor&#8217;s password, sent repeated push requests, and the contractor eventually accepted one. Cisco&#8217;s breach that year, disclosed in August, combined voice phishing with the same kind of repeated push prompts. In both cases the MFA product behaved exactly as designed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fixes are real but partial. Number matching shows a code on the login screen that the user has to enter in the authenticator app, so a blind tap no longer works; Microsoft made it mandatory for Authenticator push in May 2023. Showing the location and application in the prompt helps a little. Limiting how many prompts can be sent per hour and alerting on bursts helps more. None of it stops a real-time phishing proxy, because the user is looking at a page that displays the number too.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why WebAuthn is different<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With WebAuthn, the browser builds a <code>clientDataJSON<\/code> structure that includes the challenge and the origin the user is actually on, and the authenticator signs it along with the relying party ID hash. Credentials are scoped to the relying party ID, so an authenticator won&#8217;t even offer a credential for <code>example.com<\/code> to a page on <code>examp1e-login.com<\/code>. There&#8217;s no code for the user to copy, so there is nothing to relay.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The server side still has to do its part. Every serious library makes you pass the expected origin and RP ID, and you should never loosen those to make a test environment work.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from webauthn import verify_authentication_response\n\nverification = verify_authentication_response(\n    credential=assertion_from_browser,\n    expected_challenge=session.pop(\"webauthn_challenge\"),\n    expected_rp_id=\"example.com\",\n    expected_origin=\"https:\/\/example.com\",\n    credential_public_key=stored.public_key,\n    credential_current_sign_count=stored.sign_count,\n    require_user_verification=True,\n)\nstored.sign_count = verification.new_sign_count<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The challenge must be random, single use and tied to the session that requested it. I&#8217;ve reviewed implementations that reused a static challenge, which turns a signature scheme back into something replayable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Passkeys, synced and device bound<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Passkeys are WebAuthn credentials, usually discoverable ones, and most are synced through a platform account such as iCloud Keychain or Google Password Manager. That&#8217;s great for adoption, because users who lose a phone don&#8217;t lose their credentials. It also means the security of the passkey now depends on the security of that cloud account and its recovery process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For most consumer apps, synced passkeys are a large improvement over passwords plus TOTP. For administrators and production access I still want device bound credentials on hardware keys, and I want attestation checked so I know what kind of authenticator registered.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Recovery is part of your MFA<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Attackers go where the controls are weakest, and that&#8217;s usually account recovery. A strong factor with a &#8220;lost your key? we&#8217;ll send an SMS&#8221; fallback is an SMS factor with extra steps. Help desk resets are the same problem with a human in the loop. The MGM Resorts intrusion in 2023 was widely reported to have started with a call to the help desk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So register at least two strong authenticators per user, make recovery require something stronger than knowledge of public facts, and log and alert on every factor reset. Treat a new factor enrolled right after a password reset as a high priority event.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The next part moves from authenticating a person to delegating access between systems, which means OAuth 2.0, OpenID Connect and the token mistakes that come with them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Other parts: <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/01\/password-storage-bcrypt-scrypt-argon2id\/\">Part 1: Password storage in 2026<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/05\/oauth2-vs-openid-connect-pkce-implicit-flow\/\">Part 3: OAuth 2.0 vs OpenID Connect<\/a>, <a href=\"https:\/\/blog.canutethegreat.com\/index.php\/2026\/10\/06\/session-management-cookies-samesite-revocation-jwt\/\">Part 4: Session management<\/a>.<\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Part 2 of 4 in the Authentication done right series. Part 1: Password storage in 2026. In the first part of this series I covered how to store passwords so a database leak doesn&#8217;t hand attackers working credentials. This part assumes the password is already gone, because sooner or later it will be. Phished, reused, [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":334,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[21],"tags":[37,58,60,61,59],"class_list":["post-333","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-security","tag-authentication-done-right-series","tag-mfa","tag-passkeys","tag-phishing","tag-webauthn"],"_links":{"self":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/333","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/comments?post=333"}],"version-history":[{"count":1,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/333\/revisions"}],"predecessor-version":[{"id":397,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/posts\/333\/revisions\/397"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media\/334"}],"wp:attachment":[{"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/media?parent=333"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/categories?post=333"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.canutethegreat.com\/index.php\/wp-json\/wp\/v2\/tags?post=333"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}