How to verify incoming webhook requests are authentic and unmodified
A webhook endpoint is technically nothing more than a publicly reachable URL that accepts POST requests. Anyone who knows that URL could, in theory, inject forged events if the receiver never checks the authenticity of incoming requests. HMAC-SHA256 signatures over the payload, combined with a timing-safe comparison function and replay protection via a timestamp, close that gap reliably. This article walks through the practical implementation step by step, including a complete Symfony middleware example.
Table of Contents
- 1. Why webhook receivers must verify the authenticity of incoming requests
- 2. HMAC-SHA256: signing the payload with a shared secret
- 3. Carrying the signature in a header: X-Signature
- 4. Verification on the receiver side, step by step
- 5. A timing-safe comparison function: hash_equals in PHP
- 6. Replay protection through a timestamp and tolerance window
- 7. A Symfony middleware example for signature verification
- 8. Common mistakes in the practical implementation
- 9. A checklist and overview for implementation
- 10. Summary
- 11. FAQ
1. Why webhook receivers must verify the authenticity of incoming requests
A webhook endpoint is, from a network perspective, nothing more than a publicly reachable URL that accepts POST requests with a JSON payload. That is exactly where the security problem lies: anyone who knows or guesses the URL can technically send arbitrary requests to that endpoint that look like a legitimate event, for instance a forged 'payment succeeded' notification that gets the receiving system to mark an order as paid when it was not.
Without an authenticity check, the receiver has to blindly trust every incoming request as long as it is syntactically valid. That is a fundamental difference from a regular REST API, where a client typically authenticates through a token or a session, because with webhooks the sender initiates the connection and the receiver has no prior session to match the request against. HMAC request signing closes exactly this gap by making it cryptographically provable that a request genuinely came from the expected sender and was not altered along the way.
2. HMAC-SHA256: signing the payload with a shared secret
HMAC (Hash-based Message Authentication Code) combines a hash function, typically SHA-256, with a secret key that sender and receiver exchange in advance over a secure channel. Before sending, the sender computes a signature over the complete payload (the raw request body as a string, not the parsed JSON object) using this secret, and attaches the computed signature as its own header on the request.
The decisive security advantage over a plain checksum is that an attacker without knowledge of the secret cannot produce a valid signature for a tampered or freely invented payload, even if they know the algorithm (SHA-256) and the general request shape exactly. As long as the secret stays confidential, the signature is practically unforgeable, and any change to the payload, even a single byte, produces a completely different, invalid signature.
3. Carrying the signature in a header: X-Signature
The signature travels alongside the webhook delivery as an additional HTTP header, commonly named X-Signature or a more descriptive name such as X-Webhook-Signature. The value of this header is the hex-encoded HMAC-SHA256 signature computed over the exact request body, so the receiver can reproduce it with the same computation.
It is essential that the signature is always computed over the raw, unmodified bytes of the payload, not over a re-serialized JSON representation produced somewhere along the way, since even a different field order or whitespace in the JSON would already produce a different byte sequence and therefore an apparently invalid signature. On the sender side, the computation in PHP looks like this:
<?php
declare(strict_types=1);
namespace App\Webhook;
final class WebhookSigner
{
public function __construct(
private readonly string $secret,
) {
}
/**
* Computes the HMAC SHA256 signature over the raw payload
* and returns the headers that must be sent alongside it.
*
* @return array<string, string>
*/
public function buildHeaders(string $rawPayload): array
{
$timestamp = (string) time();
$signedPayload = $timestamp . '.' . $rawPayload;
$signature = hash_hmac('sha256', $signedPayload, $this->secret);
return [
'X-Signature' => $signature,
'X-Signature-Timestamp' => $timestamp,
];
}
}
4. Verification on the receiver side, step by step
On the receiver side, verification follows a fixed order that must be respected consistently, otherwise the check becomes ineffective through a subtly wrong implementation order. First, the raw request body is read exactly as received, before any framework middleware parses or normalizes it, because that exact raw byte stream was the basis for the signature computation on the sender side.
The receiver then computes the expected signature using the same secret and the same algorithm over that same raw payload (combined with the transmitted timestamp), and compares it against the signature carried in the X-Signature header. If both values match exactly, the request is considered authentic; in every other case the request must be rejected with a 401 status code before any business logic runs.
5. A timing-safe comparison function: hash_equals in PHP
A commonly overlooked mistake in implementation is comparing the two signatures with the plain == operator or its strict_types counterpart ===. Both compare strings character by character and stop immediately at the first mismatch, which makes the comparison time minimally, but measurably, dependent on the number of matching leading characters.
An attacker who sends millions of requests with slightly different signatures and precisely measures response times can, in theory, reconstruct the correct signature byte by byte from these tiny timing differences, a classic timing attack. PHP instead offers hash_equals(), a comparison function that always takes constant time regardless of where the first mismatch occurs, because it walks through both full strings instead of bailing out early. Every signature comparison in production code must therefore use hash_equals() exclusively, never ==, ===, strcmp(), or in_array().
6. Replay protection through a timestamp and tolerance window
A valid signature alone does not protect against a replay attack: if an attacker intercepts a legitimately sent, correctly signed request and later resends it unchanged, the signature is still valid because the payload has not changed. Without additional protection, the receiver would process the same event, say a payment confirmation, a second time.
The usual solution combines a timestamp that is part of the signed payload (as in the example above, where signedPayload = timestamp + '.' + rawPayload) with a tolerance window on the receiver side, typically five to ten minutes. If the transmitted timestamp falls outside this window, the request is rejected regardless of a valid signature, because it is either a repeated old request or a forged value set too far in the future. For additional safety, the combination of signature and timestamp can also be stored in a cache such as Redis for the duration of the tolerance window, to catch exact duplicates within that window as well.
7. A Symfony middleware example for signature verification
In a Symfony application, the full check, reading the raw payload, validating timestamp tolerance, comparing the signature with hash_equals, can be cleanly encapsulated as an event subscriber on the kernel.request event, which runs before the actual controller and immediately terminates the request with a 401 response on an invalid signature.
This approach keeps the verification logic in one central place instead of repeating it in every individual webhook controller, and guarantees that no controller can accidentally process an unverified request, because the check already happens at the kernel level, before routing even resolves the matching controller.
<?php
declare(strict_types=1);
namespace App\Webhook\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Verifies the HMAC signature of incoming webhook requests
* before the actual controller is invoked.
*/
final class WebhookSignatureSubscriber implements EventSubscriberInterface
{
private const int TOLERANCE_SECONDS = 300;
public function __construct(
private readonly string $webhookSecret,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 20],
];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/webhooks/')) {
return;
}
$signature = $request->headers->get('X-Signature');
$timestamp = $request->headers->get('X-Signature-Timestamp');
if ($signature === null || $timestamp === null) {
$event->setResponse(new JsonResponse(['error' => 'Missing signature headers'], 401));
return;
}
if (abs(time() - (int) $timestamp) > self::TOLERANCE_SECONDS) {
$event->setResponse(new JsonResponse(['error' => 'Timestamp outside tolerance window'], 401));
return;
}
$rawPayload = $request->getContent();
$signedPayload = $timestamp . '.' . $rawPayload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
$event->setResponse(new JsonResponse(['error' => 'Invalid signature'], 401));
return;
}
}
}
8. Common mistakes in the practical implementation
The most common mistake in practice is that frameworks have already parsed the request body by default before signature verification runs, so the raw byte string relevant to the signature is no longer available unchanged. In Symfony, $request->getContent() is reliable as long as the subscriber runs early enough in the request lifecycle; in other frameworks, or with body-parsing middleware enabled, this step can subtly fail once the raw body is no longer accessible.
A second widespread mistake is storing the secret in frontend code or in a publicly accessible configuration file, which renders the entire protection useless, since anyone with access to that code can produce their own valid signatures. The secret belongs exclusively in server-side environment variables or a secret manager, never in version control or client-side code. Just as common is a tolerance window that is too short or too generous: less than a minute causes false rejections under normal network latency, while more than fifteen minutes needlessly widens the window for replay attacks.
9. A checklist and overview for implementation
The table below summarizes the key building blocks of a correct HMAC implementation for webhook receivers, as a checklist for your own implementation.
| Building block | Purpose | Common mistake |
|---|---|---|
| HMAC-SHA256 over the raw payload | Proves authenticity and integrity | Signature computed over re-serialized JSON instead of raw bytes |
| Signature in the X-Signature header | Carries the signature value to the receiver | Header name not agreed upon between sender and receiver |
| hash_equals() for comparison | Protects against timing attacks | Comparison done with == or === instead of constant time |
| Timestamp with tolerance window | Protects against replay attacks | No timestamp, or a far too generous window |
| Secret stored server-side only | Prevents forgery by third parties | Secret placed in frontend code or version control |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
HMAC Webhook Signing: The Essentials at a Glance
Core principle
HMAC-SHA256 over the raw payload proves that a webhook request genuinely came from the expected sender and was not modified.
Safe comparison function
Signatures are compared exclusively with hash_equals(), never == or ===, to rule out timing attacks.
Replay protection
A signed timestamp with a five to ten minute tolerance window prevents intercepted requests from being replayed.
Most critical mistake
The secret must be stored server-side only, never in frontend code or version control.