zeroclaw-labs/zeroclaw · error

Credential ID not in allowed list

Error message

Credential ID not in allowed list

What it means

finish_authentication first checks the response's credential ID against allowed_credentials captured in the AuthenticationState at start_authentication time. An ID outside that list means the client authenticated with a credential that was not offered for this ceremony — a different passkey, a stale state, or a modified ID.

Source

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

        let state = AuthenticationState {
            challenge,
            user_id: user_id.into(),
            allowed_credentials: allowed_ids,
        };

        Ok((request, state))
    }

    /// Complete a WebAuthn authentication ceremony.
    /// Validates the assertion signature against the stored public key
    /// and updates the sign counter for clone detection.
    pub fn finish_authentication(
        &self,
        auth_state: &AuthenticationState,
        response: &AuthenticateCredentialResponse,
    ) -> Result<()> {
        // 1. Verify credential ID is in allowed list
        anyhow::ensure!(
            auth_state.allowed_credentials.contains(&response.id),
            "Credential ID not in allowed list"
        );

        // 2. Load the credential
        let mut all_credentials = self.load_all_credentials()?;
        let credential = all_credentials
            .values()
            .flatten()
            .find(|c| c.credential_id == response.id)
            .cloned()
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"credential_id": response.id})),
                    "webauthn verify refused: credential id not in store"

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Restart authentication (new start) and let the client sign with one of the freshly offered credentials
  2. Ensure response.id is sent back exactly as delivered in allowCredentials (same base64url string)
  3. Never reuse an auth_state across ceremonies or sessions; store it alongside the current challenge
Defensive patterns

Strategy: validation

Validate before calling

// server-side pre-check before finish_authentication
let allowed: &[String] = &auth_state.allowed_credentials;
if !allowed.contains(&response.id) {
    // restart the ceremony instead of calling finish_authentication
    return Err(anyhow::anyhow!("credential not offered; call auth start again"));
}

Try / catch

on this error return HTTP 400 with a 'restart-authentication' code; client must call start again — never silently retry the same finish

Prevention

When it happens

Trigger: The browser/auto-fill picks a different passkey than one of the offered ones (e.g. cross-device flow or multiple resident keys); replaying a finish request against an auth_state from a different start; credential re-registered while an old state was cached; ID encoding mismatch between what start returned and what the client echoes.

Common situations: Stale SPA state after the user re-registers; multiple devices syncing passkeys; a finish endpoint hit twice with mismatched sessions; clients that re-encode the credential ID.

Related errors


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