Webhook Security: HMAC Signatures, Replay Prevention, and Verification in Symfony
Webhooks are the integration surface teams secure last, if they secure it at all. An endpoint accepts POST requests from the public internet, parses the body, and writes to the database. If anyone can call it, anyone can create orders, flip subscription states, or mark invoices as paid. Webhook signature verification is the mechanism that prevents this, and in most codebases we audit it is either missing, half implemented, or implemented in a way that quietly breaks.
This post covers the whole chain: how to generate HMAC signatures correctly on the sending side, how to verify them in PHP without opening a timing side channel, how to stop replay attacks, and how to build a Symfony consumer that survives retries and handles multiple providers at once.
Why skipping webhook signature verification leaves an open door
A webhook endpoint without verification trusts anything that arrives with the right JSON shape. An attacker who finds the URL (it leaks through logs, browser history, provider dashboards, and misconfigured error trackers more often than you would think) can forge a payment_intent.succeeded event and get your application to ship goods that were never paid for.
The fix is a shared secret. The provider signs each delivery with HMAC-SHA256 over the request body and puts the result in a header. Your endpoint recomputes the signature from the body it received and compares. An attacker without the secret cannot produce a valid signature, so forged events fail at the door before any business logic runs.
That is the theory. The implementation is where teams get hurt, and the mistakes cluster in three places: what gets signed, how the comparison runs, and what happens when the same valid request arrives twice.
Sign the raw body, never a re-serialized object
The single most common bug in webhook signature verification, on both sides of the connection, is signing or verifying a serialized representation of the payload instead of the raw bytes.
If the sender does hash_hmac('sha256', json_encode($event), $secret) and the receiver does hash_hmac('sha256', json_encode(json_decode($body)), $secret), verification will fail intermittently. json_encode in PHP escapes forward slashes by default, orders keys in insertion order, and renders 1.0 as 1. Node on the other end makes different choices. Two encoders producing semantically identical JSON almost never produce byte-identical JSON, and HMAC operates on bytes.
The rule for the sending side: serialize once, sign those exact bytes, send those exact bytes.
$payload = json_encode($event, JSON_THROW_ON_ERROR);
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// send $payload as the body, never re-encode it
The rule for the receiving side: verify against the raw request body before anything parses it.
$rawBody = $request->getContent();
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
In Symfony, $request->getContent() gives you the untouched bytes. Do the verification there, in a listener or subscriber that runs before deserialization, body converters, or anything else that might normalize the payload.
Note the timestamp concatenated into the signed string. That is not decoration. It is the anchor for replay prevention, which we will get to in a moment.
Timing-safe comparison in PHP
Comparing signatures with === leaks information. String comparison in PHP returns as soon as it finds a differing byte, so a signature that matches the first four characters takes measurably longer to reject than one that fails immediately. Over enough requests, an attacker can use that timing difference to recover a valid signature byte by byte. This is not a theoretical attack; it has been demonstrated against real APIs over the network.
PHP has had the answer built in since 5.6:
if (!hash_equals($expected, $providedSignature)) {
throw new UnauthorizedHttpException('Webhook', 'Invalid signature.');
}
hash_equals compares in constant time relative to the length of the known string. Two details matter. The known, self-computed value goes in the first argument. And both inputs should be the hex digest strings, compared as-is; do not lowercase, trim, or otherwise massage the attacker-supplied value first, because those operations reintroduce timing variance.
If you take one thing from this post: every === or == comparing a signature is a finding in a security review. We flag it in nearly every code audit that includes a webhook consumer.
Replay prevention: timestamps plus a nonce cache
A valid signature proves the request came from someone holding the secret. It does not prove the request is fresh. An attacker who captures one legitimate delivery (from a log aggregator, a proxy, a compromised staging box) can resend it unchanged, signature intact, as often as they like. If the event is "credit this account," replay is a direct path to fraud.
Two layers close this hole.
The first is the timestamp you saw in the signed string above. The sender includes the send time in a header and inside the HMAC input. The receiver rejects anything older than a small window:
$timestamp = (int) $request->headers->get('X-Webhook-Timestamp');
if (abs(time() - $timestamp) > 300) {
throw new UnauthorizedHttpException('Webhook', 'Timestamp outside tolerance.');
}
Because the timestamp is part of the signed payload, an attacker cannot freshen a captured request by swapping the header; the signature would no longer match. Five minutes of tolerance absorbs clock skew and provider retry delays. Stripe uses the same window as its default.
The second layer covers replays inside the window. Each event carries a unique ID, and the receiver keeps a short-lived record of IDs it has already accepted. Redis is the natural fit because the entry can expire on its own:
$key = 'webhook:seen:' . $provider . ':' . $eventId;
if (!$redis->set($key, '1', ['nx', 'ex' => 600])) {
// NX failed: this ID was already processed within the last 10 minutes
return new Response(null, 200);
}
The NX flag makes the check-and-set atomic, so two concurrent deliveries of the same event cannot both pass. Returning 200 for a replayed event is deliberate. The provider considers the delivery successful and stops retrying, which is exactly what you want for a duplicate.
Idempotency: the receiver that survives retries
Replay prevention and idempotency look similar but solve different problems. Replay prevention keeps attackers from reusing captured requests. Idempotency keeps your own data correct when the provider legitimately delivers the same event twice, which every serious provider will do, because they retry on timeouts and their delivery guarantee is at-least-once.
The Redis nonce above deduplicates within a ten-minute window. That is too short for delivery retries, which can arrive hours later after an outage on your side. For those you want durable storage. Extract the event ID, record it in PostgreSQL in the same transaction that applies the business change, and let a unique constraint arbitrate:
$this->em->wrapInTransaction(function () use ($event) {
$this->em->persist(new ProcessedWebhook($event->provider, $event->id));
$this->handler->apply($event); // the actual business logic
});
If the event was already processed, the insert violates the unique constraint on (provider, event_id), the transaction rolls back, and the business change is not applied a second time. Catch the UniqueConstraintViolationException, log it as a duplicate, and return 200. Binding the dedup record and the state change into one transaction is the point: if either fails, both fail, so a crash mid-handler cannot leave the event marked as done but not applied.
A cron that purges processed_webhooks rows older than 30 days keeps the table from growing without bound.
A multi-provider consumer in Symfony
Real applications receive webhooks from several providers at once, and each provider signs differently. Stripe sends Stripe-Signature with an embedded timestamp and a scheme prefix. GitHub sends X-Hub-Signature-256 as sha256=<hex> with no timestamp. Clerk uses the Svix format: base64 signatures, a message ID, and a separate timestamp header.
Hardcoding one verification path per controller turns into copy-paste drift. A cleaner shape is one verifier interface and a request listener that picks the implementation by route:
interface WebhookVerifier
{
public function verify(Request $request, string $secret): void;
}
final class StripeVerifier implements WebhookVerifier { /* t=,v1= parsing */ }
final class GitHubVerifier implements WebhookVerifier { /* sha256= prefix */ }
final class ClerkVerifier implements WebhookVerifier { /* svix headers */ }
#[AsEventListener(event: KernelEvents::REQUEST, priority: 24)]
final class WebhookVerificationListener
{
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
$provider = $request->attributes->get('_webhook_provider');
if (null === $provider) {
return;
}
$this->verifiers->get($provider)->verify(
$request,
$this->secrets->forTenant($request, $provider),
);
}
}
The listener runs early, on the raw body, before any controller or body mapper touches the request. Routes opt in through a _webhook_provider default. In a multi-tenant setup, the secret lookup resolves per tenant, since each tenant connects its own Stripe or GitHub account and therefore has its own signing secret. Keep those secrets in your secrets manager, not in the tenant row next to their display name.
Failures should return 401 with an empty body. Do not echo the expected signature, the computed digest, or which check failed. Verbose error responses have turned broken webhook endpoints into signature oracles more than once.
This kind of structure is bread and butter in the SaaS platforms we build; if you are designing an integration layer from scratch, our custom software development page describes how we approach it.
Buying instead of building: what to demand from a provider
If you are on the consuming side, evaluating a vendor whose webhooks you will depend on, their security design tells you a lot about the rest of their engineering. Before integrating, check their documentation for HMAC-SHA256 (or better) signatures computed over the raw body, a timestamp bound into the signature rather than sent as a loose header, published retry semantics with backoff, secret rotation with an overlap period so you can roll secrets without downtime, and unique event IDs suitable for deduplication.
A vendor that signs with MD5, has no timestamp, or tells you to "verify by checking the source IP" is showing you how they handle the parts you cannot see. IP allowlisting is not authentication; providers move ranges, and shared egress IPs mean other customers of the same platform can hit your endpoint from an allowed address.
Monitoring: verification failures are a signal
Once verification is in place, its failure rate becomes a security metric. Log every rejection with the reason class (bad signature, stale timestamp, replayed nonce) and the source IP, then watch two patterns.
A sudden spike in signature failures from a single IP is someone probing your endpoint. That is worth an alert but usually harmless, since the verification layer is doing its job.
A steady trickle of failures from the provider's real IPs is worse: it usually means a secret rotation went wrong or a deploy changed body handling (a proxy that re-encodes, a middleware that trims whitespace), and you are now silently dropping legitimate events. Pair the rejection metric with a delivery-lag metric, the age of the newest successfully processed event per provider. If Stripe events normally arrive within seconds and the lag climbs past minutes, something upstream of your business logic is broken, whether or not anyone changed your code.
Both metrics are cheap: two counters and a gauge in whatever you already use for application metrics.
Where to go from here
The full checklist fits in a paragraph. Sign and verify the raw body. Compare with hash_equals. Bind a timestamp into the signature and reject stale deliveries. Deduplicate with an atomic Redis check for the short window and a unique constraint in PostgreSQL for the long one. Verify before parsing, per provider, per tenant. Return quiet 401s. Watch the failure rate.
None of it is exotic, and all of it fits into an afternoon once you know the failure modes. The expensive part is finding out which of these steps your current consumer skips, because a webhook endpoint fails silently: everything works in the happy path, and the gap only shows up when someone hostile finds it.
If you want a second pair of eyes on your webhook consumers, or on the integration surface of your application in general, write to hello@wolf-tech.io or have a look around wolf-tech.io. A focused review of the endpoints that accept money-moving events is one of the quickest security wins we know.

