SevyDevy

Guide · Backend

Passkeys vs TOTP Explained: Phishing Resistance, Attestation and Recovery

19 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Passkeys vs TOTP, in one line: a TOTP code, the six-digit code from an authenticator app, works on any site you type it into. A passkey only works on the site it was made for.
  • TOTP turns a shared secret, a value both your app and the site keep, plus the clock into a code. A fake login page can forward that code to the real site within seconds.
  • A passkey is a key pair: your device keeps a private key that signs, and the site keeps a public key that only checks. The page's address goes into the data your device signs.
  • The October 2026 "passkeys are a trap" debate is about attestation, a signed note naming the model of device that made a passkey. Scott Helme's reply says synced passkeys send no attestation.
  • Beginners can stop after the section on synced and device-bound passkeys. Engineers then get tested TOTP window maths, a runnable origin check, the BE and BS backup flags that say if a passkey can sync and is synced, and rollout steps.

What goes wrong when you type a TOTP code into a fake login page?

The fake page passes your code to the real site straight away, and the attacker is signed in as you. A six-digit code proves you know a secret, but not which website asked for it. That gap is the heart of passkeys vs TOTP.

Picture a common setup. A payroll app in Pune serves about 1,200 small businesses, and customers sign in with a password plus a TOTP code. The setup is made up, but the attack is real.

One Friday, a customer's accountant gets an SMS saying the salary file failed. The link opens payroll-verify.example, a lookalike of payroll.example, and the accountant types a password and code there. Two minutes later (made up), someone signs in and changes the bank account for a Rs 6,40,000 (6.4 lakh, or 640,000 rupees) salary run.

The bank holds the transfer, so no money is lost. The team then asks the question this guide answers: should the app add passkeys, or stay with TOTP?

The question got louder in October 2026. On 10 October 2026, the Substack newsletter Non-Functional, under the byline The Hack, published a post whose title begins "Passkeys are a trap". It recommends TOTP codes and hardware security keys instead. On 14 October 2026, security researcher Scott Helme replied on his blog with "Passkeys are not a trap".

Which words do you need before comparing passkeys and TOTP?

Six terms carry the rest of this guide. Each gets a plain meaning here, and later sections use the same words.

  • Relying party: the site you sign in to, here the payroll app at payroll.example.
  • Authenticator: the thing that holds your login secret, such as a phone, a laptop's fingerprint sensor or a USB security key.
  • Shared secret: a value both sides know. TOTP uses one, so the payroll app stores a copy for every user.
  • Key pair: a private key that makes signatures and a public key that can only check them.
  • Origin: the exact address a page came from, meaning the scheme (https), the domain and the port.
  • Phishing: tricking someone into typing secrets into a fake page that looks real.

Passkeys vs TOTP: which one stops phishing?

Passkeys stop the relay in the payroll story, and TOTP does not. A TOTP code depends only on the secret and the time, so a fake page can forward it. A passkey login is signed over the page's origin, and the browser will not use payroll.example's passkey on payroll-verify.example. This is called origin binding.

Think of a TOTP code as a cash voucher: whoever holds it can redeem it at the counter. A passkey signature is a cheque made out to one named payee, so another shop cannot cash it. Where the picture breaks: a bank checks a cheque after it is written, but the browser refuses to sign anything for the wrong site at all.

Passkeys vs TOTP for the payroll app's customers
QuestionTOTP codePasskey
What does the server store?A copy of the shared secretA public key and a credential ID, a label for that key
Is it tied to one website?No, it works wherever it is typedYes, scoped to the site's RP ID and checked against the origin
Can a fake page relay it live?Yes, within the time windowNo, the browser offers no passkey there
What if the server's database leaks?Stolen secrets can make valid codesPublic keys alone cannot sign anyone in
What if the phone is lost?Depends on where the app saved the secretSynced passkeys can be restored; device-bound ones need a second authenticator

How does TOTP work, and why can a fake site reuse the code?

TOTP feeds the shared secret and the current time step into HMAC, then cuts the result down to a short number, usually six digits. A hash turns any input into a fixed-size fingerprint, and HMAC is a hash that also needs a key. The default hash is SHA-1, and RFC 6238 also allows SHA-256 or SHA-512. It builds on HOTP from RFC 4226, which used a counter instead of the clock.

When the accountant set up the login, the app showed the secret as a QR code and the authenticator app saved it. Both sides now count 30-second steps since 1 January 1970 and compute the code for the current step.

python
import base64, hashlib, hmac, struct, time

