Skip to content

Chapter 7 of 1210 min readBy Deepak GuptaPublished

Designing the Login Screen and Recovery

In Baymard Institute's research, 18% of US online shoppers who abandoned an order did so because the site wanted them to create an account.

Those shoppers never reached your authentication technology. They left at the screen in front of it. Great authentication fails behind a bad screen, and the best passkey integration in the world cannot rescue a page that asks for an account nobody wanted. This chapter covers the screen, the code that makes passkeys appear in it, the words on it, and the recovery flow behind it.

Before login: do not ask for an account you do not need

  • Let people buy, browse, or try as guests. Offer the account on the confirmation page, where saving a passkey takes one tap.
  • Ask for sign-in at the moment it adds value to the user: order history, saved carts, synced settings, personalization.

The friction map in Chapter 8 starts from this rule. The top rows, browse and add to cart, need no login at all.

The screen itself

  1. Identifier-first. Ask for email or phone, then route to the right method: passkey, SSO for business domains, or a fallback. One field is less intimidating than three.
  2. Passkey autofill. Enable WebAuthn conditional mediation so existing passkeys appear in the same autofill menu users already know. (web.dev passkey checklist)
  3. Autofill and paste always work. Use autocomplete="username webauthn" and never block paste in password fields.
  4. Passkey as a peer, not a footnote. "Sign in with face or fingerprint" sits beside the primary button.
  5. Social sign-in where your audience uses it, with account linking so one person never ends up with two accounts.
  6. Small kindnesses. Show/hide password toggle, Caps Lock warning, and keep the email filled after a failed attempt.

Item 2 is the one that moves passkey share of sign-ins. Chapter 4 showed that most services in the 2026 FIDO Passkey Index had users with passkeys who were not routed to them. Autofill is the routing.

The code: passkey autofill in two snippets

Conditional mediation lets the browser offer saved passkeys in the username field's autofill menu. The user picks one, unlocks with a face or fingerprint, and is signed in. No button, no modal.

The markup

The webauthn token in the autocomplete attribute tells the browser to include passkeys in the menu. Include both username and webauthn, separated by a space (web.dev).

<form id="signin" action="/signin" method="post">
  <label for="username">Email or phone</label>
  <input
    type="text"
    id="username"
    name="username"
    autocomplete="username webauthn"
    autofocus
  />
  <button type="submit">Continue</button>
  <button type="button" id="passkey-button">
    Sign in with face or fingerprint
  </button>
</form>

The autofocus attribute makes the autofill prompt appear on page load. The visible passkey button stays for users who want an explicit choice.

The script

This script checks for support, asks your server for a challenge, and starts a conditional request that waits quietly until the user picks a passkey. Feature detection follows MDN, which recommends getClientCapabilities() and keeps isConditionalMediationAvailable() as the older check.

// Start on page load, not on a click. Run as a module or inside an async function.
let passkeyAbort;

async function conditionalGetAvailable() {
  if (!window.PublicKeyCredential) return false;
  if (PublicKeyCredential.getClientCapabilities) {
    const caps = await PublicKeyCredential.getClientCapabilities();
    return caps.conditionalGet === true;
  }
  if (PublicKeyCredential.isConditionalMediationAvailable) {
    return PublicKeyCredential.isConditionalMediationAvailable();
  }
  return false;
}

async function startPasskeyAutofill() {
  if (!(await conditionalGetAvailable())) return;

  // Server returns JSON: { challenge, rpId, allowCredentials: [], userVerification: "preferred" }
  const res = await fetch("/auth/passkey/options", { method: "POST" });
  const optionsJSON = await res.json();
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);

  passkeyAbort = new AbortController();
  try {
    const credential = await navigator.credentials.get({
      publicKey,
      mediation: "conditional",
      signal: passkeyAbort.signal,
    });
    const verify = await fetch("/auth/passkey/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential.toJSON()),
    });
    if (verify.ok) window.location.assign("/account/");
  } catch (err) {
    if (err.name !== "AbortError") console.error(err);
  }
}

startPasskeyAutofill();

Four details matter in production:

  • Empty allowCredentials. An empty list lets the browser show every passkey saved for your domain, which is what autofill needs (web.dev).
  • Abort before any other WebAuthn call. If the user taps the explicit passkey button, call passkeyAbort.abort() first, then start the modal request. web.dev recommends the abort signal for exactly this case (web.dev).
  • rpId must match creation. Use the same relying party ID your passkeys were registered with, or the browser will find nothing to offer.
  • Older browsers. Where parseRequestOptionsFromJSON() or toJSON() is missing, decode the base64url fields with a small helper or a maintained WebAuthn library.
Note

When a sign-in fails because the server no longer knows a credential, tell the user's password manager. Chrome 132 shipped the WebAuthn Signal API, including PublicKeyCredential.signalUnknownCredential({ rpId, credentialId }), so a deleted passkey stops appearing in autofill (Chrome for Developers). Feature-detect it before calling. For the wider standard changes, see WebAuthn Level 3: What's New in the Passkey Standard.

