The Webhook Nobody Security-Reviews — And Why AI Agents Make It Worse
Day 16 — Webhooks: The API Nobody Puts Through Security Review
You reviewed every route in that new integration for auth — except one. `POST /webhooks/stripe` got waved through, because it didn't feel like an endpoint you were exposing. It felt like a mailbox for a partner you already trust. That feeling is the vulnerability.
The endpoint that got waved through
Run the tape on a typical security pass: every REST route gets an auth check, every GraphQL resolver gets field-level authz, rate limits get sized to the traffic you expect. Then someone lands on `POST /webhooks/stripe` or `POST /webhooks/github` and waves it through — 'that one's fine, it's just events coming in from a partner we trust.' Nobody asks the one question that matters: who else can reach that URL? It ships anyway.
What a webhook actually is
An API call is you reaching out — you hold the credentials, you send the request, you pick the moment. A webhook flips the whole arrangement. You hand a third party a URL and say 'call me when something happens,' and from that point on you don't control who initiates the request, when, or how many times. That flip — outbound trust becoming inbound trust — is the entire concept. Miss that, and nothing else about webhooks will make sense.
Why 'internal RPC' thinking breaks here
Most teams file webhook receivers in the same mental drawer as internal service-to-service calls — machine-to-machine, from a known partner, basically trusted. But the URL is public. Anyone who finds it — scraped from a leaked config, guessed off a predictable path, pulled out of a mobile app bundle, or just watched going over the wire — can POST to it. The right mental model isn't 'internal RPC endpoint.' It's 'public contact form that happens to accept JSON.' Nobody skips the authz review on a contact form because they trust the people who usually fill it out.
The concrete gaps that hide in that blind spot
- ▹No signature verification — nobody's checking the payload against a shared secret (an HMAC of the raw body, say), so anything that can reach the URL can forge an event: fake a 'payment succeeded,' fake a 'user upgraded,' fake a 'deploy completed' trigger. The endpoint has no way to tell a real partner from an attacker with curl.
- ▹No replay protection — a legitimate request captured somewhere (a proxy log, a request-debugging tool, a misconfigured retry queue) can be resent verbatim, and the receiver processes it again as if it just happened. No timestamp check, no nonce, no expiry window — old truth becomes new truth on replay.
- ▹No idempotency — the same event ID delivered twice, which is normal behavior for most providers (they retry on any non-2xx or timeout), gets processed twice: double refund, double inventory decrement, double onboarding email, double-charged agent action. This one doesn't even need an attacker — it's just what happens when a partner's retry logic meets a receiver that assumes 'exactly once.'
Where it surfaces
It rarely shows up in a pentest — pentests target the endpoints people remember to scope in, and webhooks don't make that list. It shows up in an audit finding, or a postmortem: someone notices a webhook receiver is reachable with zero signature checks, or a support ticket traces back to an event that got 'received twice,' or — the bad version — an incident where a forged payload triggered a real state change: a fake 'subscription activated' event, a fake 'build passed' hook that pushed straight to production. The receiver trusted the shape of the JSON more than it verified who sent it. The fix is usually a one-line HMAC check that should've been there on day one. The gap was never technical difficulty — it was that nobody treated the endpoint as attack surface.
The reframe
This has to get caught at design review, not incident review. When you scope a new webhook receiver, ask it the same three questions you'd ask of a public form: can anyone submit to this URL? What happens if they submit garbage, or something malicious-shaped, or the same payload five hundred times? And what's the blast radius if this endpoint is the only line of defense? If the honest answer to any of those is 'we haven't checked,' the endpoint isn't ready — no matter how much you trust the partner who's supposed to be the only one calling it.
Tie to ASE: agents reach for 'add a webhook' too casually
This is a live risk right now, because agentic coding tools make wiring up a third-party integration almost too easy — 'add a Stripe webhook,' 'add a Slack event subscription,' 'add a GitHub webhook to trigger the deploy agent.' An agent will happily scaffold the route, parse the JSON, and call the handler, because from where it's sitting that's just a function call with extra steps. It won't add signature verification, replay checks, or idempotency keys unless you ask for them explicitly — none of that shows up in the happy-path example it's pattern-matching against. And the stakes are higher than they used to be: a lot of these new receivers don't just update a database row, they trigger an autonomous agent action — kick off a deployment, spend budget on an LLM call, send a notification, mutate infra state. An unauthenticated webhook that triggers an agent is, plainly, an unauthenticated remote trigger for that agent. Put 'verify the signature, reject replays, dedupe by event ID' in the same prompt or template you use for 'add a webhook' — the same instinct that stops you from accepting an agent-generated SQL query without asking about parameterization.
Extend your knowledge
- ▹Go read how Stripe documents webhook signature verification — the `Stripe-Signature` header scheme is a solid reference for HMAC + timestamp + tolerance window done right.
- ▹Check GitHub's webhook docs on the `X-Hub-Signature-256` header too — a second real-world example, handy for comparing conventions across providers.
- ▹Pick one webhook receiver you own and audit it this week: confirm signature verification, a replay window, and idempotency-by-event-ID are actually present — not assumed.
- ▹If you're using an agent to scaffold a new integration, spell out 'verify signature, reject replays, dedupe by event ID' in the prompt or template. Don't wait for the agent to ask.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.