def totp(secret_b32, step=30, digits=6, now=None):
    key = base64.b32decode(secret_b32.upper() + "=" * (-len(secret_b32) % 8))  # pad to 8
    t = int((time.time() if now is None else now) // step)  # 30-second steps since 1970
    msg = struct.pack(">Q", t)                   # step number as 8 bytes
    mac = hmac.new(key, msg, hashlib.sha1).digest()
    offset = mac[-1] & 0x0F                      # dynamic truncation
    part = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(part % 10 ** digits).zfill(digits)

# Check against the RFC 6238 test vector for time 59: expect 94287082
rfc_seed = base64.b32encode(b"12345678901234567890").decode()
print(totp(rfc_seed, digits=8, now=59))

demo_secret = "JBSWY3DPEHPK3PXP"                 # made-up seed from the QR code
print("Code right now:", totp(demo_secret))

What just happened? The first line prints 94287082, the RFC's own published value for time 59 seconds, so the function is correct. The second line prints a different six-digit code every 30 seconds.

Now look at the inputs: a secret and a time. The site's name appears nowhere, so the code is equally valid on payroll.example or when a fake page forwards it.

How long a relayed code stays valid

RFC 6238 recommends that a server accept at most one extra time step for network delay. With that allowance, a code is accepted until the end of the next step. That is between 30 and 60 seconds after it appears, depending on when in its step it was shown. Servers that also allow for clock drift accept codes for longer: RFC 6238's example of two backward steps gives about 89 seconds.

Without the extra step, the window shrinks to what is left of the current step, which can be under a second. A relay forwards the code in real time, so the strict setting only helps when a code was about to expire anyway.

The RFC also says the verifier must not accept the same code twice. That rule does not help here, because the attacker's relayed attempt is the first and only use of the code.

How the fake page relayed the accountant's code

Notice that every arrow carries the same password and code. Nothing in them names the site, so the payroll app cannot tell the login came through a fake page.

Diagram connections:

  • Fake SMS to Lookalike login page (Accountant taps the link)
  • Lookalike login page to Relay to the real site (Forwarded within seconds)
  • Relay to the real site to Signed-in session (Code checks out)
  • Signed-in session to Bank account changed (Changes payout account)
  • Fake SMS: Attacker → accountant: salary file failed
  • Lookalike login page: Accountant → fake site: password and code
  • Relay to the real site: Phishing kit → payroll app: same password and code
  • Signed-in session: Payroll app → attacker: logged in as the accountant
  • Bank account changed: Attacker → payroll app: new payout account

How does a passkey tie a login to the real website?

The browser only uses a passkey on the site it was made for, and the page's origin is signed into every login. The WebAuthn spec puts the first part plainly: a credential can only be accessed by origins belonging to that relying party.

Each passkey belongs to an RP ID, short for relying party ID, which is a domain name. By default it is the page's own domain, here payroll.example. Normally the spec allows an RP ID only if it equals the page's domain or is a registrable parent of it.

A registrable parent is a parent domain someone could own, not a public ending such as co.in. So login.payroll.example may use payroll.example. But payroll-verify.example cannot, because payroll.example is not its parent.

WebAuthn Level 3 adds one opt-in exception, related origins. The site at the RP ID can publish a list of its other origins at a fixed address on its own domain. A browser that supports this checks the list before refusing. A lookalike cannot add itself, because only payroll.example controls that list.

Pause and predict: the lookalike page asks the browser for the payroll app's passkey. What does it get back?

A passkey sign-in at the payroll app

Notice where the lookalike page stops: at the browser's RP ID check, before the phone is asked to sign anything. The real page's request goes all the way through.

Diagram connections:

  • Real sign-in page to Browser checks the RP ID (Challenge)
  • Browser checks the RP ID to Accountant's phone (RP ID matches)
  • Accountant's phone to Payroll app server (Signs challenge and origin)
  • Lookalike page to Browser checks the RP ID (Wrong domain: refused)
  • Real sign-in page: Server → browser: a fresh random challenge
  • Browser checks the RP ID: Browser: may this page use payroll.example?
  • Accountant's phone: Browser → phone: sign this challenge
  • Payroll app server: Phone → server: signature checked with public key
  • Lookalike page: Fake site → browser: asks for a passkey

The answer is nothing usable. The browser refuses payroll.example's passkeys on the lookalike page, and the accountant has none for the lookalike's own domain.

For the real page, the server sends a challenge, a random value used once, and the phone signs it with the private key. The fingerprint or PIN check happens on the device. The FIDO Alliance says biometric data stays on the device and is never sent to any server.

The server checks too. It confirms the signed origin is one it expects. It also confirms the RP ID hash matches its own; that hash is a SHA-256 fingerprint of the domain, stored in the signed data.

python
import hashlib, json

EXPECTED_ORIGIN = "https://payroll.example"
RP_ID = "payroll.example"

def check_assertion(client_data_json, auth_data, sent_challenge):
    client = json.loads(client_data_json)
    if client["type"] != "webauthn.get":
        return "not a sign-in request"
    if client["challenge"] != sent_challenge:      # base64url text we sent
        return "stale or replayed challenge"
    if client["origin"] != EXPECTED_ORIGIN:
        return "signed for another site"
    if auth_data[:32] != hashlib.sha256(RP_ID.encode()).digest():
        return "wrong RP ID"
    return "ok so far: now verify the signature with the stored public key"

What just happened? We fed this function assertions, the signed sign-in responses, from a test authenticator. One signed on payroll-verify.example returned "signed for another site", and a correct one passed every check.

The browser's RP ID check already stops the lookalike domain. The origin line is the server's backstop, for example against a hijacked page at evil.payroll.example, which may use the payroll.example RP ID.

Check it with a real library

The function above skips the signature check, so use a maintained WebAuthn library in production. We ran the same cases through py_webauthn 3.0.1, the Python server library from Duo Labs. A software authenticator, a test program that signs like a security key, made the assertions.

It signs the authenticator data plus the SHA-256 hash of the client data JSON with a test private key. The client data JSON is the browser's note of the request type, challenge and origin. To repeat it, run pip install webauthn==3.0.1 and call verify_authentication_response. Keep expected_origin set to the real site and pass an assertion signed on the lookalike origin.

A login signed on the lookalike origin fails with an error starting "Unexpected client data origin". A wrong RP ID hash fails with "Unexpected RP ID hash". A tampered signature fails with "Could not verify authentication signature".

What is the difference between synced and device-bound passkeys?

A synced passkey is copied to your other devices through an end-to-end encrypted cloud service. That means the cloud holds it in a form only your own devices can read. A device-bound passkey never leaves one device, such as a USB security key.

Both resist phishing. The FIDO Alliance says the goal is met at sign-in whether or not the keys are bound to hardware, because origin binding works the same way.

Every passkey sign-in carries two flags, single bits that are on or off. BE, backup eligible, says whether the passkey may ever be copied, and the spec says it is permanent for that passkey. BS, backup state, says whether it is backed up right now.

The spec calls a passkey with BE set a multi-device credential, and one without it a single-device credential. In the payroll story, the accountant's phone passkey arrives with BE 1 and BS 1. It can be restored on a new phone, provided the accountant can still sign in to the sync account.

A USB security key arrives with BE 0. A single-device passkey cannot be copied, so losing the key loses that passkey. Such users need a second authenticator or a recovery process.

Read the flags yourself

The flags sit in one byte of the authenticator data, the signed bytes the authenticator returns. It is the 33rd byte, right after the 32-byte RP ID hash. Bit 0 is UP (user present), bit 2 is UV (user verified), bit 3 is BE and bit 4 is BS.

python
def read_flags(flags_byte):
    return {"UP": bool(flags_byte & 0x01), "UV": bool(flags_byte & 0x04),
            "BE": bool(flags_byte & 0x08), "BS": bool(flags_byte & 0x10)}

print(read_flags(0x1D))  # {'UP': True, 'UV': True, 'BE': True, 'BS': True}
print(read_flags(0x05))  # {'UP': True, 'UV': True, 'BE': False, 'BS': False}

In a sign-in with no extensions, a backed-up synced passkey that verified the user gives 0x1D. A device-bound passkey that verified a PIN gives 0x05. A byte with BS set but BE clear makes no sense, and py_webauthn 3.0.1 rejects it as impossible.

Why is attestation at the centre of the October 2026 passkey debate?

Attestation lets a site verify which model of authenticator made a passkey, and so choose which ones it accepts. The trap post fears sites will turn that against users. Helme argues the fear does not match how passkeys work today.

What WebAuthn attestation reports

A site asks for attestation with one of four values. The default, none, means it is not interested. Indirect asks for a verifiable statement but lets the browser decide how, for example through an anonymising service. Direct asks for the authenticator's own statement.

Enterprise is for company deployments that tie registrations to specific authenticators. Level 3 only allows it when the browser chooses to support enterprise attestation for that site.

When a site asks for none, Level 3 tells the browser to set the attestation format to "none" and send an empty statement. So a browser following the spec strips any vendor-signed proof before the site sees it.

Attestation names a model through its AAGUID, a 16-byte (128-bit) ID for an authenticator's make and model, not the single device. MDN's reference advises sites to ignore it unless they have an enterprise or regulatory need.

With none, Level 3 removes only the signed statement and does not tell the browser to blank the AAGUID. So a site may still see a claimed model ID, but it cannot prove it. We checked that py_webauthn 3.0.1 accepts such a registration and reports the AAGUID it carries.

What the trap post and Scott Helme each argue

The trap post calls attestation a dial sites could turn up over time. In the end, only approved passkey managers, operating systems and firmware might be allowed to log in. For better security than a password, it points to TOTP codes for now. It calls hardware security keys an even more secure option.

Its addendum concedes that passkeys are, in most use cases, bound to a domain. So they resist man-in-the-middle attacks, the live relay described earlier. Its objection is to attestation and confusing branding, not to phishing resistance.

Helme replies that synced passkeys return none when a site asks for attestation. So a consumer site that demanded it would lock out almost all of its own users. He adds that attestation names the authenticator's make and model, not your operating system, browser or extensions.

He also notes that hardware security keys attest too: every YubiKey ships with a Yubico-issued attestation certificate. His advice is to keep using passkeys and push back if consumer sites ever start demanding attestation.

How do you recover an account or move passkeys to a new provider?

Recovery is still the site's job, and moving passkeys between providers is only partly standardised as of 19 October 2026. A lost device or an abandoned provider can still lock a user out.

The FIDO Alliance points to two safety nets. Passkeys that are backed up are better protected from loss, and a FIDO security key can act as a recovery credential.

For moving credentials, the Credential Exchange Format defines a common layout for passwords, passkeys, TOTP secrets and more. The protocol meant to carry it, the Credential Exchange Protocol, is still the 3 October 2024 working draft.

Helme's practical advice is to pick a passkey provider that supports moving your credentials. Sites help by letting each account register passkeys from several providers.

Watch the fallback path too. If recovery ends in an emailed or SMS code, a phisher can relay that code exactly like a TOTP code.

How should a team roll out passkeys without locking users out?

Add passkeys next to TOTP, protect the riskiest actions with them, and keep recovery tested. The payroll team's plan has five steps.

  1. Offer a passkey after sign-in

    After a normal password and TOTP login, offer to create a passkey, with attestation left at none.

  2. Allow several passkeys

    Let each account add a phone, a laptop and a USB key, each shown by a name the user picks in settings.

  3. Store public data only

    Save the credential ID, the public key and the latest BE and BS flags. Nothing secret is stored.

  4. Guard the payout screen

    Require a fresh passkey sign-in with the UV flag set before anyone changes a payout bank account. That is the step the attacker used. TOTP-only users add a passkey first.

  5. Retire TOTP slowly

    Keep TOTP as a fallback and warn its users about lookalike pages. Remove passwords only for accounts that have a backed-up passkey or a second authenticator.

How do you explain passkeys vs TOTP in a system design interview?

Start with the attack, then the mechanism, then the trade-offs. Each step below is one short sentence you can say aloud.

  1. Name the threat

    A real-time phishing relay beats any code the user types, including TOTP.

  2. Explain origin binding

    A passkey is a key pair scoped to an RP ID, and the origin is signed into each login.

  3. Say what the server stores

    Public keys, credential IDs and backup flags, with no shared secrets to leak.

  4. Compare synced and device-bound

    Synced passkeys can be restored after device loss. Device-bound ones need a second authenticator.

  5. Close with attestation and recovery

    Attestation defaults to none. Recovery is now the weakest link, so protect risky actions with a fresh passkey check.

Questions people ask

Are passkeys a trap?
That is a matter of opinion, not fact. The trap post fears sites could use attestation to accept only approved software. Scott Helme replies that synced passkeys send no attestation, so a consumer site that demanded it would lock out almost all of its users. The trap post itself concedes passkeys are, in most use cases, bound to a domain.
Can TOTP codes be phished?
Yes. A TOTP code is made from a shared secret and the time, so it does not name the site that asked for it. A fake page can pass it to the real site while it is still valid, which is between 30 and 60 seconds when the server allows one extra step. Servers that also allow for clock drift accept it for longer.
Do synced passkeys send attestation?
According to Scott Helme, no: synced passkeys return none. In the WebAuthn spec, none is also the default, meaning the site is not interested. Even with none, the site may still see an unproven model ID, the AAGUID. Hardware security keys can provide attestation, and every YubiKey ships with a Yubico-issued attestation certificate.
What happens to my passkeys if I lose my phone?
It depends on the kind. A synced passkey is backed up through an end-to-end encrypted cloud service, so you can restore it on a new device if you can still reach that account. A device-bound passkey is lost with the device, so keep a second one.
Should websites stop offering TOTP?
Not straight away. TOTP is much safer than a password alone, and some users will not have passkeys yet. Add passkeys first, require them for risky actions, and retire TOTP slowly once accounts have a backed-up passkey or a second authenticator.
Can I move passkeys from one provider to another?
Partly. The FIDO Alliance's Credential Exchange Format, a Proposed Standard, defines a common layout for moving credentials. As of 19 October 2026, its transfer protocol is still a 2024 working draft. Register passkeys from more than one provider where you can.

Sources and further reading

Practise this with Lala

Practise