Discoverable vs Server-Side Credentials

Whether a WebAuthn credential is discoverable (a resident key the authenticator can enumerate on its own) or server-side (a non-resident key the RP must name via allowCredentials) is the single decision that determines whether usernameless, one-tap passkey sign-in is even possible. It also drives authenticator storage cost, the shape of your authentication request, and your fallback UX. This page is the decision reference, grounded in WebAuthn Level 2 §5.4.7 and CTAP2. It expands the storage trade-off introduced in public key vs symmetric credential types and sits under WebAuthn & FIDO2 Protocol Fundamentals.


Concept Definition and Spec Grounding

The controlling knob is residentKey inside authenticatorSelection (WebAuthn L2 §5.4.4), which supersedes the legacy boolean requireResidentKey:

  • residentKey: 'required' → a discoverable credential. The authenticator stores the credential and the associated userHandle and RP ID in its own memory, so it can list matching credentials without a hint. This is what makes a passkey appear in conditional mediation autofill with no username typed.
  • residentKey: 'discouraged' → a server-side (non-resident) credential. The authenticator derives the key from a wrapped key handle stored in the returned credentialId; it keeps nothing locally, so the RP must supply the credentialId in allowCredentials for the user to authenticate.
  • residentKey: 'preferred' → discoverable if the authenticator can, otherwise server-side.

Discoverability is also surfaced post-registration: the authenticator sets the BE/BS flags and reports credential properties (credProps.rk) telling you whether a resident key was actually created.


Architecture and Data Flow

The flow below contrasts the two authentication ceremonies: usernameless (empty allowCredentials) versus username-first (server supplies the list).

Discoverable vs server-side authentication flows Left path: user focuses field, browser offers resident passkeys, empty allowCredentials. Right path: user enters username, server looks up credentialIds, populates allowCredentials. Discoverable (usernameless) Server-side (username-first) focus field → conditional get() allowCredentials: [] authenticator lists resident keys userHandle → RP resolves account user types username server looks up credentialIds allowCredentials: [id1, id2] authenticator signs named credential

Implementation Guide

Step 1 — Choose the residentKey policy at registration

// Discoverable: enables usernameless autofill sign-in
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' }

// Server-side: no resident slot consumed on the authenticator
authenticatorSelection: { residentKey: 'discouraged', userVerification: 'preferred' }

Step 2 — Confirm what was actually created (credProps)

Request the credProps extension and read rk to know whether a resident key was stored — authenticators may downgrade preferred.

// client: extensions: { credProps: true }
const ext = credential.getClientExtensionResults();
const isDiscoverable = ext.credProps?.rk === true;
await saveCredential({ /* … */, discoverable: isDiscoverable });

Step 3 — Shape the authentication request accordingly

// Usernameless (discoverable): empty allowCredentials → any resident passkey
const options = await generateAuthenticationOptions({ rpID, allowCredentials: [], userVerification: 'preferred' });

// Username-first (server-side): supply the stored credential IDs
const creds = await lookupCredentials(username); // from your indexed store
const options2 = await generateAuthenticationOptions({
  rpID,
  allowCredentials: creds.map((c) => ({ id: c.credentialId, type: 'public-key', transports: c.transports })),
});

The lookup that feeds allowCredentials is exactly the concern covered in credential indexing and database schema design.

Step 4 — Resolve the account from userHandle (discoverable only)

For usernameless sign-in the RP identifies the user from the returned userHandle, not a typed username, so userHandle must be a stable, non-PII account identifier set at registration.

const userHandle = base64url(assertion.response.userHandle); // set to an opaque account id at registration
const account = await findAccountByUserHandle(userHandle);

Validation Checklist


Error Reference Table

Error / Condition HTTP Status Trigger Diagnostic
ConstraintError (client) residentKey: 'required' but authenticator storage full Inform user; allow server-side fallback
Empty autofill dropdown Credential is server-side, not discoverable Check credProps.rk; re-register as discoverable
No account for userHandle 404 userHandle not stored or mismatched Verify userHandle set at registration equals stored id
Assertion with no allowCredentials fails 400 Server-side credential expected but none named Populate allowCredentials for username-first flow
PII leaked in userHandle — (privacy) Username/email used as userHandle Replace with opaque id; re-enrol

Platform and Library Notes

@simplewebauthn/server

generateRegistrationOptions({ authenticatorSelection: { residentKey } }) and the credProps extension are supported; verifyAuthenticationResponse exposes userHandle for account resolution. Empty allowCredentials yields the usernameless flow.

Roaming security keys

Resident-key slots are finite (older keys hold ~25); mandating required across many RPs can exhaust them. Prefer preferred for broad hardware-key support.

iOS / Android platform passkeys

Synced passkeys are always discoverable and effectively unlimited, so required is safe. userHandle round-trips through the sync.

Windows Hello

Discoverable credentials are supported but device-bound (no sync); plan recovery accordingly, and see platform vs roaming trade-offs.


Pitfalls and Security Hardening

1. Expecting autofill from server-side credentials. Root cause: residentKey: 'discouraged'. Mitigation: require discoverable credentials for usernameless sign-in.

2. Using PII as userHandle. Root cause: setting user.id to an email. Mitigation: use an opaque, stable identifier (WebAuthn L2 §5.4.3 recommends ≤64 random bytes).

3. Exhausting roaming-key resident slots. Root cause: required on hardware keys. Mitigation: use preferred; detect via credProps.rk.

4. No username-first fallback. Root cause: assuming every device holds a resident key. Mitigation: keep the allowCredentials path available.

5. Trusting preferred to always create a resident key. Root cause: not reading credProps. Mitigation: store rk and branch on it.


Related