Verifying Webhooks

Because of the way webhooks work, attackers can impersonate services by simply sending a fake webhook to an endpoint. It’s just an HTTP POST from an unknown source. This is a potential security hole for many applications, or at the very least, a source of problems. In order to prevent it, every webhook and its metadata are signed with a unique key for each endpoint. This signature can then be used to verify the webhook, and only process it if it’s valid.

Another potential security hole is what’s called replay attacks. A replay attack is when an attacker intercepts a valid payload (including the signature), and re-transmits it to your endpoint. This payload will pass signature validation, and will therefore be acted upon. To mitigate this attack, a timestamp for when the webhook attempt occurred is included. Webhooks with a timestamp that are more than five minutes away (past or future) from the current time are automatically rejected. This requires your server’s clock to be synchronised and accurate, and it’s recommended that you use NTP to achieve this.

Here is a short example in Ruby of how to verify signatures to get you started.

require 'rack'
require 'rack/handler/puma'
require 'json'
require 'logger'
require 'openssl'
require 'base64'

# In the portal, you can retrieve the secret for your endpoint once it's created
secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";

class VerifyWebhook
  def initialize(secret)
    @secret = secret
  end

  def call(env)
    request = Rack::Request.new(env)
    body = request.body.read

    if valid_signature?(request, body)
      # Process the webhook as you'd like
      return [200, {'content-type' => 'application/json'}, [{ message: "Webhook Received" }.to_json]]
    else
      # Handle and return the error accordingly
      return [403, {'content-type' => 'text/plain'}, ['Forbidden']]
    end
  end

  private

  def valid_signature?(request, body)
    webhook_id = request.env['HTTP_SVIX_ID']
    webhook_timestamp = request.env['HTTP_SVIX_TIMESTAMP']
    received_signatures = request.env['HTTP_SVIX_SIGNATURE'].split(' ')

    signed_content = "#{webhook_id}.#{webhook_timestamp}.#{body}"
    expected_signature = compute_signature(signed_content)

    received_signatures.any? do |sig|
      version, signature = sig.split(',')
      secure_compare(signature, expected_signature)
    end
  end

  def compute_signature(data)
    # Webhooks are signed using an HMAC with SHA-256
    key = Base64.decode64(@secret.split('_')[1])
    digest = OpenSSL::Digest.new('sha256')
    hmac = OpenSSL::HMAC.digest(digest, key, data)
    Base64.strict_encode64(hmac)
  end

  def secure_compare(a, b)
    # This method is used to mitigate timing attacks
    return false unless a.bytesize == b.bytesize

    l = a.unpack "C#{a.bytesize}"
    res = 0
    b.each_byte { |byte| res |= byte ^ l.shift }
    res.zero?
  end
end

Rack::Handler::Puma.run VerifyWebhook.new(secret), Port: 4567

Firewalls (IP blocking)

In case your webhook receiving endpoint is behind a firewall or NAT, you may need to allow traffic from these IP addresses. This is the US list of IP addresses that webhooks may originate from.

US

44.228.126.217
50.112.21.217
52.24.126.164
54.148.139.208
2600:1f24:64:8000::/52