Protecting Webhooks in Laravel Applications From Replay Attacks

The Spatie packages for interacting with webhooks in Laravel applications (laravel-webhook-server and laravel-webhook-client) are a fantastic starting place for enabling applications to send and receive webhooks.

But there is one use case that isn't handled out-of-the-box which is very common: protection from replay attacks.

TL;DR: using a request header that contains a timestamp value in addition to the signature header and adjusting the construction and verification of the signature hash to include both the request body and timestamp value will protect your webhook sending and receiving applications from replay attacks.

Replay Attacks

Since a webhook is a publicly exposed route/endpoint that is called programmatically from another application some considerations need to be taken when setting up your applications to send or receive them. Some are protected using Bearer tokens and treated like an API endpoint, but a lot are not. In many cases, there is a signature that is sent (usually as a request header) with the request. A secret that is known to both of the applications is used to hash the request body and this "signature" hash is sent with the request.

But this does leave the possibility that the same request (same body, same signature hash) can be sent an infinite number of times. Because the application receiving the webhook will see each request as valid, the work done in response to the webhook can be triggered ad infinitum. The way that webhooks are received and handled using the Spatie package, a new job is dispatched with every valid webhook received.

This is known as a replay attack and can lead to a huge backlog of webhook processing jobs and waste a lot of resources and cause long delays.

The Solution

Luckily, there is a relatively easy fix for replay attacks. Because the main vulnerability that is exposed is because the request doesn't know at what time it was sent, the application sending the webhook should add a timestamp as a request header that is sent with the request and alter the signature sent as a header to be a hash of the request body AND the timestamp.

The application receiving the webhook should first check to see if the difference between the timestamp in the request header and the timestamp of when the webhook was received does not exceed a couple of minutes. Then, recreate the signature by concatenating the request body and the timestamp in the request header and hashing it using the application secret.

Let's walk through how we might alter the setup for an application to send a webhook to implement this workflow.

Application Sending the Webhook Setup

After you have followed the setup found in the README file of the spatie/laravel-webhook-server package, we will use the package to send a webhook.

In config/webhook-server.php, you will need to set a new config option timestamp_header_name to the name of the header that will be sent with the webhook request.

/*
 * This is the name of the header where the signature will be added.
 */
'signature_header_name' => 'X-Webhook-Signature',

+ /*
+  * The name of the header where the timestamp will be added.
+  */
+ 'timestamp_header_name' => 'X-Webhook-Timestamp',
+

Note: My usage of X-Webhook-Signature and X-Webhook-Timestamp as header names is simply because that's what I prefer to use. This is mostly an arbitrary choice as custom HTTP headers are a very common practice. I prefer to prefix my custom headers with X- as per convention, but this is not required.

Now, wherever in your application code you want to dispatch a webhook from you'll want to use the Spatie WebhookCall class like so:

use Illuminate\Support\Str;
use Spatie\WebhookServer\WebhookCall;

// this secret should match the secret originating with the receiving application
// using Str::random is only a suggestion to generate it
// it will need to be saved somewhere such as your .env file
$secret = 'gRVMep8n4ehD3wGn4GnnZDYWwooTFTwRrz6v7z8rfcFSH7L2Vswfw0MYlXCm';

$timestamp = now()->timestamp;
$payload = ['key' => 'value'];
$signature = hash_hmac('sha256', $timestamp.'|'.json_encode($payload), $secret);

WebhookCall::create()
   ->doNotSign()
   ->withHeaders([
      config('webhook-server.signature_header_name') => $signature,
      config('webhook-server.timestamp_header_name') => $timestamp,
   ])
   ->url('https://other-app.com/webhooks')
   ->payload($payload)
   ->dispatch();

That's it for the webhook-sending application. Onward!

Application Receiving the Webhook Setup

After you have followed the setup found in the README file of the spatie/laravel-webhook-client package, we will use the package to receive a webhook.

In config/webhook-client.php, you will need to set a new config option timestamp_header_name to the name of the header that will be sent with the webhook request.

You will also need to set the existing option signature_validator to a class in your application that implements Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator.

/*
 * The name of the header containing the signature.
 */
'signature_header_name' => 'X-Webhook-Signature',

+ /*
+  * The name of the header containing the timestamp.
+  */
+ 'timestamp_header_name' => 'X-Webhook-Timestamp',
+
/*
 *  This class will verify that the content of the signature header is valid.
 *
 * It should implement \Spatie\WebhookClient\SignatureValidator\SignatureValidator
 */
- 'signature_validator' => \Spatie\WebhookClient\SignatureValidator\DefaultSignatureValidator::class,
+ 'signature_validator' => \App\Webhook\TimestampSignatureValidator::class,

Note: The header names must be the same as specified in the configuration in the application sending the webhook. They create a contract between the two applications.

<?php

namespace App\Webhook;

use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Spatie\WebhookClient\Exceptions\InvalidConfig;
use Spatie\WebhookClient\SignatureValidator\SignatureValidator;
use Spatie\WebhookClient\WebhookConfig;

class TimestampSignatureValidator implements SignatureValidator
{
    public function isValid(Request $request, WebhookConfig $config): bool
    {
        $signature = $request->header($config->signatureHeaderName);

        // this is needed because the WebhookConfig data transfer opject does not have custom configs
        $timestampHeaderName = collect(config('webhook-client.configs'))->first(fn($item) => $item['name'] === $config->name)['timestamp_header_name'];

        $timestamp = $request->header($timestampHeaderName);
        // reject if either the signature or timestamp header aren't present
        if (!$signature || !$timestamp) {
            return false;
        }

        // reject if the timestamp from the header is from more than 3 minutes ago
        if (Carbon::createFromTimestamp($timestamp)->diffInMinutes(now()) > 3) {
            return false;
        }

        $signingSecret = $config->signingSecret;

        if (empty($signingSecret)) {
            throw InvalidConfig::signingSecretNotSet();
        }

        $computedSignature = hash_hmac('sha256', $timestamp.'|'.$request->getContent(), $signingSecret);

        return hash_equals($signature, $computedSignature);
    }
}

You'll notice that the method for computing the signature matches the one used in the application sending the webhook.

// application sending webhook signature
hash_hmac('sha256', $timestamp.'|'.json_encode($payload), $secret);

// application receiving webhook computed signature
hash_hmac('sha256', $timestamp.'|'.$request->getContent(), $signingSecret);

The format of the combination of the request body and the timestamp doesn't matter. I use the format $timestamp{delimiter}$requestBody. Any string that contains the request body and the timestamp will get the job done. Just make it consistent between the sending and receiving applications.

The End

So, that's a bare minimum implementation of webhooks that are secured using a timestamp-dependent signature to prevent replay attacks.

Let me know if you found this useful or if you have other ideas about to best do webhooks in Laravel.