BuilderProof editorial team12 min read41 views

Can Anyone POST to Your Webhook Endpoint? An Inbound Verification Axis (September 2026)

A webhook endpoint that never verifies its caller behaves identically to one that does, for every request an honest provider ever sends. We pre-register a seven-signal rubric for inbound webhook verification in generated apps, and name the trap that hides it.

Blueprint line drawing: an envelope travels along a line to a circular seal and a vertical barrier, with a clock dial above connected by a dotted line, and an empty vessel accepting data beyond the barrier.
Blueprint line drawing: an envelope travels along a line to a circular seal and a vertical barrier, with a clock dial above connected by a dotted line, and an empty vessel accepting data beyond the barrier.
On this page

Quick answer. Most generated apps that receive webhooks will accept a request from anyone who knows the URL. The endpoint works perfectly, because the only caller anyone ever tests with is the honest one. This is a proposal for a new BuilderProof axis that measures one narrow, checkable thing: when a generated application exposes a route for a third party to POST events to, does that route establish that the request actually came from that third party, and does it do so in the specific way the provider's own documentation requires. We are pre-registering the rubric before we measure anything. No builder is scored here, and no placement is implied.

Why this needs its own axis

A webhook endpoint is a strange thing for a code generator to emit. It is a public, unauthenticated-looking POST route that performs privileged work: marking an invoice paid, provisioning a seat, kicking off a deployment. It has no session, no logged-in user and no bearer token. Its only defence is a cryptographic proof carried in a header.

The defect is invisible in the only way anyone ever exercises it. Stripe sends a real event, the handler runs, the invoice is marked paid, the endpoint returns 200. A completely unverified endpoint and a correctly verified one are byte-for-byte identical in that trace. They differ only in their response to a request nobody sends during development: a forged one.

We are naming this pattern the honest-caller illusion. Every observation you can cheaply make about a webhook endpoint comes from the honest caller, and the honest caller always passes. Confidence accumulates from evidence that is structurally incapable of distinguishing the working case from the broken one.

What the providers actually say

Stripe Stripe's webhook documentation is explicit that the check is not optional and that a common framework behaviour breaks it. On the body, it states: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail."

On replay, it defines the attack and the mitigation together: "A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them." The timestamp is inside the signed payload, so it cannot be edited without breaking the signature, and Stripe's libraries carry "a default tolerance of 5 minutes between the timestamp and the current time." It then flags the configuration that quietly removes the protection: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely."

GitHub GitHub's guidance covers the comparison itself. It sends an HMAC hex digest in an X-Hub-Signature-256 header, and it is blunt about how to check it: "Never use a plain == operator. Instead consider using a method like secure_compare or crypto.timingSafeEqual, which performs a 'constant time' string comparison to help mitigate certain timing attacks against regular equality operators, or regular loops in JIT-optimized languages."

GitHub also publishes a test vector, which is unusual and useful: the secret It's a Secret to Everybody over the payload Hello, World! must produce 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. We computed that HMAC-SHA256 independently while preparing this proposal and it reproduces exactly. That matters for a benchmark, because it means an implementation can be checked deterministically against a published constant rather than against our opinion.

Standard Webhooks The Standard Webhooks specification, which exists precisely because every provider does this differently, states the consequence of getting the comparison wrong in stronger terms: "When verifying symmetric signatures, use a constant time comparison function to compare the calculated with the expected signature. Failing to do so can expose consumers to timing-attacks and turn them into signing oracles." It also asks implementers to "verify the webhook-timestamp header has a timestamp that is within some allowable tolerance of the current timestamp to prevent replay attacks."

The trap that appears when you follow the advice

The most interesting finding in preparing this axis came from reading two primary sources against each other, and it is the reason the rubric scores the failure mode separately from the comparison.

Node.js GitHub tells you to use crypto.timingSafeEqual. Node's own documentation for that function says it "compares the underlying bytes ... using a constant-time algorithm" and "does not leak timing information that would allow an attacker to guess one of the values", which is exactly what you want. Two sentences later it adds a constraint: the two arguments "must have the same byte length. An error is thrown if a and b have different byte lengths."

The signature is supplied by the caller. On a forged request, its length is chosen by the attacker. So the literal implementation of the vendor's own recommendation contains a path where a hostile caller controls whether your verification function returns false or throws. What happens next is entirely down to the surrounding handler: a thrown exception may surface as a 500 rather than a rejection, and in some framework shapes an unhandled throw inside a try block that wraps more than the comparison can take a different branch than the designed reject path. Node says as much itself, in a caveat that is easy to skim past: "Use of crypto.timingSafeEqual does not guarantee that the surrounding code is timing-safe."

This is not a criticism of any of the three documents. Each is correct in isolation. It is an observation that the correct behaviour lives in the seam between them, that a code-generating model imitating any one of them will not see the seam, and that a benchmark should therefore probe the seam directly rather than grep for the presence of a function call.

