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
- 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][…]
- 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
- In tests, pass a fixture of the real credential JSON object, not a placeholder string
- 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
- Use the official WebAuthn JS library's serialization; never build the payload by hand
- Always JSON.stringify the entire PublicKeyCredential from navigator.credentials
- Keep the request Content-Type consistent with the body format you send
- In tests, use recorded real credential JSON objects, never placeholder strings
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.