The Webhook Signature Nobody Is Checking
Key Intel / TL;DR
  • A webhook receiver is an endpoint you deliberately exposed to the internet with no session, no login, and no user behind the request.
  • That makes signature verification the whole security model, and it fails in four predictable ways.
  • A signature with no timestamp check is a permanent credential, because a captured request stays valid forever.
  • Outbound webhooks are worse, since a user-supplied destination URL turns your server into a request-forging tool aimed at your own network.
  • Every one of these is a half-day fix, and all of them are cheaper than the incident review that follows the alternative.

Picture the loading dock at the back of a building. Somebody cut a door into the wall years ago because a supplier needed to deliver directly to the floor instead of going around to reception, and the arrangement works fine. The driver knows to come to that door, the door stays propped during business hours, and everybody who works there has stopped noticing it exists.

Now ask the question a security person has to ask. What actually stops somebody who is not that driver from walking through it? On most loading docks the honest answer is that the driver wears a uniform and nobody has ever checked one closely.

That is a webhook. You cut a hole in your own perimeter so a third party could push data to you, and the only thing standing in it is a signature that a developer implemented once, under deadline, against documentation they skimmed.

Why a Webhook Is Not Like the Rest of Your API

We have written about the endpoints you have forgotten you are running, and a webhook receiver is the opposite problem. You know exactly that it exists, you documented it, and somebody put it in the integration guide. The danger comes from what it cannot do rather than from what you forgot.

Every other endpoint you own can lean on your identity stack. There is a session, a token, an API key, a user who logged in, something that ties the request to an actor you can look up and revoke. The authorization model you built assumes that foundation exists.

A webhook receiver has none of it. The caller is a payment processor’s server farm or a version control provider’s delivery fleet, calling you from addresses you do not control, with no user in the loop and no credential you issued to a person. Your entire ability to distinguish a real notification from a forged one collapses into one operation, which is verifying that the body of this request was signed by somebody holding the shared secret.

When that one operation is weak, everything downstream inherits the weakness, and everything downstream is usually consequential. Webhook handlers mark invoices paid, provision accounts, trigger deployments, and move money.

The Four Ways the Check Goes Wrong

There is no check

This is far more common than it should be, and it usually arrives through a reasonable-sounding shortcut. The handler reads an account identifier out of the request body and trusts it, on the theory that nobody knows the URL.

The URL is not a secret. It appears in browser network tabs, in third-party dashboards, in screenshots pasted into support tickets, and in the logs of every proxy between the sender and you. Treating an unguessable path as authentication is the same bet as leaving the loading dock door propped open because the alley is hard to find.

The check is not timing-safe

A developer verifies the signature by comparing two strings with a standard equality operator. Functionally it looks correct, and every test passes.

The problem is that a normal string comparison returns as soon as it finds a mismatched byte, so a signature that shares the first four bytes takes measurably longer to reject than one that fails immediately. An attacker who can send a few hundred thousand requests can recover the correct signature one byte at a time from those timing differences. Use the constant-time comparison function your language ships, such as hmac.compare_digest in Python or crypto.timingSafeEqual in Node, and the attack disappears entirely.

There is no replay window

Here is the one I find most often, and it is the one people argue about. The signature verifies correctly, the code is clean, and the handler is doing real cryptography. There is still no timestamp in the signed payload, or there is one and nobody checks it.

A signature with no expiry is a permanent credential. Anybody who captures one valid request, from a log file, a proxy, an old support ticket, or a misconfigured error tracker, can replay that exact request as many times as they like, forever, and every replay verifies. Now put yourself in the shoes of somebody who found one captured “payment succeeded” notification in an error report from last March. They do not need to break your cryptography, because you already signed the message for them.

Sign the timestamp along with the body, reject anything older than about five minutes, and keep a short record of recently seen delivery identifiers so the same one cannot be processed twice inside that window.

The secret is shared or stored badly

One signing secret across every tenant means any customer who can see their own secret can forge notifications for every other customer. A secret passed as a query parameter ends up in access logs on every hop. A secret that has never rotated since the integration was built in 2021 has been in more places than anybody can reconstruct.

These are machine credentials, and they deserve the same lifecycle you would give a human one: per-tenant issuance, storage in a secret manager, and a rotation path that supports two valid secrets at once so rotating does not require an outage.

The Direction Everybody Forgets

Everything above is about webhooks you receive. The larger hole is usually in the ones you send.

If your product lets a customer type in a URL and you promise to POST to it when something happens, you have built a service that fetches arbitrary destinations on command, from inside your network, with your server’s network position. That is a server-side request forgery primitive, offered as a feature, with a configuration page.

Consider what your server can reach that the customer cannot. It can reach 169.254.169.254, the link-local address where most cloud providers serve instance metadata and, depending on configuration, temporary credentials. It can reach your internal admin panels, your databases, and the staging environment somebody exposed on a private subnet because it was only internal. A customer who sets their webhook destination to an internal address and reads your delivery logs or your error messages is using your own integration to map your network.

Validate the destination before you ever send. Resolve the hostname yourself, reject anything that resolves into private, loopback, or link-local ranges, re-resolve at request time so a DNS record cannot change between your check and your fetch, and send outbound deliveries through a dedicated egress proxy that has no route to anything internal.

What This Costs to Get Wrong

A forged webhook that marks orders paid is direct revenue loss that scales with however long it runs before somebody reconciles the books. A replayed provisioning notification creates accounts nobody purchased. An SSRF into cloud metadata that yields instance credentials is not a webhook incident at all by the time it finishes, because it is now a cloud incident with a forensics bill, a notification question, and a set of conversations with customers about what those credentials could reach.

Against that, the work is small. Adding a timestamp to the signed payload and a five-minute tolerance is a few hours including tests. Swapping a string comparison for a constant-time one is a one-line change. Per-tenant secrets take a day if you have a secret manager already. An egress proxy for outbound deliveries is the only item that looks like a project, and it is a small one.

Where to Start on Monday

The fastest way to find out where you stand is to list every webhook endpoint you expose, then answer four questions about each one. Does it verify a signature at all, is the comparison constant-time, is a timestamp inside the signed material and enforced, and is the secret unique per sender. Any endpoint that fails the first question is doing nothing and should be treated as an open door rather than a finding.

Then list every place a customer can give you a URL that you will later request. That list is usually longer than the team expects, because it includes avatar imports, document fetchers, and the integration somebody built for one enterprise deal in 2023. This is the same inventory discipline that securing your API surface depends on, applied to the one direction most inventories never record.

The loading dock door is fine. It is a good door and the supplier needs it. Check the uniform.

Chris Armour is Director of Information Security at Grab The Axe.

Distribute Intel
Chris Armour
Director of Information Security
Chris Armour
The Breaker & Builder.

Operating on the philosophy that 'you can't build a secure system if you don't know how to break it,' Chris leads our engineering division. A top 1% National Cyber League competitor, he hardens our digital infrastructure against the very exploits he has mastered.

View Author Page →