Enrollment moments that convert

  • Right after a successful sign-in, when the user has just proven who they are.
  • Silently, with conditional create. Chrome and Safari can enroll a passkey in the background after a password autofill sign-in. (Chrome for Developers)
  • After a cross-device QR sign-in, so the next login on this device skips the QR step.
  • During password reset. Someone who just forgot a password is the most receptive user you will ever have.
  • Not forced at first signup for consumers. Make it the obvious default instead.

Ready-to-use copy

Words decide whether users trust a passkey prompt or dismiss it. The blocks below are starting copy. Replace [Brand] and the bracketed details, and keep the structure. Every message says what happened and what to do next.

Passkey enrollment prompt (after sign-in)

Sign in faster next time
Use your face, fingerprint, or screen lock instead of a password or code.
Your passkey stays on your device. [Brand] never sees your fingerprint or face.

[Create a passkey]     [Not now]

Keep "Not now" visible and honest. If the user declines, ask again at the next good moment, not on every page.

Passkey enrollment prompt (during password reset)

Skip passwords from now on
You just reset your password. Want to make this the last time?
Create a passkey and sign in with your face, fingerprint, or screen lock.

[Create a passkey]     [Keep using my password]

Error messages

Wrong credentials (never say which part was wrong):
That email and password don't match. Try again or reset your password.

Passkey cancelled:
Sign-in was cancelled. Try again, or choose another way to sign in.

Passkey not found on this device:
We couldn't find a passkey for [Brand] on this device.
Use a phone that has one, or sign in with an email code.

Code expired:
That code has expired. We've sent a new one to [masked email].

Too many attempts:
Too many tries. For your security, wait 15 minutes or reset your password.

The first rule is the important one. Use one message for wrong email or wrong password: "That email and password don't match. Try again or reset it." Saying which one was wrong lets attackers enumerate accounts. (Cieden) Apply the same rule to "forgot password" and signup: respond the same way whether or not the address has an account.

  • Tell people what to do next, not just what failed.
  • Show CAPTCHAs only when risk signals justify them. A default CAPTCHA punishes every human to slow down a few bots.

Recovery started email

Subject: Someone asked to recover your [Brand] account

We received a request to recover your account on [date, time, time zone]
from [approximate location, device type].

If this was you, use this link within 15 minutes:
[Recover my account]

If this wasn't you, do nothing. Your account stays as it is.
To be safe, review your sign-in methods: [Security settings]

[Brand] will never ask for this code or link by phone, chat, or text.

Recovery completed email

Subject: Your [Brand] account was recovered

Your account was recovered on [date, time] from [device type, location].
For your protection, changes to your email, phone, and payout details
are paused for 24 hours.

Not you? Secure your account now: [This wasn't me]

New device notification

Subject: New sign-in to your [Brand] account

You signed in on [device and browser] near [approximate location]
on [date, time, time zone].

If this was you, there's nothing to do.
If it wasn't, sign out everywhere and secure your account: [This wasn't me]

Send this one to every known channel when the sign-in is on a new device. It costs the real owner a glance and gives them the first chance to catch a takeover, as rule 5 in Chapter 6 requires.

Recovery is the new front door

Once passwords fade, attackers aim at recovery, usually through help desks and support chats. (Start with Identity)

  • Collect at least two recovery methods at a calm moment, not during a lockout.
  • Use single-use, short-lived email links or codes, never security questions.
  • Add a cool-down period and notifications before a recovered account can change contact details or move money.
  • Give support agents a scripted verification process that cannot bypass the account's own factors.
  • After recovery, offer a passkey immediately.

Your recovery flow sets the real security ceiling of your login. A phishing-resistant sign-in with a recovery path that accepts a date of birth over chat is only as strong as that chat. I wrote about this at length in Account Recovery Design: The Flow That Determines Your Real Security Ceiling.

A support script that cannot be talked around

Help desk staff want to help, which is exactly what social engineers count on. Give them a script with no override branch.

  1. Ask the caller to start recovery in the app or website while on the line.
  2. Confirm that the recovery request appears in your admin tool with the expected time and channel.
  3. Let the account's own recovery factors do the verification. The agent watches; the agent does not verify.
  4. If the caller cannot complete any factor, open a slow-path case with a longer cool-down and notifications to every known channel.
  5. Never change an email, phone, or payout detail during the call.

Seeing what good looks like from the user's side also helps. Locked Out walks people through official recovery paths on major platforms, and it is a useful reference for the tone and step order users already expect.

Key takeaways

  • Guest paths and sign-in at the moment of value remove friction before authentication even starts.
  • autocomplete="username webauthn" plus a conditional navigator.credentials.get() call is what routes users with passkeys to them.
  • Error copy must never reveal whether an account exists, and every message should say what to do next.
  • Recovery is the new attack surface. Use two pre-registered methods, cool-downs, notifications, and a support script with no override.
Tip

Do this next week:

  1. Add webauthn to the username field's autocomplete attribute and ship the conditional mediation script behind a feature flag.
  2. Replace your login, reset, and signup error messages with the non-enumerating copy above.
  3. Turn on new-device notifications to every known channel.
  4. Run a recovery drill: have a colleague call support pretending to be locked out, and see what the agent changes.