zeroclaw-labs/zeroclaw · error

Challenge mismatch in registration response

Error message

Challenge mismatch in registration response

What it means

finish_registration compares the challenge inside client_data_json against the challenge stored in the pending AuthenticationState/registration state created by register start. A mismatch means the response was not produced for this ceremony instance — expired state, a second start overwriting the first, or encoding differences.

Source

Thrown at crates/zeroclaw-runtime/src/security/webauthn.rs:283

        response: &RegisterCredentialResponse,
    ) -> Result<WebAuthnCredential> {
        // 1. Validate client data JSON
        let client_data_bytes = URL_SAFE_NO_PAD
            .decode(&response.client_data_json)
            .context("Invalid base64url in client_data_json")?;
        let client_data: serde_json::Value =
            serde_json::from_slice(&client_data_bytes).context("Invalid client data JSON")?;

        // Verify type
        let cd_type = client_data["type"].as_str().unwrap_or_default();
        anyhow::ensure!(
            cd_type == "webauthn.create",
            "Expected type 'webauthn.create', got '{cd_type}'"
        );

        // Verify challenge matches
        let cd_challenge = client_data["challenge"].as_str().unwrap_or_default();
        anyhow::ensure!(
            cd_challenge == reg_state.challenge,
            "Challenge mismatch in registration response"
        );

        // Verify origin
        let cd_origin = client_data["origin"].as_str().unwrap_or_default();
        anyhow::ensure!(
            cd_origin == self.config.rp_origin,
            "Origin mismatch: expected '{}', got '{cd_origin}'",
            self.config.rp_origin
        );

        // 2. Parse attestation object to extract public key and auth data
        let attestation_bytes = URL_SAFE_NO_PAD
            .decode(&response.attestation_object)
            .context("Invalid base64url in attestation_object")?;

        // For "none" attestation, we extract the authData which contains the

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Restart the ceremony: call register start, then immediately finish with the fresh challenge
  2. Ensure exactly one start per registration attempt and that finish uses the options from that same start
  3. Compare challenges using the identical base64url (no padding) encoding on both client and server

Example fix

// before: stale options reused after a re-render
const regOptions = cachedFromLastPageLoad;
// after: fetch fresh options, then create the credential
const regOptions = await fetch('/register/start').then(r => r.json());
Defensive patterns

Strategy: validation

Validate before calling

// client: create the credential with the exact challenge bytes from the latest start call
const opts = await fetch('/register/start', { cache: 'no-store' }).then(r => r.json());
const cred = await navigator.credentials.create({ publicKey: opts });
// server: confirm state freshness
if reg_state.created_at + TTL < now { /* re-run start instead of finish */ }

Try / catch

catch the mismatch; respond 400 with a 'restart-registration' code so the client calls start again, and invalidate the stored reg_state

Prevention

When it happens

Trigger: Calling register start twice and finishing with options from the first call; the browser session restarting so server-side state was regenerated; encoding mismatches (raw vs base64url, padded vs unpadded challenge strings); replaying a captured finish payload.

Common situations: SPA flows that refetch registration options on re-render; multiple tabs each calling start; server storing one pending challenge per user that gets clobbered; clients re-encoding the challenge before hashing.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/91dfdc34b045b8da. Report an issue: GitHub.