bitwarden/server · error · BadRequestException

Unable to complete WebAuthn registration.

Error message

Unable to complete WebAuthn registration.

What it means

BadRequestException 'Unable to complete WebAuthn registration.' is thrown in PUT /webauthn (PutWebAuthn) when _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync returns false. The command wraps fido2-net-lib attestation verification; false indicates the device response failed to verify against the stored challenge/options.

Source

Thrown at src/Api/Auth/Controllers/TwoFactorController.cs:361

    [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly
    public async Task<TwoFactorWebAuthnChallengeResponseModel> GetWebAuthnChallenge(
        [FromBody] TwoFactorWebAuthnChallengeRequestModel model)
    {
        var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn);
        var options = await _startTwoFactorWebAuthnRegistrationCommand.StartTwoFactorWebAuthnRegistrationAsync(user);
        return new TwoFactorWebAuthnChallengeResponseModel { Options = options };
    }

    [HttpPut("webauthn")]
    public async Task<TwoFactorWebAuthnUpdateResponseModel> PutWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model)
    {
        var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn);

        var success = await _completeTwoFactorWebAuthnRegistrationCommand.CompleteTwoFactorWebAuthnRegistrationAsync(
            user, model.Id.Value, model.Name, model.DeviceResponse);
        if (!success)
        {
            throw new BadRequestException("Unable to complete WebAuthn registration.");
        }

        return new TwoFactorWebAuthnUpdateResponseModel(user);
    }

    [HttpPost("webauthn")]
    [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")]
    public async Task<TwoFactorWebAuthnUpdateResponseModel> PostWebAuthn([FromBody] TwoFactorWebAuthnUpdateRequestModel model)
    {
        return await PutWebAuthn(model);
    }

    [HttpDelete("webauthn")]
    public async Task<TwoFactorWebAuthnDeleteResponseModel> DeleteWebAuthn(
        [FromBody] TwoFactorWebAuthnDeleteRequestModel model)
    {
        var user = await ValidateUserVerificationTokenAsync(model.UserVerificationToken, TwoFactorProviderType.WebAuthn);

View on GitHub (pinned to e93b962371)

Solutions

  1. Start a fresh challenge via the start-registration endpoint and submit the device response from the same flow.
  2. Ensure the calling origin/RP id matches the server's WebAuthn/fido2 configuration.
  3. Confirm the authenticator supports the requested parameters and the user actually completed the ceremony.
  4. Pass model.Id (the pending challenge id) and model.DeviceResponse exactly as returned by the authenticator.

Example fix

// before: stale challenge id
api.put('/users/two-factor/webauthn', { id: oldChallengeId, deviceResponse })
// after: start then immediately complete
const { id, options } = await api.post('/users/two-factor/webauthn/challenge');
const deviceResponse = await navigator.credentials.create({ publicKey: options });
api.put('/users/two-factor/webauthn', { id, name, deviceResponse });
Defensive patterns

Strategy: validation

Validate before calling

if (!model.id || !model.deviceResponse) throw new Error('Missing challenge id or device response');

Type guard

function isCompleteWebAuthnModel(m): m is WebAuthnUpdateModel { return !!m?.id && !!m?.deviceResponse && typeof m.userVerificationToken === 'string'; }

Try / catch

try { await api.put('/users/two-factor/webauthn', model); }
catch (e) {
  if (e.response?.status === 400 && /WebAuthn registration/.test(e.response.data?.message)) {
    const challenge = await api.post('/users/two-factor/webauthn/challenge');
    throw new RetryableError('Restart the WebAuthn flow with a fresh challenge', challenge);
  }
  throw e;
}

Prevention

When it happens

Trigger: PUT /api/users/two-factor/webauthn (TwoFactorController line 361) where model.DeviceResponse fails attestation/challenge verification, or the challenge (model.Id) does not match a pending registration started by the get/challenge endpoint.

Common situations: Challenge expired, the DeviceResponse was generated against a different challenge/origin/RP id, a non-passkey-capable authenticator, clock/origin mismatch (origin must match the RP id config), or tampered/truncated device response.

Related errors


AI-assisted analysis of bitwarden/server@e93b962371 (2026-08-13). Data as JSON: /api/errors/86cfec490420fee1. Report an issue: GitHub.