StackAttestTechnical Trust
Webhook security audit

Check whether your webhook endpoint trusts whatever posts to it

A webhook handler is the one endpoint you publish so that strangers can post to it. In testing the only thing posting is the provider, so everything works, and every protection that separates a real delivery from a copy of one is invisible until somebody sends the copy.

Validate a repositoryScan a live appSee an example Passport

What a run can establish about a webhook endpoint

Three layers, and it is worth being exact about which questions each answers here. Whether your handler verifies a signature is a decision inside code only you can run. What follows is what a run settles from outside that decision.

Repository validation

E2
Repository

Reads the source, and on this question what it establishes best is where credential material sits. It reports the shapes it recognises, cloud keys, tokens, private key blocks and an api or secret key assigned a literal value, each with the file and the line, along with any environment file committed to the repository. Git history is read in the same pass, because a secret removed in a later commit is still readable in an earlier one and still works until it is rotated. It matches shapes rather than proving absence, so a clean result here is evidence and not proof.

Runtime validation

E4
Runtime

Builds and runs the application, then posts a body of the wrong shape to the state-changing endpoints it advertises in its own API description. Routes are never guessed, so an application that publishes no API description is not probed this way at all and the control is reported uncovered rather than passed. Where endpoints are probed, any that answers with a success rather than a refusal is named rather than counted in a total, and what a handler returns when it fails is read at the same time, because a stack trace sent to an anonymous caller describes how to attack the thing that produced it.

Public URL scan

E4
URL scan

Tests the deployed application from outside with nothing but the address: transport security, security response headers, and what the running service says about itself. It reaches the front door rather than the handler behind it, so on this page it is context rather than the answer, and every control it cannot reach is reported uncovered rather than assumed.

Which controls a webhook handler is settled under

There is no control called webhooks. A handler is ordinary application code and it is judged by the same named controls as the rest of your endpoints, which is also why a page like this has to say plainly what evidence sits behind each result.

Server-side input validation on state-changing endpoints
Read in source, and probed against the running application: a body of the wrong shape is posted to the state-changing endpoints the application advertises in its own API description, and any that answers with a success is named. An application that advertises none is not probed this way, and the control is reported uncovered.
Object ownership is enforced server-side
Probed against the running application: a record belonging to one account is requested while authenticated as another, and what came back is what gets recorded.
Error responses do not leak internals
Probed against the running application, because what a handler returns when it fails is a fact about the deployment rather than about the code.
Secrets are managed outside source code
Read in source, git history included, so a credential that was committed and later deleted is still reported. The scan matches credential shapes, which is a reason to believe your source is clean rather than a proof that it is.

Where webhook handlers give way

Generated applications cut these corners more reliably than anything else on the site, for one reason: none of them are needed to make the demo pass. They are common failure modes rather than claims about your handler, and the point of the page is to tell you which ones to go and look at.

A signature nobody checked, or checked with a plain comparison

The provider signs each delivery over the exact bytes it sent, using a secret only the two of you hold. Verifying that signature is the only thing that makes the caller trustworthy; without it, the endpoint acts on whatever arrives. Two ways it goes wrong. The handler parses the body first, so the bytes that were signed are gone by the time anything verifies them, and a framework that hands you a parsed object has already lost them. Or the digests are compared with an ordinary equality check, which returns as soon as two bytes differ, and how long that took is a measurement of how much of the guess was right. Compare with a constant-time function.

A valid signed payload stays valid forever

A signature proves the delivery came from the provider. It does not prove it came from the provider just now. Anyone who captures one delivery, out of a log, a proxy, a screenshot in a support thread, holds a payload that verifies for as long as the secret does. Two things fix it and you need both: refuse a delivery whose signed timestamp falls outside a narrow window, and refuse an event id you have already recorded.

The provider will send it twice, on purpose

Retries are the contract, not an error condition. A provider that does not get a timely acknowledgement sends the delivery again, and it may send it again regardless. A handler that credits an account, provisions a seat or sends an invoice on every delivery will eventually do it twice. Claim the event id first and in the same transaction as the work, then treat a repeat as an acknowledgement that changes nothing. This is the one on this page that costs money rather than data.

A webhook proves an event happened, not that the sender may act

The payload names a customer, an order, a subscription. That names a record; it does not establish that whoever sent it is allowed to touch that record, and an identifier that arrived in a request is under the sender's control. Look the record up yourself, check it belongs where the event claims, and act on what you found rather than on what you were handed.

The status code is part of the protocol

