Passkeys without usernames: how discoverable credentials work
FIDO credentials such as passkeys were designed to replace passwords, but many of them can also eliminate the username field entirely. With discoverable credentials, a user can authenticate by picking an account from a list of passkeys already registered with the current site, without typing anything.
Earlier security keys were built for two-step authentication and needed credential IDs up front, which meant the user had to supply a username first. Credentials that a security key can locate on its own, without knowing their IDs, are called discoverable credentials. Most FIDO credentials created today — especially passkeys held in a password manager or on a modern security key — are discoverable credentials.
To make sure your credentials are created as discoverable credentials, set residentKey and requireResidentKey during credential creation.
Creating discoverable credentials
Pass the residentKey and requireResidentKey properties inside authenticatorSelection on navigator.credentials.create():
async function register () {
// ...
const publicKeyCredentialCreationOptions = {
// ...
authenticatorSelection: {
authenticatorAttachment: 'platform',
residentKey: 'required',
requireResidentKey: true,
}
};
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
});
// This does not run until the user selects a passkey.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;
// ...
}
residentKey accepts one of three values:
'required': the authenticator must create a discoverable credential; otherwise aNotSupportedErroris returned.'preferred': the relying party (RP) would like a discoverable credential but will accept a non-discoverable one.'discouraged': the RP prefers a non-discoverable credential but will accept a discoverable one.
requireResidentKey is kept for backward compatibility with WebAuthn Level 1. Set it to true when residentKey is 'required', and false otherwise.
Authentication flows that skip the username
During authentication, an RP controls the experience through the allowCredentials parameter of navigator.credentials.get(). Depending on how that parameter is set, three different passkey sign-in flows are possible. Account selector UIs never display non-discoverable credentials, so only passkeys will appear in these flows.
Modal account selector
When a user taps a dedicated passkey sign-in button, the typical flow is a modal dialog listing available passkeys, followed by user verification. This works well when most of your users have local passkeys. To trigger it, omit allowCredentials from the get() call or pass an empty array:
async function authenticate() {
// ...
const publicKeyCredentialRequestOptions = {
// Server generated challenge:
challenge: ****,
// The same RP ID as used during registration:
rpId: 'example.com',
// You can omit `allowCredentials` as well:
allowCredentials: []
};
const credential = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
signal: abortController.signal
});
// This does not run until the user selects a passkey.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;
// ...
}
Conditional UI with form autofill
The modal approach is less ideal during a transition period, since users without local passkeys still see a dialog offering them the option to present a passkey from another device. In that situation, consider folding passkey selection into the autofill suggestions of a traditional sign-in form. Users with saved usernames and passwords see those options side-by-side with passkeys, and users with neither can simply type their credentials.
This conditional flow requires an empty allowCredentials array (or omitting the parameter), mediation: 'conditional' on the get() call, and an HTML input annotated with autocomplete="username webauthn" or autocomplete="password webauthn".
async function authenticate() {
// ...
const publicKeyCredentialRequestOptions = {
// Server generated challenge:
challenge: ****,
// The same RP ID as used during registration:
rpId: 'example.com',
// You can omit `allowCredentials` as well:
allowCredentials: []
};
const cred = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
signal: abortController.signal,
// Specify 'conditional' to activate conditional UI
mediation: 'conditional'
});
// This does not run until the user selects a passkey.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;
// ...
}
<input type="text" name="username" autocomplete="username webauthn" ...>
The get() call shows no UI by itself. If the user focuses the annotated input, available passkeys appear in the autofill drop-down. Selecting one triggers the usual device unlock verification before the promise resolves. If the user never picks a passkey, the promise never settles.
Reauthentication
When the user is already known — say, during reauthentication — no account selector is needed. Provide a list of credential IDs in allowCredentials so the browser or OS can match against locally available passkeys. If one matches, the user is prompted for device unlock directly. Otherwise, they are prompted to present an external device holding a valid credential.
async function authenticate() {
// ...
const publicKeyCredentialRequestOptions = {
// Server generated challenge:
challenge: ****,
// The same RP ID as used during registration:
rpId: 'example.com',
// Provide a list of PublicKeyCredentialDescriptors:
allowCredentials: [{
id: ****,
type: 'public-key',
transports: [
'internal',
'hybrid'
]
}, {
id: ****,
type: 'public-key',
transports: [
'internal',
'hybrid'
]
}, ...]
};
const credential = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
signal: abortController.signal
});
// This does not run until the user selects a passkey.
const credential = {};
credential.id = cred.id;
credential.rawId = cred.id; // Pass a Base64URL encoded ID string.
credential.type = cred.type;
// ...
}
Each entry is a PublicKeyCredentialDescriptor built from:
id: the credential ID obtained when the passkey was registered.type: normally'public-key'.transports: a hint to the browser about how to prompt for an external device. If supplied, it should be the result of callinggetTransports()at registration time.
Putting discoverable credentials to work
Combining residentKey, requireResidentKey, and allowCredentials lets an RP build passkey sign-in that adapts to the context: a modal selector for fresh sign-ins, form autofill for mixed password/passkey migrations, and a silent reauthentication prompt for returning users. Used deliberately, these options produce a seamless sign-in experience that encourages repeat engagement.



