Passkey autofill: show passkeys in the username field
Set up passkey autofill with mediation conditional: the autocomplete attribute, the feature check, an empty allowCredentials, aborting for the button, and fixes.
Passkey autofill puts a user’s passkeys in the same dropdown as saved passwords, on the username field they already tap. There is no extra button to find and no modal that appears before the user asks. The spec calls it conditional mediation; browsers and docs also call it conditional UI. It takes three changes to a normal passkey sign-in.
1. Mark the username field
Add webauthn as the last token of the field’s autocomplete:
<input type="text" name="username" autocomplete="username webauthn" autofocus />
email webauthn works the same for an email field. Without the webauthn token the browser shows passwords only.
2. Check support
async function autofillSupported() {
if (!window.PublicKeyCredential) return false;
if (PublicKeyCredential.getClientCapabilities) {
const caps = await PublicKeyCredential.getClientCapabilities();
return caps.conditionalGet === true;
}
return PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
}
getClientCapabilities() is the newer check; isConditionalMediationAvailable() covers browsers that shipped autofill before it.
3. Start a conditional get() when the page loads
Ask your server for sign-in options with an empty allowCredentials, then start the request straight away, not on a click:
const autofill = new AbortController();
async function startAutofill() {
if (!(await autofillSupported())) return;
const json = await fetch("/api/passkeys/options").then((r) => r.json()); // fresh challenge, allowCredentials: []
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
try {
const cred = await navigator.credentials.get({ publicKey, mediation: "conditional", signal: autofill.signal });
if (!cred) return;
await fetch("/api/passkeys/verify", { method: "POST", body: JSON.stringify(cred.toJSON()) });
} catch (err) {
if (err.name === "AbortError") return; // we cancelled it
if (err.name === "NotAllowedError") return; // the user dismissed it
throw err;
}
}
startAutofill();
The promise stays pending, with no UI, until the user focuses the field and picks a passkey. If they pick a saved password instead, the promise never settles and your normal form submit runs. Your server finds the account from the credential id and userHandle, because it did not know the user when it made the options.
The button, and why you must abort first
Keep a Sign in with a passkey button for browsers without autofill. Only one WebAuthn request runs at a time, so a pending conditional request blocks the button’s call. Abort it first:
button.onclick = async () => {
autofill.abort();
const json = await fetch("/api/passkeys/options").then((r) => r.json());
const cred = await navigator.credentials.get({ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(json) });
// verify as above
};
Abort the same way before a registration starts on the same page, and when a single-page app routes away from the sign-in screen.
When passkeys do not appear in the list
| Symptom | Cause |
|---|---|
| Only passwords in the dropdown | autocomplete lacks webauthn, or get() never ran because it waited for a click |
| Nothing at all | The field is not focused; autofocus or a tap opens the list |
| The user has a passkey but it is missing | rpId differs from the rp.id used at registration, or the passkey was made non-discoverable (residentKey: "discouraged") |
| Works on desktop, not in the app’s web view | Many in-app web views do not support WebAuthn; open the system browser or use the platform’s passkey API |
| Second sign-in fails with a challenge error | The page reused the first challenge. Make a fresh one for every get() |
| The button does nothing | The conditional request is still pending. Abort it before the button’s get() |
The server side
- A new random challenge for every request, kept a few minutes, used once.
allowCredentials: []for autofill,userVerification: "preferred", and the samerpIdevery time.- Look up the credential by id, check the signature, the challenge, the origin and the
rpIdHash, then start the session. - If the credential id is unknown (the passkey was deleted on your side), answer 404 and, where the browser supports it, call
PublicKeyCredential.signalUnknownCredential()so the password manager stops offering it.
With SignOps
SignOps Passkeys makes the options and checks the answer on its servers. In the browser, @signops/web does steps 2 and 3:
const { signops_token } = await passkeys.signIn({ autofill: true });
It checks support, starts the conditional request and gives back a token your server confirms with one call. The full flow, from your domain to the server check, is in the passkeys on your website guide. If something fails, WebAuthn errors lists each error name and its cause, and the passkey checker shows whether this browser supports autofill.
Sources: web.dev: Create a passkey for passwordless logins with form autofill, MDN: CredentialsContainer.get(), mediation, W3C: Web Authentication Level 3.