Webhook signature verification is the only thing standing between your application and a stranger forging events into it. Your webhook URL is public the moment you register it with Stripe, GitHub, or any other provider, which means anyone who learns that URL can POST a fully-formed JSON body to it and pretend to be the provider. A forged payment.succeeded or subscription.created event can flip a paid flag, grant access, or close a loan with no money ever changing hands. The fix is to verify a cryptographic signature on every inbound request and reject anything that does not match. Most people implement this and still get it wrong on one detail: they verify against parsed JSON instead of the raw request body, so the signature never matches and they either disable the check or trust everything. Here is how to do it correctly.
How does webhook signature verification actually work?
When you set up a webhook, the provider gives you a signing secret. It keeps a copy; you store yours in config. On every event, the provider computes an HMAC-SHA256 of the exact payload bytes it is about to send, keyed with that shared secret, and puts the result in a header. Stripe uses Stripe-Signature, GitHub uses X-Hub-Signature-256 (with a sha256= prefix on the digest), and most others follow a similar shape. On your end you recompute the same HMAC over the body you received, with your copy of the secret, and compare the two. If they match, only someone holding the secret could have produced that signature, so the event is authentic. If they do not, you reject it. An attacker who does not know the secret cannot forge a valid signature, even though they can hit the URL all day.
- The secret is shared and symmetric: the same key signs and verifies. Treat it like a password, store it per-provider in env, and never commit it.
- The signature covers the bytes, not the meaning. Whitespace, key ordering, and trailing newlines are all part of what was signed.
- Each endpoint gets its own secret. If you have separate webhooks for payments and account events, they have separate signing secrets, and mixing them up gives you silent verification failures.
- Read the header format for your provider. Some send the bare hex digest, others prefix it (sha256=...) or pack a timestamp and scheme into one header, so compare like for like.
Why does my signature never match?
This is the detail that trips up almost everyone. The HMAC is computed over the raw bytes the provider sent. The instant your framework parses that body into an associative array and you re-encode it to verify, you have changed the bytes: a round-trip through json_decode then json_encode can reorder keys, change unicode escaping, drop the trailing newline, or render 1.0 as 1. The recomputed HMAC then differs from the provider's, and verification fails on every legitimate event. The rule is simple: capture and hash the raw request body before anything parses it. In Laravel that means $request->getContent(), which returns the unmodified body string. Do not pass $request->all() or the decoded JSON into your HMAC. Read the raw content, verify it, and only decode afterwards.
Verify the bytes the provider signed, never the JSON you parsed back. The single most common webhook bug I have debugged is an HMAC computed over re-encoded data that can never match the original.
What does a correct verification look like in Laravel?
Below is a middleware that does the three things a real check needs: it recomputes the HMAC over the raw body, compares it in constant time, and enforces a timestamp tolerance so a captured-and-replayed request goes stale. Three rules carry the weight here. First, compare with hash_equals, never with == or ===. A naive string comparison can return early on the first differing byte, and that timing difference is the basis of a timing side-channel; over a network the signal is buried in jitter and rarely practical, but hash_equals removes the class of bug for free, so there is no reason not to use it. Second, include a timestamp from the provider in the signed string and reject anything older than a few minutes, which kills replay attacks where someone resends a valid old event. Third, reject unsigned or invalid requests with a 400 and log them; do not let them fall through to your handler.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
// 1. RAW body bytes — never $request->all() or decoded JSON.
$payload = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$timestamp = $request->header('X-Webhook-Timestamp');
$secret = config('services.provider.webhook_secret');
if (! $signature || ! $timestamp || ! $secret) {
abort(400, 'Missing signature, timestamp, or secret.');
}
// 2. Replay protection: reject anything older than 5 minutes.
if (abs(time() - (int) $timestamp) > 300) {
abort(400, 'Timestamp outside tolerance.');
}
// 3. Recompute HMAC over "{timestamp}.{rawBody}" with the secret.
$signedPayload = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// 4. Constant-time compare — NEVER == or ===.
if (! hash_equals($expected, $signature)) {
abort(400, 'Invalid signature.');
}
return $next($request);
}
}Register it on the webhook route only, and exempt that route from CSRF, since providers do not carry your CSRF token. The secret comes from config, which reads from env, so you never hardcode it and you can rotate it without a deploy. If you use Stripe's own SDK, Webhook::constructEvent($payload, $sigHeader, $secret) does this whole dance for you, including the default five-minute timestamp tolerance, but the principles are identical: it reads the raw payload, parses Stripe-Signature, and uses a constant-time compare under the hood. The official Stripe webhooks documentation is worth reading once even if you use another provider, because it spells out the timestamp-and-scheme format clearly.
use App\Http\Controllers\WebhookController;
use App\Http\Middleware\VerifyWebhookSignature;
// Exempt this URI from CSRF in bootstrap/app.php (Laravel 11/12):
// ->withMiddleware(fn ($middleware) => $middleware->validateCsrfTokens(except: ['webhooks/*']))
Route::post('/webhooks/provider', [WebhookController::class, 'handle'])
->middleware(VerifyWebhookSignature::class);What happens after verification passes?
Verification proves authenticity, not that you should act once. Providers retry on any non-2xx response and sometimes deliver the same event twice even on success, so a verified event still needs an idempotency key before it touches your database. The clean structure is one thin controller that verifies, dedupes, then dispatches a job. I keep all of that in one place; the pattern is laid out in my guide to a centralized payment webhook controller in Laravel, and the retry-and-dedupe side is covered in handling payment webhooks with idempotency and retries. Signature verification is one line item on a larger list; it sits alongside the rest of my Laravel security checklist.
- Return 2xx fast, then process asynchronously. Run the real work in a queued job so a slow handler does not trigger provider retries.
- Dedupe on the provider's event ID. Store seen IDs and skip duplicates, because at-least-once delivery means the same event will arrive twice eventually.
- Log every rejected request. A spike of 400s with bad signatures is someone probing your endpoint, and you want to see it.
- Rotate the secret if it ever leaks. Because it lives in env and is read through config, rotation is a value change, not a code change.
Get these three things right and a public webhook URL stops being a liability: hash the raw body, compare in constant time with hash_equals, and enforce a timestamp window. Forged events bounce off with a 400, replayed events go stale, and only payloads genuinely signed by your provider reach your handler. It is maybe twenty lines of middleware, and it is the difference between a webhook endpoint and an open door into your application.