Two places this axis pulls against its neighbours

Axes in this series are supposed to be independent. These two are not, and we would rather publish the conflict than pretend it away.

Raw body against body validation. Our input-validation and data-integrity axis gives its heaviest weight to validating the request body on the server. The ordinary way to do that is a parser or middleware that consumes the body and hands you a typed object. Stripe's documentation says in plain words that any manipulation of the raw body causes verification to fail. The remedies genuinely interfere: the standard route to a high score on one axis is a documented way to score zero on this one. The resolution is ordering, read the raw bytes and verify first, then parse and validate the same bytes, and a rubric that did not separate the two would hide the ordering entirely.

Signature headers against a uniform auth surface. Our API-design-consistency axis rewards one credential-passing mechanism across the whole emitted surface. A webhook route cannot participate in that. It has no session to check, and its credential is a provider-specific header computed over the body. A project that maximises auth-surface uniformity by putting the webhook route behind the same session middleware as everything else does not merely score badly here, it breaks: the provider has no session and every delivery fails. Uniformity and verifiability point in opposite directions on exactly this one route.

What this axis is not

It is not the background-work correctness axis. That axis has a trigger-authorization signal, and the two are easy to confuse because both are about a public route proving who called it. The boundary is direction and mechanism. Trigger authorization concerns a route we own, called by a scheduler we configured, holding a secret we minted, compared as one opaque string in a header. This axis concerns a route we own, called by a third party we do not control, holding a key that party issued, verified as a message authentication code computed over the request body.

They move in opposite directions, and both directions occur. A project can compare its cron secret impeccably and leave its payment webhook wide open. A project can verify every provider signature correctly and still expose a scheduled route that any caller on the internet can trigger.

It is also not abuse and rate limiting. That axis asks how many requests a public route will accept. This one asks which requests it should have accepted at all. A perfectly throttled unverified endpoint accepts forged events at a polite and sustainable rate.

Finally, replay rejection here is a security property, not a deduplication one. Rejecting a re-transmitted capture because its timestamp is outside the tolerance window is a different question from processing an honestly redelivered event only once, which belongs to the duplicate-run signal on the background-work axis. A handler can be perfectly idempotent and still accept an attacker's replay.

The proposed rubric

Weights sum to 100 and are provisional until the first measured run.

Scroll to see more

SignalWeightWhat a failing case looks like
Verification present at all24The handler reads the event body and acts on it without ever computing a signature. The provider's secret is either unused or absent from the environment
Raw body preserved for the check18The signature is computed over a re-serialised object rather than the received bytes, so verification either always fails or is quietly skipped to make the endpoint work
Constant-time comparison15The computed digest is compared with ===, == or a string equality helper, rather than a documented constant-time primitive
Replay window enforced14The signature is checked but the timestamp is not, or a tolerance of zero or an effectively unbounded tolerance is configured, so an intercepted valid request is accepted indefinitely
Verification precedes side effects12The handler writes a row, sends a mail or enqueues work before the signature check, or returns 200 on a path that reaches business logic ahead of the check
Failure mode on a bad or missing signature10A forged, truncated or absent signature produces a 500, an unhandled throw, or a 200, rather than a deliberate rejection
Secret provenance and per-endpoint scoping7The signing secret is hardcoded, shared across providers or environments, or read from a client-visible variable

Four structural postures

We expect emitted handlers to fall into four groups. These are shapes, not scores, and no builder is assigned to one here.

  1. Open. The route accepts and acts on any POST. Verification is absent.
  2. Parsed. The route reads the signature header and does something with it that is not a verification: checks it is present, checks a prefix, or compares it to a value that is not a MAC over the body.
  3. Verified. A MAC is computed over the received bytes with the provider's secret and compared in constant time. Forged bodies are rejected.
  4. Hardened. Verified, plus a bounded replay window, verification strictly ahead of any side effect, and a deliberate rejection status for every malformed case.

The measurement protocol

  1. Prompt the builder to add an integration whose provider sends webhooks, without naming verification as a requirement.
  2. Record the emitted route, the runtime and the framework body-handling defaults.
  3. Determine whether a signing secret is referenced at all, and where it is read from.
  4. Send a well-formed delivery with a correct signature. Confirm the happy path works, so that later rejections are attributable.
  5. Send the identical body with the signature header removed.
  6. Send the identical body with a signature that is valid hex but wrong.
  7. Send the identical body with a signature of a different byte length from the expected digest, which is the case that separates a rejection from a throw.
  8. Send a correctly signed body with a timestamp well outside any plausible tolerance.
  9. Replay a capture of step 4 verbatim, unchanged, some minutes later.
  10. Control. Where the provider publishes a test vector, check the emitted verification routine against it directly. GitHub's is the one we have verified reproduces, and a handler that cannot agree with a published constant does not need a live delivery to be judged.

