zeroclaw-labs/zeroclaw · error

Challenge mismatch in authentication response

Error message

Challenge mismatch in authentication response

What it means

finish_authentication compares the challenge in client_data_json against the challenge stored in the AuthenticationState issued by auth start. A mismatch means the assertion was not generated for this ceremony — stale state, a newer start overwriting the challenge, or inconsistent encoding.

Source

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

                );
                anyhow::Error::msg(format!("Credential not found: {}", response.id))
            })?;

        // 3. 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")?;

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

        let cd_challenge = client_data["challenge"].as_str().unwrap_or_default();
        anyhow::ensure!(
            cd_challenge == auth_state.challenge,
            "Challenge mismatch in authentication response"
        );

        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
        );

        // 4. Verify signature
        let auth_data_bytes = URL_SAFE_NO_PAD
            .decode(&response.authenticator_data)
            .context("Invalid base64url in authenticator_data")?;
        let new_count =
            validate_assertion_authenticator_data(&auth_data_bytes, &self.config.rp_id)?;

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Restart authentication and finish with the assertion built from that same start's options
  2. Bind the auth_state to the session that requested it and discard it once used or expired
  3. Use one consistent base64url encoding for challenges on both sides
Defensive patterns

Strategy: validation

Validate before calling

// client: always get fresh options, never cache auth options across re-renders
const opts = await fetch('/auth/start', { cache: 'no-store' }).then(r => r.json());
const assertion = await navigator.credentials.get({ publicKey: opts });

Try / catch

catch the mismatch, invalidate the stored auth_state, respond 400 with 'restart-authentication'; the client calls start again

Prevention

When it happens

Trigger: Calling auth start twice (two tabs, re-render) and finishing against the first state; the auth_state persisted client-side going stale; challenge compared with different base64url padding; replaying a captured assertion.

Common situations: SPAs refetching auth options on component mount; multiple login attempts racing; server keeping one challenge per session that gets overwritten; tests reusing a fixture assertion across runs.

Understand the failure class

Related errors


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