we-promise/sure · error · ActionController::BadRequest

credential must be an object

Error message

credential must be an object

What it means

WebAuthn registration/authentication sends the browser's credential response as the credential param. The concern's webauthn_credential_payload helper accepts it as JSON (string) or as nested params, normalizes ActionController::Parameters via to_unsafe_h, and then requires the result to be a Hash — because WebAuthn::Credential.from_json needs a JSON object with fields like id/rawId/type/response. If the parsed payload is an Array, String, number, or nil, it raises ActionController::BadRequest (HTTP 400) with "credential must be an object".

Source

Thrown at app/controllers/concerns/webauthn_relying_party.rb:25

    def webauthn_relying_party
      webauthn_config = Rails.application.config.x.webauthn

      WebAuthn::RelyingParty.new(
        name: "Sure",
        id: webauthn_config.rp_id,
        allowed_origins: webauthn_config.allowed_origins,
        # Accept consumer passkeys/security keys without attesting device vendor
        # identity; this keeps MFA registration broad for self-hosted users.
        verify_attestation_statement: false
      )
    end

    def webauthn_credential_payload
      payload = params.require(:credential)
      payload = JSON.parse(payload) if payload.is_a?(String)

      payload = payload.to_unsafe_h if payload.respond_to?(:to_unsafe_h)
      raise ActionController::BadRequest, "credential must be an object" unless payload.is_a?(Hash)

      payload
    rescue JSON::ParserError, TypeError, ArgumentError
      raise ActionController::BadRequest, "invalid credential payload"
    end
end

View on GitHub (pinned to e69894adb9)

Solutions

  1. Send the complete PublicKeyCredential object exactly as navigator.credentials.get()/create() returns it — JSON.stringify the whole response into the credential param, or nest its fields as credential[id], credential[rawId], credential[type], credential[response][…]
  2. Confirm the request Content-Type matches how you're sending it (application/json body vs form params) so Rails doesn't collapse it to a string
  3. In tests, pass a fixture of the real credential JSON object, not a placeholder string
  4. Reproduce the normalization: payload = JSON.parse(str); payload.is_a?(Hash) or raise

Example fix

# before (broken fetch)
fetch(url, { method: "POST", body: "credential=" + assertion.id })
# server: credential="Y3JlZA" -> parsed to a String -> 400 "credential must be an object"

# after
const res = await navigator.credentials.get({ publicKey: options });
fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ credential: JSON.stringify(res) }) // full object, parses to Hash
});
Defensive patterns

Strategy: validation

Validate before calling

# Client-side, before POSTing
function isCredentialObject(v) {
  if (typeof v === "string") { try { v = JSON.parse(v); } catch { return false; } }
  return v !== null && typeof v === "object" && !Array.isArray(v);
}
if (!isCredentialObject(credential)) throw new Error("credential must be a JSON object");

Type guard

function isCredentialObject(v) {
  if (typeof v === "string") { try { v = JSON.parse(v); } catch { return false; } }
  return v !== null && typeof v === "object" && !Array.isArray(v);
}

Try / catch

rescue ActionController::BadRequest
  # 400 already sent by Rails; log shape of params (never the credential secrets)
  # and prompt the client to re-send the full PublicKeyCredential object
  head :bad_request
end

Prevention

When it happens

Trigger: POSTing the credential param as a JSON string that parses to an array or scalar (e.g. credential='["abc"]' or credential='"abc"'); sending form-encoded params that make credential a bare string like credential=hello instead of nested fields (credential[id]=…); a client double-encoding so the outer parse yields a non-object; tests/fixtures that stub the param with a plain token instead of the full PublicKeyCredential JSON.

Common situations: Custom passkey UIs that build the request body by hand instead of using the serialization the frontend library (e.g. @simplewebauthn/browser) produces; fetch with a wrong Content-Type so Rails stringifies the body; load balancer or middleware mutating the body; copying a curl example from another app that sends the id only.

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/5d5537590bba5027. Report an issue: GitHub.