basecamp/fizzy · error · ActionPack::WebAuthn::InvalidResponseError

Client data is not valid JSON

Error message

Client data is not valid JSON

What it means

Authenticator::Response#client_data runs JSON.parse on the clientDataJSON string handed to AttestationResponse/AssertionResponse. JSON::ParserError is re-raised as InvalidResponseError. Note that the browser's clientDataJSON is plain UTF-8 JSON (not base64), so any wrapping or partial decoding of it on the server will produce this error.

Source

Thrown at lib/action_pack/web_authn/authenticator/response.rb:69

  end

  def validate!
    super
  rescue ActiveModel::ValidationError
    raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
  end

  # Returns the RelyingParty used for RP ID validation.
  def relying_party
    ActionPack::WebAuthn.relying_party
  end

  # Parses the client data JSON string into a Hash. Raises
  # +InvalidResponseError+ if the JSON is malformed.
  def client_data
    @client_data ||= JSON.parse(client_data_json)
  rescue JSON::ParserError
    raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
  end

  def authenticator_data
    nil
  end

  private
    def challenge_must_be_present
      if client_data["challenge"].blank?
        errors.add(:base, "Challenge missing")
      end
    end

    def challenge_must_not_be_expired
      return if errors.any?

      signed_message = Base64.urlsafe_decode64(client_data["challenge"])

View on GitHub (pinned to 7aabe74580)

Solutions

  1. Pass the browser's response.clientDataJSON string through verbatim — do not base64-decode it server-side.
  2. If your transport base64-encodes everything, decode that one field first: JSON.parse(Base64.urlsafe_decode64(value)).
  3. Pre-validate at the boundary with JSON.parse(value) and return 400 early so the failure names the field.
  4. Log the first 80 chars of the received value — HTML tags or base64 patterns identify the mangling source fast.

Example fix

# before
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
  client_data_json: params[:client_data_json] # arrives base64-encoded
)

# after — decode only if your client encoded it, then hand over clean JSON
json = params[:client_data_json]
json = Base64.urlsafe_decode64(json) if json.match?(/^[A-Za-z0-9_-]+={0,2}$/) && !json.strip.start_with?('{')
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(client_data_json: json)
Defensive patterns

Strategy: validation

Validate before calling

json = params[:client_data_json].to_s
begin
  JSON.parse(json)
rescue JSON::ParserError
  return render(json: { error: 'client_data_json must be raw JSON' }, status: :bad_request)
end

Type guard

def valid_client_data_json?(value)
  JSON.parse(value.to_s)
  true
rescue JSON::ParserError
  false
end

Try / catch

begin
  response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(client_data_json: json, **rest)
  response.validate!
rescue ActionPack::WebAuthn::InvalidResponseError => e
  render json: { error: e.message }, status: :bad_request
end

Prevention

When it happens

Trigger: Constructing a Response with client_data_json that is base64-encoded JSON, an empty string, an HTML error page captured by a proxy, a truncated body, or a Ruby-inspect artifact like \"{\"type\":...}\" from string interpolation.

Common situations: Teams that uniformly base64-encode all WebAuthn fields client-side and forget to decode clientDataJSON server-side; fetch wrappers that .text() a failed request and pass the error page along; truncation by middleware; test fixtures storing clientDataJSON already-decoded once.

Related errors


AI-assisted analysis of basecamp/fizzy@7aabe74580 (2026-08-21). Data as JSON: /api/errors/240b21d249954de3. Report an issue: GitHub.