Steps 5 through 9 are the axis. Step 4 exists so that a failure in those steps is known to be a rejection rather than a broken endpoint, and step 10 exists so that at least one finding does not depend on our own harness being correct.

Open questions before the first run

Three things are unresolved and we would rather say so now than quietly decide them later.

Whether a builder that emits a provider's official SDK verification call, correctly, should score identically to one that hand-rolls a correct HMAC. We are inclined to say yes, because the axis measures the emitted application's behaviour rather than its authorship, but it does change what the score means.

Whether an absent replay window should be scored as a partial failure or as a full one, given that Stripe ships a five-minute default that a handler receives without asking, while a hand-rolled verification receives nothing by default.

Whether a route that verifies but returns 200 on rejection, which several delivery systems will read as success and stop retrying, should be penalised under the failure-mode signal or treated as a separate concern.

Comments and corrections are welcome, as always, before this becomes a measured axis rather than a proposal.

References

  1. Stripe. (2026). Receive Stripe events in your webhook endpoint. Raw-body requirement, Stripe-Signature, replay prevention, the five-minute default tolerance and the warning against a tolerance of zero.
  2. GitHub. (2026). Validating webhook deliveries. X-Hub-Signature-256, HMAC hex digest, the instruction never to use a plain equality operator, and the published test vector.
  3. Node.js. (2026). Crypto: crypto.timingSafeEqual(a, b). Constant-time comparison, the equal-byte-length requirement, the thrown error on length mismatch, and the caveat about surrounding code.
  4. Standard Webhooks. (2026). Standard Webhooks specification. Signing oracles, timestamp tolerance, and the role of a unique delivery identifier.

Cite this benchmark

Plain text
BuilderProof editorial team. "Can Anyone POST to Your Webhook Endpoint? An Inbound Verification Axis (September 2026)". BuilderProof, September 2026. https://www.builderproof.org/benchmarks/can-anyone-post-to-your-webhook-endpoint-verification-axis-september-2026.
BibTeX
@misc{builderproof-can-anyone-post-to-your-webhook-endpoint-verification-axis-september-2026,
  title  = {{Can Anyone POST to Your Webhook Endpoint? An Inbound Verification Axis (September 2026)}},
  author = {{BuilderProof editorial team}},
  year   = {2026},
  month  = {sep},
  howpublished = {\url{https://www.builderproof.org/benchmarks/can-anyone-post-to-your-webhook-endpoint-verification-axis-september-2026}},
  note   = {BuilderProof, builderproof.org}
}

Frequently asked questions

Why can a missing webhook signature check stay invisible in testing?

Because every request a developer sees during integration comes from the provider itself, and the provider's requests are genuine. A route that verifies nothing and a route that verifies correctly produce the same 200 response, the same database write and the same log line for an authentic delivery. They differ only on a forged request, which nobody sends by accident. We call this the honest-caller illusion: the available evidence is structurally incapable of separating the working case from the broken one.

Why does signature verification need the raw request body?

Because the signature is a message authentication code computed over the exact bytes the provider sent. Stripe's documentation states that it requires the raw body and warns that if you are using a framework you should make sure it does not manipulate the raw body, because any manipulation causes verification to fail. Parsing JSON and re-serialising it can change key order, whitespace or number formatting, and the recomputed code will then not match.

Is comparing two signature strings with a normal equality operator good enough?

The providers say no. GitHub instructs implementers never to use a plain equality operator and to use a constant-time comparison such as secure_compare or crypto.timingSafeEqual instead. The Standard Webhooks specification puts the consequence more strongly, warning that failing to use a constant-time comparison can expose consumers to timing attacks and turn them into signing oracles.

What is a webhook replay attack and how is it prevented?

Stripe defines it as an attacker intercepting a valid payload and its signature and then re-transmitting them. The mitigation is a timestamp inside the signed payload, so it cannot be altered without invalidating the signature, combined with a tolerance window on the receiving side. Stripe's libraries default to a five-minute tolerance and its documentation warns specifically against setting a tolerance of zero, because that disables the recency check entirely.

Does this axis overlap with the background-work correctness axis?

No, and the boundary is direction and mechanism. The background-work axis has a trigger-authorization signal about a route we own being called by a scheduler we configured, using a secret we minted and compared as one opaque header value. This axis is about a route we own being called by a third party we do not control, using a key that party issued, verified as a code computed over the request body. The two move independently: an app can compare its cron secret impeccably and leave its payment webhook open, or verify every provider signature and still expose an unprotected scheduled route.

Are any AI app builders scored on this axis yet?

No. This is a pre-registration of a proposed rubric, published before any measurement, so that the method is fixed in advance of the results. No builder is placed, ranked or assigned a posture in this article.