Answering 500 because your database blinked is right: the provider retries and the event survives. Answering 500 because the payload was malformed asks the provider to keep retrying something that can never succeed, which is how a queue fills up. Answering 200 to something you failed to process throws the event away, quietly, and nothing anywhere will mention it again. Decide which failures are worth a retry and which are permanent, and make the response say which one it is.

What the result looks like

An illustrative extract, not a real customer result. Every row carries the evidence grade behind it, and a control nobody could establish says so.

ControlStatusEvidenceNote
Server-side input validation on state-changing endpointsFailedE4The delivery endpoint accepted a body of the wrong shape
Error responses do not leak internalsPartialE4One handler returned a framework trace on a malformed body
Object ownership is enforced server-sideFailedE4A record named in a request was returned to another account
Secrets are managed outside source codeVerifiedE2No credential material recognised in source or git history
Dependencies are free of known-critical vulnerabilitiesUncoveredNot gradedNo lockfile committed, so no resolved versions to check

Mapped to published standards

Each control is tested against a named clause, so a result means something outside our own vocabulary. Mapping is not certification, and none of these bodies endorse StackAttest.

OWASP ASVS 5OWASP API Security Top 10CWENIST SSDF

What this check is not

  • It does not send test deliveries to your endpoint. StackAttest holds no signing secret of yours and has no relationship with Stripe, PayPal or any other provider, so it cannot produce a delivery one of them would have signed. Signature verification, the replay window and the idempotency key stay yours to confirm, and the controls named above are what a run settles.
  • It is not a penetration test. A deterministic check and a runtime probe are not an adversarial human engagement, and there are classes of weakness only a person hunting for them will find.
  • It is not SOC 2 and does not replace it. SOC 2 attests to organisational controls over time; this validates technical controls in the software. They answer different questions for different buyers.
  • It does not replace human due diligence. It gives a reviewer repeatable evidence to start from, which is a different thing from being the reviewer.
  • Controls the run could not reach are reported as uncovered rather than passed. A result you did not earn is worse than no result.

Questions

How do I secure a webhook endpoint?

Five things, in this order. Verify the signature over the raw bytes before anything parses them. Reject a delivery whose signed timestamp is outside a narrow window. Record the event id and refuse one you have seen. Look up the record the event names instead of trusting what the payload says about it. Return a status code that tells the provider whether retrying will help. Everything else is detail.

Why is Stripe the example everyone uses?

Because a payment webhook is where the consequence is immediate and the same delivery arriving twice is money rather than a duplicate row. Stripe signs each delivery with a secret from your dashboard, puts a timestamp inside what it signs, and publishes a helper that checks both together. The shape is the same for every provider that signs its deliveries. We do not integrate with Stripe or with any other provider; this page is about the endpoint you wrote.

Is comparing the signature with a normal equality check really a problem?

Yes, and it is the cheapest thing on this page to fix. String equality stops at the first byte that differs, so the time it took is a measurement of how much of the attacker's guess was correct, and enough attempts turn that measurement into the secret. Every language ships a constant-time comparison for exactly this. Use it, and compare the digests rather than the raw secrets.

What does a handler that gets this right look like?

Ours is small, and the shape is the point rather than the product. The PayPal endpoint verifies the delivery with PayPal before it does anything else, and an event it cannot verify is logged and dropped rather than recorded. The event id is claimed only after verification, because recording an unverified id would let anyone reserve a real one and starve the delivery that mattered. The work and the record of the work commit together, so nothing is left half applied. Your handler has to do the same things, in whatever framework it is written in.

Do you test my webhook endpoint by sending it a delivery?

No. We hold no signing secret of yours and cannot produce a delivery your provider would have signed, so nothing here replays traffic at you. What a run does establish is listed above: whether the endpoint accepts a body of the wrong shape, what it leaks when it fails, whether a signing secret is sitting in your source or your history, and whether ownership is enforced on the endpoints that change state.

My webhook works. Is that not enough?

Working and safe are different tests, and only one of them is being run in development. Delete the signature check and your integration still passes end to end, because the only thing posting to the endpoint is the provider. The failures arrive the first time somebody who is not your provider posts to it, or the first time your provider posts the same event twice.

Related

Free AI app security checkCheck for vulnerable dependenciesLovable security auditCursor security auditAI app security auditProduction readiness audit

Find out what is actually true of your application

Start with the layer you can run today. The report says which layers ran and what they could not reach, so the result is honest about its own limits.

Validate a repositoryScan a live app