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
- Restart authentication and finish with the assertion built from that same start's options
- Bind the auth_state to the session that requested it and discard it once used or expired
- 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
- Bind each challenge to one session and consume it on first finish (single-use)
- Expire pending auth states with a TTL
- In tests, mint a fresh challenge per assertion
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
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Challenge mismatch in registration response
- Credential ID not in allowed list
- Expected type 'webauthn.create', got '{cd_type}'
- No registered credentials for user '{user_id}'
- Expected type 'webauthn.get', got '{cd_type}'
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/73c55c589ca9d876.
Report an issue: GitHub.