DEV Community

Cover image for I Shipped 'Passwordless Login.' It Still Asked for a Username.
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

I Shipped 'Passwordless Login.' It Still Asked for a Username.

I shipped "Sign in with a passkey" on a side project last month. Fingerprint reader popped up, the credential got created, login worked. I was pretty proud of it — right up until my partner tried it and typed her email address first, the way she does for every other login, and only then got the fingerprint prompt.

That's not a passkey. That's a security key with extra steps. The whole pitch of a passkey is that the browser already knows who's trying to sign in — no username field, no typing, just a tap. Mine still needed the name first. And the code I'd written looked exactly like the WebAuthn examples everyone links to.

The code that "works"

Here's roughly what I had, trimmed to the part that matters:

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: randomChallengeFromServer,
    rp: { name: "My App", id: "myapp.com" },
    user: {
      id: userIdBytes,
      name: "you@example.com",
      displayName: "You",
    },
    pubKeyCredParams: [{ alg: -7, type: "public-key" }],
  },
});
Enter fullscreen mode Exit fullscreen mode

This is correct WebAuthn. It creates a real public/private key pair, stores the private half on the authenticator, and hands you back a public key to save on your server. Signing in with it later works fine — if you already know which user is signing in and can send back the specific credential.id you stored for them.

That "if" is the whole bug. Nothing here tells the authenticator to save this credential somewhere the browser can find on its own. So at login time, you're stuck asking for a username first, looking up that user's stored credential ID, and only then calling navigator.credentials.get() with it. Which is exactly the flow I'd built — technically WebAuthn, functionally a slower 2FA prompt.

Discoverable vs. non-discoverable — the distinction the tutorials skip

A WebAuthn credential can be one of two things, and the spec's own names for them undersell how different they are in practice:

  • Non-discoverable (a "server-side" credential). The credential is registered, but nothing about it is stored on the authenticator in a way the browser can enumerate. To sign in, your server has to already know the user (from a username, a cookie, whatever) and hand back that user's specific credential ID in the get() call.
  • Discoverable (what people mean by "passkey"). The credential itself is saved on the device — in the platform's keychain or the security key's own storage — with enough metadata that the browser can show it in a picker with no prior input at all. Type nothing, get a list of "sign in as ___," tap one, done.

Spot the fix yet? It's one field, in the options object you already have:

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: randomChallengeFromServer,
    rp: { name: "My App", id: "myapp.com" },
    user: { id: userIdBytes, name: "you@example.com", displayName: "You" },
    pubKeyCredParams: [{ alg: -7, type: "public-key" }],
    authenticatorSelection: {
      residentKey: "required",       // ← this is the whole fix
      userVerification: "preferred", // ask for biometric/PIN, don't require it
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

residentKey: "required" tells the authenticator: don't just hand back a key, keep a copy of yourself here so the browser can find you later without being told your name. That's the difference between "a WebAuthn credential" and "a passkey" — the second is really just the first, stored discoverably.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The other half: how sign-in actually skips the username field

Making the credential discoverable only gets you halfway. The login call still has to ask for a discoverable credential instead of a specific known one — and there's a second option most people never see because it doesn't live in the WebAuthn spec at all. It's part of the separate Credential Management API, layered on top:

const credential = await navigator.credentials.get({
  publicKey: {
    challenge: randomChallengeFromServer,
    // no allowCredentials list — let the browser show whatever it has
  },
  mediation: "conditional",
});
Enter fullscreen mode Exit fullscreen mode

mediation: "conditional" is what makes a passkey show up inside the browser's own autofill dropdown on a plain <input autocomplete="username webauthn"> field, sitting right next to any saved passwords — instead of requiring a separate "Sign in with passkey" button the user has to notice and click. Drop the button. Let the username field do it. That's the UX every passkey demo video is showing you, and it's a call that has nothing to do with residentKey — you need both.

Why this is more than a UX nitpick

The reason WebAuthn is worth this much ceremony isn't the fingerprint reader — Touch ID logins existed before this API. It's what happens underneath, on every sign-in:

  1. Your server sends a fresh, random challenge.
  2. The authenticator signs that challenge with the private key — the one that has never left the device and never will.
  3. Critically, the signature also covers the origin the browser is actually on. A credential created for myapp.com produces a signature that a server for myapp.com accepts — and a phishing page at myapp-login.com cannot get a valid one out of the same authenticator, because the browser includes the real origin, not whatever the page claims to be.

Password managers that autofill by domain get phished anyway when a user manually copy-pastes. WebAuthn doesn't give the user that option — there's no password to copy. The credential simply doesn't produce a usable signature for the wrong origin. That's not "harder to phish." It's structurally not phishable in the way a shared secret is.

Two things to know before you reach for it

  • It needs a secure context and a stable rp.id. WebAuthn only runs on HTTPS (or localhost), and rp.id has to be the exact domain or a registrable parent of it — a credential registered for app.example.com will not work if you later authenticate against a different subdomain unless rp.id was set to the shared parent domain from the start. Decide that ID before you have real users on it.
  • You still store a public key and a sign counter, not "nothing." Losing your database doesn't hand out passwords — public keys are safe to leak by design — but you're still responsible for storing each credential's ID, public key, and signature counter correctly per user, and for letting people register a second authenticator in case they lose a device.

The takeaway

A "passkey" isn't a separate technology from WebAuthn — it's WebAuthn with residentKey: "required" on registration and mediation: "conditional" on login, so the browser can offer the credential without you asking for a name first. Skip either one and you've built a working, secure, entirely un-magical second factor that still makes people type their email.

Have you shipped a passkey flow that still shows a username field first? What did the fix turn out to be?

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The username-less promise is exactly where the seam shows. I shipped the same passkey flow and it degraded the same way: works perfectly in the happy path, then a real user types the email first and the browser still requires the discoverable-credential ceremony. For me the fix wasn't in the WebAuthn calls at all, it was refusing to even render a username field when PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() is true and we can use discoverable credentials. If the UI asks for identity, the passkey never gets the chance to be the first-class path you intended.