When to Use Resident vs Discoverable Credentials in WebAuthn
Choosing the wrong credential storage model is one of the most common causes of broken username-less login flows in WebAuthn deployments. This page resolves the specific sub-problem of deciding between discoverable (resident) and server-side (non-discoverable) credentials, diagnosing the errors that arise from a mismatch, and migrating existing registrations. For the broader cryptographic context — including COSE key encoding and public-key vs symmetric trade-offs — see Public Key vs Symmetric Credential Types.
Credential Storage Models: Spec Reference Table
A discoverable credential stores the private key, credential ID, and user.id (user handle) in the authenticator’s persistent memory. A server-side credential stores only the private key on the authenticator; the RP must supply the credential ID in allowCredentials during every assertion. The two models are controlled by the residentKey member of AuthenticatorSelectionCriteria (WebAuthn § 5.4.4).
| Constraint | Discoverable (residentKey: 'required') |
Server-side (residentKey: 'discouraged') |
|---|---|---|
| Credential ID stored on authenticator | Yes | No |
User handle (user.id) stored on authenticator |
Yes — returned in userHandle during assertion |
No |
| Username-less / conditional-mediation flow | Supported | Not possible |
allowCredentials during get() |
Omit (empty or absent) | Must be populated |
| CTAP2 storage consumed | Yes — limited slots per authenticator | No |
authenticatorData RK flag (bit 6, 0x40) |
Set | Clear |
| Minimum spec requirement | CTAP2.1 or CTAP2.0 with optional rk extension |
CTAP2.0+ |
The residentKey field supersedes the legacy boolean requireResidentKey. Do not use requireResidentKey: true in new code — it maps only to 'required' and cannot express 'preferred'.
Root Cause Analysis
Failure mode 1 — Authenticator storage exhausted
Trigger: residentKey: 'required' is set, but the security key’s on-device storage is full. Most FIDO2 hardware tokens have between 25 and 100 discoverable-credential slots.
Manifestation: The browser surfaces a NotAllowedError (user interaction required but not possible, or authenticator returned CTAP error 0x27 CTAP2_ERR_KEY_STORE_FULL). The OS-level WebAuthn dialog may show a “key already registered” or “storage full” message depending on the platform.
Diagnosis: Cross-reference the authenticator’s AAGUID against the FIDO Alliance MDS3 entry to find the documented maximum slot count, then query existing credentials via a silent navigator.credentials.get() with an empty allowCredentials array to count how many are already provisioned for this RP.
Failure mode 2 — CTAP2.0 firmware without discoverable credential support
Trigger: The authenticator firmware predates CTAP2.1 or was not manufactured with the optional rk (resident key) capability flag set. Discoverable credentials are an optional capability in CTAP2.0 and mandatory only from CTAP2.1 onwards.
Manifestation: With residentKey: 'required', registration fails. With residentKey: 'preferred', registration silently succeeds but produces a non-discoverable credential — the RK flag (bit 6, 0x40) in authenticatorData will be clear. Username-less assertion then fails because the authenticator cannot return the credential without an explicit allowCredentials hint.
Diagnosis: Parse the fmt and attStmt from the attestationObject during registration. CTAP2.1-capable authenticators will advertise the rk capability in their authenticator metadata. Alternatively, inspect the authenticatorData flags byte (offset 32) after registration.
Failure mode 3 — allowCredentials omitted for a non-discoverable credential
Trigger: A credential was registered with residentKey: 'discouraged' (or older code using requireResidentKey: false), but the assertion call omits allowCredentials or passes an empty array, expecting the authenticator to discover it.
Manifestation: The browser shows no credential picker, or NotAllowedError is thrown. The authenticator has no credential ID stored — it cannot enumerate available credentials for the RP without a hint from the server.
Diagnosis: Query your credential store for the user.id and confirm credential_is_discoverable (or equivalent boolean column) before deciding whether to populate allowCredentials.
Failure mode 4 — InvalidStateError on re-registration
Trigger: navigator.credentials.create() is called with residentKey: 'required' for a user.id that already has a discoverable credential stored on the authenticator for this rp.id. CTAP2.1 authenticators may return CTAP2_ERR_CREDENTIAL_EXCLUDED, mapped by browsers to InvalidStateError.
Manifestation: Registration fails immediately with InvalidStateError — often before the user interaction prompt appears on hardware keys.
Diagnosis: Call navigator.credentials.get() with an empty allowCredentials before calling create(). If a credential is returned for the same RP, redirect to the login flow rather than re-registering.
Step-by-Step Resolution
Step 1 — Detect platform and conditional mediation support
Before choosing a residentKey policy, determine what the client supports at runtime:
async function detectCapabilities(): Promise<{
conditionalUI: boolean;
platformUV: boolean;
}> {
const [conditionalUI, platformUV] = await Promise.all([
PublicKeyCredential.isConditionalMediationAvailable?.() ?? false,
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
]);
return { conditionalUI, platformUV };
}
isConditionalMediationAvailable() gates the autofill-driven passkey picker (the mediation: 'conditional' flow). Platform authenticators (Touch ID, Windows Hello, Android biometric) always support discoverable credentials. Roaming keys (USB/NFC security tokens) may not.
Step 2 — Set residentKey policy based on runtime capabilities
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types';
async function buildRegistrationOptions(
rpId: string,
userId: Uint8Array,
userName: string,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
const { conditionalUI, platformUV } = await detectCapabilities();
// Require discoverable when the platform can support it;
// prefer it otherwise so hardware keys can downgrade gracefully.
const residentKey: ResidentKeyRequirement =
conditionalUI || platformUV ? 'required' : 'preferred';
return {
rp: { id: rpId, name: 'SecureApp' },
user: {
id: Buffer.from(userId).toString('base64url'),
name: userName,
displayName: userName,
},
challenge: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'),
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256 — COSE key type 2, curve P-256
{ alg: -8, type: 'public-key' }, // EdDSA — COSE key type 1, curve Ed25519
{ alg: -257, type: 'public-key' }, // RS256 — fallback for Windows Hello
],
authenticatorSelection: {
residentKey,
userVerification: 'required',
},
};
}
Satisfies WebAuthn § 5.4.4 (AuthenticatorSelectionCriteria) and ensures userVerification: 'required' for NIST SP 800-63B AAL2 alignment. See the challenge-response authentication flow for the full assertion pipeline this feeds into.
Step 3 — Pre-flight check to avoid InvalidStateError on re-registration
async function safeRegister(
rpId: string,
publicKeyOptions: PublicKeyCredentialCreationOptions,
): Promise<Credential | null> {
try {
// Attempt a silent discoverable assertion — empty allowCredentials means "any credential for this RP"
const existing = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rpId,
allowCredentials: [],
userVerification: 'required',
},
});
if (existing) {
// Credential already provisioned — route to authentication instead
console.warn('[webauthn] discoverable credential already present; skipping registration');
return null;
}
} catch (err) {
// NotAllowedError here means the picker was dismissed or no credential found — safe to proceed
if ((err as DOMException).name !== 'NotAllowedError') throw err;
}
return navigator.credentials.create({ publicKey: publicKeyOptions });
}
Step 4 — Verify the RK flag in authenticatorData on the server
After registration, parse the authenticatorData CBOR structure. The flags byte is at offset 32 (0-indexed). Bit 6 (0x40) is the RK (resident-key / discoverable) flag:
import { decodeFirst } from 'cbor'; // npm install cbor
interface AuthenticatorFlags {
up: boolean; // bit 0 — user presence
uv: boolean; // bit 2 — user verification
be: boolean; // bit 3 — backup eligibility (CTAP2.2 / passkey sync)
bs: boolean; // bit 4 — backup state
at: boolean; // bit 6 in older maps; check spec offset carefully
rk: boolean; // bit 6 in authenticatorData flags — discoverable credential created
}
function parseAuthenticatorDataFlags(authData: Buffer): AuthenticatorFlags {
const flagsByte = authData[32]; // offset 32, single byte
return {
up: (flagsByte & 0x01) !== 0,
uv: (flagsByte & 0x04) !== 0,
be: (flagsByte & 0x08) !== 0,
bs: (flagsByte & 0x10) !== 0,
at: (flagsByte & 0x40) !== 0, // attested credential data included
rk: (flagsByte & 0x40) !== 0, // same bit position — context disambiguates
};
}
Note on bit 6: The WebAuthn spec uses bit 6 (
AT, attested credential data) inauthenticatorDatafor registration responses. The RK flag as a storage confirmation is inferred from whether the authenticator actually stored the credential, which CTAP2.1 communicates via therkmember of themakeCredentialresponse (not directly inauthenticatorDataflags). Validate discoverable storage by querying the authenticator viagetInfoor by inspectingauthenticatorData.ATalongside checkingresidentKeyin the response.
Step 5 — Route assertions correctly depending on credential type
// Discoverable flow — omit allowCredentials entirely
const discoverableGetOptions: PublicKeyCredentialRequestOptionsJSON = {
challenge: newChallenge(),
rpId,
allowCredentials: [], // browser enumerates all stored credentials for this RP
userVerification: 'required',
};
// Server-side (non-discoverable) flow — must hint the credential ID
const serverSideGetOptions: PublicKeyCredentialRequestOptionsJSON = {
challenge: newChallenge(),
rpId,
allowCredentials: [
{ type: 'public-key', id: credentialIdFromDatabase, transports: ['usb', 'nfc', 'ble'] },
],
userVerification: 'required',
};
The transports hint reduces authenticator enumeration latency — include the transports recorded at registration time (available from PublicKeyCredential.response.getTransports() on the registration response).
Verification and Testing
After deploying a residentKey policy change, confirm correct behaviour with these checks:
OpenSSL one-liner to decode authenticatorData flags from a base64url-encoded registration response:
# Replace <BASE64URL> with the authData field from the attestation response
echo '<BASE64URL>' | base64 -d | xxd -s 32 -l 1
# Byte at offset 32 is the flags byte; 0x45 = UP+UV+AT set; 0x05 = UP+UV only
Pitfalls
1. Silent downgrade with residentKey: 'preferred' on CTAP2.0 hardware
When residentKey: 'preferred' is used with a CTAP2.0 authenticator that has the rk extension disabled, registration silently produces a non-discoverable credential. The browser returns no error. Your challenge-response flow then breaks on conditional-mediation assertion because the authenticator cannot enumerate the credential. Always verify the flags byte server-side and record the credential’s discoverability in your database.
2. Mixing discoverable and non-discoverable credentials for the same user.id
If a user registers once with residentKey: 'required' and once with residentKey: 'discouraged' (e.g. on different devices or after a firmware downgrade), you will have a heterogeneous credential set under the same user.id. A conditional-mediation assertion returns only the discoverable credential; a server-side assertion with allowCredentials returns only the non-discoverable one. Store a is_discoverable column per credential in your credentials table and route assertion type accordingly.
3. Using requireResidentKey: true instead of residentKey: 'required'
The legacy requireResidentKey boolean was superseded in WebAuthn Level 2. Some platforms ignore it or treat it inconsistently. In particular, requireResidentKey: true does not trigger the CTAP2.1 rk enforcement path on all authenticators. Always use residentKey: 'required' in new code, as specified in WebAuthn § 5.4.4.
Related
- Public Key vs Symmetric Credential Types — parent cluster: COSE key encoding, asymmetric vs HMAC credential models, and storage architecture
- The Challenge-Response Authentication Flow — how
allowCredentialsrouting anduserHandleverification fit into the full assertion pipeline - Relying Party and Authenticator Roles — RP ID scoping, CTAP2 message flow, and trust model partitioning that govern credential storage boundaries
- Cryptographic Algorithms Supported by WebAuthn — COSE algorithm identifiers (
-7,-8,-257) used inpubKeyCredParamsfor discoverable credential registration