Verifying Webhooks


Overview

This article shows:

  • What security features Remote provides for webhooks
  • How webhooks can be verified

Before you get started

Before you get started, you should be familiar with registering webhook callbacks with the Remote API.

Webhook Security Features

When you have an endpoint in your system that can accept requests from the outside world, it’s possible that bad actors will attempt to exploit this endpoint. This is a problem especially when such an endpoint accepts and modifies data in your system.

To overcome this problem, you need a way to verify requests that are made to this endpoint, such that you can differentiate bad actors from expected requests. The Remote API allows you to verify the authenticity of webhook requests by using cryptographically-signed signatures.

The Signing Key

When you register a webhook callback URL with the POST /v1/webhook-callbacks endpoint, the response will always include a signing_key. Here’s an example:

{
    "data": {
        "webhook_callback": {
            "id": "fbac1e8a-3f38-4e1a-acbe-f046761171bf",
            "signing_key": "wkyzvs764ifdrpct2naqhksmq4",
            "subscribed_events": [
                "employment.onboarding_task.completed"
            ],
            "url": "<Your URL>"
        }
    }
}

The signing_key returned from this endpoint is unique to this webhook callback. All requests made to the URL you registered (with the POST /v1/webhook-callbacks endpoint) will include a signature signed with the signing_key returned in the response for a call to POST /v1/webhook-callbacks.

⚠️ The signing key is a secret and should not be exposed outside your systems. Anyone with access to your signing key can send webhook requests to your webhook URL and make it appear legitimate.

If you suspect that the signing key has been leaked, make sure to delete the webhook callback and register the same URL again to obtain a different signing key.

You will need this signing_key to re-create the signature on your end.

Webhook Signature and Timestamp

When you receive a webhook request from Remote, it will include 2 custom HTTP headers:

  • X-Remote-Timestamp
  • X-Remote-Signature

The Webhook Timestamp

The webhook timestamp is a Unix timestamp of millisecond-precision. Remote uses it to generate part of the signature sent in X-Remote-Signature. This timestamp represents the first time Remote makes an attempt to send the webhook request. Once you verify the signature in X-Remote-Signature, you can use this timestamp to ignore old requests.

ℹ️ Remote retries sending webhook requests if the recipient (i.e. the URL you provided for when registering the webhook callback) responds with `4xx` or `5xx`. In such cases, an old, retried webhook request will have an old timestamp, while a new webhook request for the same type of event will have a newer timestamp.

Having access to the timestamp in this case will allow you to ignore an old, retried webhook request.

The Webhook Signature

The webhook signature is a cryptographically-signed hash that is unique to every webhook request. Remote uses the following to generate the signature:

  • A signing key, as described in the chapter “The Signing key”
  • The unformatted, raw webhook request body
  • The timestamp included sent in the X-Remote-Timestamp header

Remote uses the Hash-based Message Authentication Code (HMAC) cryptographic technique to create the signatures, with SHA256 as the hash function. The signature included in the X-Remote-Signature header is Base 16 encoded.

Here’s an example of the X-Remote-Signature header:

X-Remote-Signature: e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7

Using the signature to verify authenticity

The HMAC cryptographic technique generates a one-way hash, so you cannot decrypt it to get its contents. Instead, you will need to generate the signature on your end and ensure that the signature you generate matches what Remote sent in the X-Remote-Signature header.

Collecting the required data to generate the signature

For this example, assume that you will be using the response sent in the example above, demonstrating the response from POST /v1/webhook-callbacks. From this response, you will need the signing_key:

wkyzvs764ifdrpct2naqhksmq4

Next, you will need the unformatted, raw request body from the webhook request you receive. For example, if you received a webhook request with the following body:

{
    "company_id": "9a88cdac-4e57-46ca-afa8-580a50931cd8",
    "completed_task": {
        "action": "identity_verification",
        "completed_at": "2023-02-16T07:52:26Z",
        "description": "To help us keep you and our platform safe.",
        "name": "Verify your identity",
        "required": true,
        "status": "completed"
    },
    "employment_id": "b6e76f7c-9126-4afa-9f43-37bbc1d8e509",
    "event_type": "employment.onboarding_task.completed"
}

Then, with the headers, the raw request would look like this:

POST / HTTP/1.1
Content-Length: 376
Content-Type: application/json
X-Remote-Signature: e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7
X-Remote-Timestamp: 1677816097219

