HubSpot: Validate v3 Request Signature

Hello, everybody!
I am having a really difficult time validating requests from HubSpot using the v3 request signature.
Here is the HubSpot documentation:

Basically, it says:

  1. Create a UTF-8-encoded string that concatenates requestMethod + requestURI + requestBody + timestamp
  2. Create an HMAC SHA-256 hash of the resulting string using the application secret for the HMAC function.
  3. Base64 encode the result of the HMAC function
  4. Compare the hash value to the “x-hubspot-signature-v3” header. If the values are equal, this request is valid.

Assumptions:

  • I have tried using just the path, but I think requestURI should take the form “https://www.mysite.com/path/to/endpoint”.
  • “application secret” === dev account > app > “Auth” tab > “Client secret”.

I would like to benefit from the experience of somebody here who has already dealt with this and can show me what I’m doing wrong. Barring that, I invite you to look at my code, spot errors, suggest improvements, etc., and thank you so much in advance!

Here’s my code:

uri = "#{scheme}://#{host}#{args["req_path"]}"

body = params |> Jason.encode!()

# Create a string that concatenates together the following:
# Request Method + Request URI + Request Body + Timestamp
val_str = Enum.join([method, uri, body, timestamp], "")

# Create a SHA-256 hash of the resulting string.
hash =
      :crypto.mac(:hmac, :sha256, secret, val_str)
      |> Base.encode64()

 # Compare the hash value to the signature.
 x_hubspot_signature == hash

And here’s what my code produces:

X HUBSPOT v3 SIGNATURE: "++IdcWvpfaPQ85JZ3LIYR9YqAm0J2Zy1H5kD6tAgtY0="  

VALIDATION STRING: "POSThttps://my_domain.com/api/v1/hs_send_msg{\"callbackId\":\"ap-xxxxxxxx-xxxxxxxxxxxxx-9-0\",\"context\":{\"source\":\"WORKFLOWS\",\"workflowId\":xxxxxxxxx},\"fields\":{\"Contact's Mobile Number\":\"+12345678910\",\"Message\":\"Some test string\",\"Org\":\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - stag\"},\"inputFields\":{\"Contact's Mobile Number\":\"+12345678910\",\"Message\":\"Some test string\",\"Org\":\"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - stag\"},\"object\":{\"objectId\":xxxxxxx,\"objectType\":\"CONTACT\"},\"origin\":{\"actionDefinitionId\":12345678,\"actionDefinitionVersion\":1,\"actionExecutionIndexIdentifier\":{\"actionExecutionIndex\":0,\"enrollmentId\":1234567891011},\"extensionDefinitionId\":xxxxxxxx,\"extensionDefinitionVersionId\":1,\"portalId\":xxxxxxxx}}1728041677019"

HASH: "g3wm5qs11huc08xdtp1iy+v97n5dvmx5qqcm83js5y8="

I realize you can’t actually test this, but if you can see what I’m doing wrong, that would be so helpful!

Again, thanks in advance!

1 Like

My guess is this is the problem. Since maps don’t guarantee order in Elixir, the result of Jason.encode!() may not give the same value that Hubspot used when calculating the signature. So instead of using the params to derive the body, you need to use Plug.Conn.read_body/1 to read the raw body, and if you’re using Phoenix you need to do it before Plug.Parsers reads the body.

Here’s a plug that is able to successfully verify Hubspot webhooks in my testing:

defmodule MyAppWeb.HubspotWebhookPlug do
  require Logger

  @max_allowed_delay_ms :timer.minutes(5)

  def init(_), do: []

  def call(%{path_info: ["hubspot_webhook"]} = conn, _opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn)
    handle_webhook(conn, body)

    conn
    |> Plug.Conn.send_resp(204, "")
    |> Plug.Conn.halt()
  end

  def call(conn, _opts), do: conn

  def handle_webhook(conn, body) do
    with {:ok, {signature, timestamp}} <- fetch_signature_and_timestamp(conn),
         {:ok, _} <- validate_timestamp(timestamp),
         {:ok, _} <- validate_signature(conn, body, timestamp, signature) do
      # Process webhook here if needed
      Logger.info("Signature matches! Request is valid.")
    else
      {:error, reason} ->
        Logger.warning("Hubspot webhook validation failed: #{reason}")
    end
  end

  defp fetch_signature_and_timestamp(conn) do
    case Plug.Conn.get_req_header(conn, "x-hubspot-signature-v3") do
      [signature | _] ->
        case Plug.Conn.get_req_header(conn, "x-hubspot-request-timestamp") do
          [timestamp | _] ->
            {:ok, {signature, timestamp}}

          _ ->
            {:error, "Missing timestamp"}
        end

      _ ->
        {:error, "Missing signature"}
    end
  end

  defp validate_timestamp(timestamp) when is_binary(timestamp) do
    case Integer.parse(timestamp) do
      {timestamp_int, _} ->
        current_time = :os.system_time(:millisecond)

        if current_time - timestamp_int > @max_allowed_delay_ms do
          {:error, "Timestamp is too old"}
        else
          {:ok, timestamp_int}
        end

      _ ->
        {:error, "Invalid timestamp format: #{inspect(timestamp)}"}
    end
  end

  defp validate_signature(conn, body, timestamp, signature) when is_binary(signature) do
    uri = "https://#{conn.host}#{conn.request_path}"

    raw_string = Enum.join([conn.method, uri, body, timestamp], "")

    computed_signature =
      :crypto.mac(:hmac, :sha256, client_secret(), raw_string)
      |> Base.encode64()

    # Compare signatures using constant-time comparison to prevent timing attacks
    if Plug.Crypto.secure_compare(computed_signature, signature) do
      {:ok, computed_signature}
    else
      {:error, "Signature does not match"}
    end
  end

  defp client_secret do
    Application.fetch_env!(:my_app, :hubspot_client_secret)
  end
end

Also here’s a link to the docs for reference: HubSpot Developer Documentation

Also, one note for others. The client secret is what you should use to validate the signature. NOT the API key (don’t ask me how I know :see_no_evil:)