Webhook Security Vulnerabilities Guide

Working with webhooks exposes an HTTP endpoint that can be called from any actor on your server. Without appropriate measures, this could be extremely unsafe. However, there are now well-understood strategies that ensure your webhook endpoints are secured.

Click here for a more general guide on webhook best practices. There are 3 main vectors of attacks that you need to watch out and for and protect yourself against.

Man-in-the-middle Attack

A man-in-the-middle attack is a vulnerability where a third party obtains access to your webhook data by capturing and reading the request. It's essential that you only work with HTTPS URLs (using SSL) when working with sensitive data. Some providers such as Shopify will enforce this restriction, but many platforms will let you input unencrypted URLs. By using HTTPS, the content of the request is encrypted and can't be read or used by anyone that intercepts the requests.

While technically not a man-in-the-middle attack, it's also possible to provide a URL that you do not own. The burden is on you to make sure the URL that you provide as your Webhook URL points to your own server that this server is also secure. Some platforms like Okta will perform a one-time verification to validate that you are indeed the owner of the URL and will not send any webhooks until that verification is performed. Otherwise, audit your server logs and configured webhook URLs to ensure they are pointing to the correct address.

Forged requests

A forged request is a request made to your webhook endpoint that acts as if it was sent from the original source (ex: Shopify), but contains forged data. It's critical to ensure that bad actors are not causing side effects in your systems by sending forged requests to your webhook endpoints. There are multiple strategies to verify that the request isn't coming from an impostor, signature verification being the most popular and secure approach.

Signature verification

Platforms that sign their webhooks can be verified by comparing a value found in the headers (ex: Shopify includes an X-Shopify-Hmac-Sha256 header) with a computed HMAC made up of a secret key and the content of your webhook payload. In generating a Hash-based message authentication code (HMAC), you are required to use your payload content along with the secret key that the platform provides you when you create a webhook subscription. If the header and your computed signature match, then you can be certain the message was originally sent from the expected provider.

This works because only you and the platform have access to that secret key and can compute an identical hash. By including the body of the payload within the hash, you can also guarantee that it has not been tempered. In event that this secret is leaked, you should replace it as soon as possible.

Different providers will use different hashing algorithms (SHA256, SHA128, MD5, etc.), and your implementation should use the same algorithm. Many platforms will also provide SDKs that include the verification logic to spare you most of the trouble.

Example of adding webhook verification for Shopify in Javascript

To get started with adding webhook verification to the server, run the following command:

$ npm install raw-body

The raw-body module we just installed would help us parse our incoming request body in a way that can be used to generate the hash. The default body-parser module that comes with Express could also be used in place of the raw-body package.

Next, Import the raw-body & crypto packages into the project by adding the following lines of code:

const getRawBody = require("raw-body");
const crypto = require("crypto");

The crypto module would be used to generate the hash from our request body and the secret key provided to us by Shopify.

Replace the app.post request handler created above with this

app.post("/webhook", async (req, res) => {
  //Extract X-Shopify-Hmac-Sha256 Header from the request
  const hmacHeader = req.get("X-Shopify-Hmac-Sha256");

  //Parse the request Body
  const body = await getRawBody(req);
  //Create a hash based on the parsed body
  const hash = crypto
    .createHmac("sha256", secret)
    .update(body, "utf8", "hex")
    .digest("base64");

  // Compare the created hash with the value of the X-Shopify-Hmac-Sha256 Header
  if (hash === hmacHeader) {
    console.log("Notification Requested from Shopify received");
    res.sendStatus(200);
  } else {
    console.log("There is something wrong with this webhook");
    res.sendStatus(403);
  }
});

In the code above, we extract the X-Shopify-Hmac-SHA256 HTTP header from the request, create a hash based on the Hmac-SHA256 algorithm from the request body then compare both hashes.

Lastly, go ahead and create a constant called secret which would hold the value of the secret Shopify returned to you when created a new webhook connection. You would want to store that as an environmental variable to ensure it is safe.

Secrets

Alternatively, you might find platforms with the option to add custom headers to the webhook requests, or a basic authentication header. In that case, you should generate a secret key on your own that you would set as a header and would verify on your end. This is similar to how most API authentication works and is considered secured when used over HTTPS. However, absolutely do not rely on this strategy when using standard HTTP, because any man-in-the-middle could steal your secret and forge authenticated requests

IP whitelisting

If the platform that's publishing webhooks provides a list of IPs that they send requests from, you can set up the receiving endpoint to only accept requests from those IPs. This is generally straightforward, but it depends on the platform that you're hosting on. While IP whitelisting is effective, it can also get cumbersome because you have to maintain the IP whitelist. If the provider adds an IP address, and you fail to update your list, you run the risk of potentially refusing payloads. For that reason, it should be used as a complement to other strategies listed above. Whitelisting IPs is not a requirement to protect yourself against forged requests.

Replay attacks

Even with the previous two vulnerabilities addressed, you are still susceptible to replay attacks. A bad actor could, in theory, intercept an encrypted request with signature verification and simply replay or make identical requests. While he wouldn't be able to see any of the data, nor alter it, he could still cause unintended side-effects such as creating multiple orders after a "Payment Captured" webhooks. This limits the possible consequences of this kind of attack, but nonetheless there are practical solutions to protect yourself against it.

Timestamped signatures

Some webhook providers, like Stripe, will include a timestamp within the signature. The timestamp used at the time of signing will be contained within the header and can be checked against a time window that you determine. Stripe SDK has a default tolerance of 5 minutes. That same timestamp must be appended to the signature before it's hashed.

Idempotency

Idempotency is a core concept when working with webhooks: if you have already ensured that your endpoints are idempotent, you won't be impacted by a replay attack. By making your endpoint idempotent you ensure that any webhooks are only processed once, even if received multiple times. That's an important case to handle regardless of replay attacks, since most (if not all) providers operate on an at least once delivery strategy. Timestamped signatures are unnecessary if idempotency is correctly implemented - idempotency is the only solution to protect yourself against attacks if your provider does not support timestamped signatures (and most don't).

Conclusion

Webhooks, when used correctly, are very secure. As with most things, the burden often falls on you to make sure you are properly verifying signatures, using HTTPS and implementing idempotency. Luckily, you're not alone, and the information is available