{"company_id":"9e88cdac-4e57-46ca-a5a8-580150935cd8","completed_task":{"action":"identity_verification","completed_at":"2023-02-16T07:52:26Z","description":"To help us keep you and our platform safe.","name":"Verify your identity","required":true,"status":"completed"},"employment_id":"b6e66f7c-9026-4afc-9f43-37bb31a8e509","event_type":"employment.onboarding_task.completed"}

From this webhook request, you would need the following:

  • The raw request body:
{"company_id":"9e88cdac-4e57-46ca-a5a8-580150935cd8","completed_task":{"action":"identity_verification","completed_at":"2023-02-16T07:52:26Z","description":"To help us keep you and our platform safe.","name":"Verify your identity","required":true,"status":"completed"},"employment_id":"b6e66f7c-9026-4afc-9f43-37bb31a8e509","event_type":"employment.onboarding_task.completed"}
ℹ️ It’s important that the raw request is unformatted. Otherwise, you won’t get the same signature as the one Remote sends you.

One way to ensure you’re using the raw request body is to compare its length to the Content-Length header. If they match, then you have the right data for the signature.

  • The X-Remote-Timestamp: 1677816097219
  • The X-Remote-Signature:
e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7

Now that you have all the required data, you can generate the signature.

Generating and comparing the signature

You can generate the signature and compare it to the one you receive in the X-Remote-Signature header in any programming language that has HMAC libraries. This guide will show you how to do it in Python, using the standard Python libraries.

First, assign all the data you need to variables:

# Assigned to your webhook callback during registration
signing_key = "wkyzvs764ifdrpct2naqhksmq4"

# From the X-Remote-Timestamp header
timestamp = "1677816097219"

# From the webhook request you receive
raw_request_body = '{"company_id":"9e88cdac-4e57-46ca-a5a8-580150935cd8","completed_task":{"action":"identity_verification","completed_at":"2023-02-16T07:52:26Z","description":"To help us keep you and our platform safe.","name":"Verify your identity","required":true,"status":"completed"},"employment_id":"b6e66f7c-9026-4afc-9f43-37bb31a8e509","event_type":"employment.onboarding_task.completed"}'

# From the X-Remote-Signature
received_signature = "e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7"

Next, build the message that you will create the signature from:

message = raw_request_body + ":" + timestamp

# The message looks like {"company_id":"..."}:1677816097219
ℹ️ The format of the message should be as shown here, regardless of the programming language you use. Don’t forget to add the colon (`:`) between the two variables!

Next, you can create the HMAC object using the HMAC library provided as part of standard Python libraries:

import hmac

hmac_object = hmac.new(signing_key.encode(), message.encode(), hashlib.sha256)
ℹ️ Notice the `.encode()` calls on `signing_key` and `message`. In Python, we have to convert the Strings into Byte-arrays before passing it into `hmac.new`. The programming language you use may not require you to do so.

Finally, you can use this HMAC object to generate the signature:

import base64

signature_byte_array = hmac_object.digest()
# signature_byte_array == b'\xe3\xf4\t/\x15\x89\x83\xae\xa3*\xb2_o\xec\xc5\x9fd\xb2mE\xfa\xdb\xedd\t\x89?:\x88*\xbe\xf7'

base16_encoded_signature = base64.b16encode(signature_byte_array)
# base16_encoded_signature == b'E3F4092F158983AEA32AB25F6FECC59F64B26D45FADBED6409893F3A882ABEF7'

generated_signature = base16_encoded_signature.decode().lower()
# generated_signature == "e3f4092f158983aea32ab25f6fecc59f64b26d45fadbed6409893f3a882abef7"

if hmac.compare_digest(received_signature, generated_signature):
	# Accept webhook request
else:
  # Reject webhook request
⚠️ Python recommends using the `[compare_digest()](https://docs.python.org/3/library/hmac.html#hmac.compare_digest)` function instead of the `==` operator to reduce the vulnerability to timing attacks. You can read more about this kind of timing attack [here](https://codahale.com/a-lesson-in-timing-attacks/).

Note that this vulnerability is not unique to Python and can happen in any programming language where you use == for comparison.

ℹ️ The `base16_encoded_signature` needs to be decoded because it's a Byte-array. Alternatively, you could convert the `received_signature` to a Byte-array and then compare it to `base16_encoded_signature` instead.

Regardless of your comparison method, remember that X-Remote-Signature is Base-16 encoded.

Since the received_signature matches generated_signature in this case, you have successfully authenticated the webhook request as having originated from Remote!