antiwork/gumroad · error · VerificationError

malformed_credential

malformed_credential

Error message

malformed_credential

What it means

Raised in `Logins::PasskeysController#assertion_params` (logins/passkeys_controller.rb:63): the credential arrived as an object, but its fields failed `valid_assertion_params?` — `type` must equal "public-key", `id`/`rawId` must be base64url-encoded strings, and `response` must be a hash whose `authenticatorData`, `clientDataJSON`, and `signature` are base64url (`userHandle` optional). Any deviation raises `VerificationError("malformed_credential")` → generic 422.

Source

Thrown at app/controllers/logins/passkeys_controller.rb:63

        webauthn_credential = WebAuthn::Credential.from_get(assertion_params)
        stored_credential = WebauthnCredential.find_by_webauthn_id(webauthn_credential.id)
        raise VerificationError, "unknown_credential" if stored_credential.nil?

        webauthn_credential.verify(
          challenge,
          public_key: stored_credential.public_key,
          sign_count: stored_credential.sign_count,
          user_verification: true
        )

        stored_credential.assign_attributes(sign_count: webauthn_credential.sign_count, last_used_at: Time.current)
        stored_credential
      end
    end

    def assertion_params
      permitted_params = permitted_credential_params(response: [:authenticatorData, :clientDataJSON, :signature, :userHandle])
      raise VerificationError, "malformed_credential" unless valid_assertion_params?(permitted_params)

      permitted_params
    end

    def valid_assertion_params?(permitted_params)
      response = permitted_params["response"]

      valid_credential_base?(permitted_params) &&
        base64url_encoded?(response["authenticatorData"]) &&
        base64url_encoded?(response["clientDataJSON"]) &&
        base64url_encoded?(response["signature"])
    end

    def log_authentication_failure(reason)
      log_ceremony_failure("authentication", reason)
    end
end

View on GitHub (pinned to afeacbd394)

Solutions

  1. Serialize exactly per the WebAuthn JSON convention: id/rawId and response fields as base64url (RFC 4648 §5, no +,/), type: "public-key".
  2. Use a maintained serializer (e.g. @github/webauthn-json style helpers) rather than hand-written encoding.
  3. Check every response field is a non-empty base64url string before POSTing; log which field fails in dev.
  4. Keep the JS bundle deployed atomically with the server params contract (the same lesson as the dispute-evidence singular/plural param note in this codebase).

Example fix

// before — rawId left as a byte array and wrong type
body: JSON.stringify({ credential: { id: cred.id, rawId: Array.from(new Uint8Array(cred.rawId)), type: "webauthn.get", response: { ... } } })

// after
const b64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
body: JSON.stringify({ credential: { id: cred.id, rawId: b64u(cred.rawId), type: "public-key", response: { authenticatorData: b64u(cred.response.authenticatorData), clientDataJSON: b64u(cred.response.clientDataJSON), signature: b64u(cred.response.signature), userHandle: cred.response.userHandle ? b64u(cred.response.userHandle) : undefined } } })
Defensive patterns

Strategy: validation

Validate before calling

// client, before POST
const b64url = (s) => typeof s === "string" && /^[A-Za-z0-9_-]+={0,2}$/.test(s);
const ok =
  cred.type === "public-key" &&
  b64url(cred.id) && b64url(cred.rawId) &&
  b64url(cred.response?.authenticatorData) &&
  b64url(cred.response?.clientDataJSON) &&
  b64url(cred.response?.signature);
if (ok) await submit(cred);

Type guard

const isBase64url = (v) => typeof v === "string" && /^[A-Za-z0-9_-]+={0,2}$/.test(v);
const isValidAssertion = (c) =>
  c.type === "public-key" &&
  isBase64url(c.id) && isBase64url(c.rawId) &&
  c.response instanceof Object &&
  isBase64url(c.response.authenticatorData) &&
  isBase64url(c.response.clientDataJSON) &&
  isBase64url(c.response.signature);

Try / catch

begin
  params = assertion_params
rescue VerificationError => e
  log_authentication_failure(e.reason) # reason=malformed_credential in logs
  render json: { success: false, error_message: AUTHENTICATION_ERROR_MESSAGE }, status: :unprocessable_entity
end

Prevention

When it happens

Trigger: Client sends `type` missing or not "public-key"; ArrayBuffer fields serialized as hex/plain objects instead of base64url; rawId sent as an array of bytes; response fields left as empty strings or omitted; a hand-rolled client re-encoding fields with standard base64 (+/) instead of base64url (-_) or padding errors.

Common situations: Custom serialization helpers in older JS bundles; polyfills that stringify ArrayBuffers differently; proxies/transformers that URL-decode and corrupt base64url characters; test fixtures with hardcoded non-base64url values.

Understand the failure class

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/7bff2d071a7c9cf3. Report an issue: GitHub.