Secure Your Webhooks in Laravel: Preventing Data Spoofing
Learn how to implement robust authentication using pre-shared keys and HMAC signatures to protect your webhook endpoints.
Secure data exchange between applications is crucial. When an order is placed on system A, system B often needs immediate notification via webhooks – a common method for real-time communication. However, many webhook implementations lack adequate security, leaving them vulnerable to data spoofing and allowing malicious actors to trigger unintended actions.
To prevent this, verifying the integrity and authenticity of incoming webhook data is paramount, similar to securing API endpoints with API keys.
Authenticating
For this implementation, we'll use the concept of pre-shared keys. This is a string that both the sending and receiving systems know. This key must be kept secret between systems.
With this pre-shared key, we can easily verify the integrity of the data being sent over by creating a HMAC key, which is signed using the pre-shared key.
This is known as a signature and protects against man-in-the-middle attacks and ensures the integrity of the data being received. Only the systems that know the key are able to exchange valid data between eachother. The signature corresponds directly to the contents of the webhook and any manipulation of that data will result in a failure.
Lets talk code!
That's the basic concept out of the way, but chances are you came here for the code! Let's get stuck in!
Signing your request
Firstly, we need a simple class to generate our signature. This factory will be implemented on both the sending and receiving systems.
Important: For this example, the pre-shared key is hardcoded. In a real application, you must store this key securely using environment variables or a secrets management system and avoid hardcoding it directly in your code.
<?php
namespace App\Factories;
class WebhookSignatureFactory
{
private string $signingKey = 'YOUR_SIGNING_KEY';
public function generate(string $url, array $params): string
{
// sort our parameters by key for consistent signature generation
ksort($params);
// create a json string of the url and data to be signed
$data = $url . json_encode($params);
// generate the HMAC-SHA1 and base64 encode it
return base64_encode(hash_hmac('sha1', $data, $this->signingKey, true));
}
}
We can now call on this class from both applications to generate our signature. An example of its usage is below:
$signature = (new WebhookSignatureFactory())->generate(
'https://yoursystem.com/webhooks/receiver',
['input' => ['example' => true]]
);
Note that the URL will be the webhook destination, and the params will be all of the data that you're sending over.
Middleware to the rescue!
Ah, the power of Laravel's middleware. We can leverage this to easily protect our webhook routes. This middleware needs to be placed on the system, or systems that are receiving the webhooks. Its purpose is to rebuild the signature and compare that to the original we were sent as a header.
They must match exactly, otherwise we know the data has been manipulated, or the request is invalid.
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use App\Factories\WebhookSignatureFactory;
class AuthenticateWebhookMiddleware
{
public function handle(Request $request, \Closure $next)
{
// check we were sent a signature
abort_unless($request->hasHeader('X-Signature'), 401);
// rebuild our signature locally
$factory = new WebhookSignatureFactory();
$expectedSignature = $factory->generate($request->url(), $request->all());
// if our signatures don't match, return a 401 error
abort_if($request->header('X-Signature') !== $expectedSignature, 401);
return $next($request);
}
}
In the code above I'm using dependency injection, but that's a design decision for you and your programming style!
When making our requests, we'll generate and send our signature as an X-Signature
header. So in the middleware we firstly make a simple check to ensure we actually have the signature.
If we have a signature, we need to compare it against the one we generate locally, using our factory, based on the incoming URL and payload being sent.
Finally, we compare that the signature sent with the request matches our expected signature. If it doesn't, we'll exit abort out early.
Implementation
That's all the code we need for effectively implementing pre-shared key webhook signing, ensuring security and integrity of the data you're exchanging. Now, lets have a look at implementing this, and a few examples of how you can make requests, demonstrated in the form of test cases.
Securing our webhook routes
That's the groundwork done, now its time to secure our webhooks. Here's a simple example of how you can do this in your routes file:
use App\Http\Middleware\AuthenticateWebhookMiddleware;
Route::post('webhooks/test', function () {
return response()->json(['success' => true]);
})->name('test')->middleware(AuthenticateWebhookMiddleware::class);
If you need help with getting webhook routes configured, take a look at the Laravel documentation here which gives a good example: https://laravel.com/docs/12.x/routing#routing-customization
And that's it! Now, any requests sent to your webhook will require a valid signature. Lets put that to the test with a PEST test suite. We'll check we need a header, it requires signing and any data manipulation will result in erroring out early:
<?php
use App\Factories\WebhookSignatureFactory;
use Illuminate\Support\Facades\Http;
it('will return 401 without a signature', function () {
$response = Http::post(route('webhooks.test'), ['data' => ['testing' => true]]);
expect($response)
->getStatusCode()->toBe(401);
});
it('will return 401 with an invalid signature', function () {
$response = Http::withHeader('X-Signature', 'invalid-signature')
->post(route('webhooks.test'), ['data' => ['testing' => true]]);
expect($response)
->getStatusCode()->toBe(401);
});
it('will return 401 with a manipulated payload', function () {
$route = route('webhooks.test');
$payload = ['data' => ['testing' => true]];
$factory = new WebhookSignatureFactory();
$response = Http::withHeader('X-Signature', $factory->generate($route, $payload))
->post(route('webhooks.test'), [...$payload, 'manipulated' => true]);
expect($response)
->getStatusCode()->toBe(401);
});
it('will return 200 with a valid signature and payload', function () {
$route = route('webhooks.test');
$payload = ['data' => ['testing' => true]];
$factory = new WebhookSignatureFactory();
$response = Http::withHeader('X-Signature', $factory->generate($route, $payload))
->post(route('webhooks.test'), $payload);
expect($response)
->getStatusCode()->toBe(200);
});
A quick run and we can see all tests are passing, great news!
./vendor/bin/pest --filter=WebhookSigningTest
PASS Tests\Feature\WebhookSigningTest
✓ it will return 401 without a signature 0.23s
✓ it will return 401 with an invalid signature 0.07s
✓ it will return 200 with a valid signature and payload 0.07s
✓ it will return 401 with a manipulated payload 0.08s
Tests: 4 passed (4 assertions)
Duration: 0.50s
And that's it, you now know how to properly secure your webhooks in Laravel, preventing them from exploits an manipulation by unauthorised third parties. The only systems able to communicate with your webhooks will be the ones that have your pre-shared key!
If you enjoyed this article or it was useful to you, please subscribe for more. Thanks!