Draft, not yet published
Securing webhooks in an AI-generated app: signature verification done right
7 July 2026· 5 min read · by Stackbastion
Stripe, or your email provider, or some other service posts data to a URL in your app when something happens. A payment clears. An email bounces. The AI that built your app wired up the endpoint so it works. But it probably never checked who is actually calling it. Right now, anyone who guesses that URL can fake a “payment succeeded” event, and your code will believe them.
Why this happens
A webhook is just an HTTP endpoint. The sender POSTs some JSON, your app reads it and acts on it. When an AI tool scaffolds this, it optimizes for the happy path: receive the data, parse it, do the thing. That demo works, so it looks done.
The missing piece is authentication. Your public webhook URL is not a secret. It shows up in logs, in browser network tabs, in error trackers, sometimes in the AI’s own generated docs. If your handler acts on any POST it receives, an attacker can send their own POST that looks exactly like a real one:
{ "type": "payment_intent.succeeded", "data": { "amount": 49900 } }
Your code sees “payment succeeded,” grants the account, ships the goods. No real money moved. This isn’t theoretical. It’s one of the most common ways vibe-coded apps that touch money get drained.
Real webhook providers already solved this. They sign every request with a shared secret, so you can prove the message came from them and wasn’t tampered with. The catch: you have to actually check the signature. Most generated code doesn’t.
How to fix it
The provider gives you a signing secret. They use it to compute an HMAC (a keyed hash) of the request body and send that hash in a header. You recompute the same hash on your side with the same secret. If the two match, the message is genuine and unchanged. If not, you reject it.
Three rules make this safe:
- Hash the raw request body, exactly as received. Not the re-serialized JSON. If you parse and re-stringify first, the bytes change and the hash won’t match.
- Compare hashes with a timing-safe function, not
===. A plain compare leaks, character by character, how much of the signature was right, which lets an attacker guess it. - Reject old requests. Include the timestamp in the signed data and refuse anything older than a few minutes, so a captured request can’t be replayed later.
The clean way: use the provider’s SDK
Stripe’s library does all three for you. Give it the raw body, the header, and your secret:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Framework note: the body MUST be the raw bytes, not parsed JSON.
// In Express: app.post("/webhook", express.raw({ type: "application/json" }), handler)
export function handler(req, res) {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
// Signature failed, timestamp too old, or body was tampered with.
console.warn("Rejected webhook:", err.message);
return res.status(400).send("Invalid signature");
}
// Only now do we trust it.
if (event.type === "payment_intent.succeeded") {
// fulfill the order
}
res.status(200).send("ok");
}
If your generated code has express.json() running before the webhook route, this fails, because the raw body is already gone. Add the express.raw() middleware on the webhook route specifically.
The manual way: verify HMAC yourself
For a provider without an SDK, or to see what’s actually happening, verify it by hand. This uses Node’s built-in crypto, no extra package:
import crypto from "node:crypto";
const MAX_AGE_SECONDS = 300; // reject anything older than 5 minutes
export function verifyWebhook(rawBody, signatureHeader, timestamp, secret) {
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (Number.isNaN(age) || age > MAX_AGE_SECONDS) return false;
// 2. Recompute the HMAC over timestamp + body, exactly as the sender did
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest("hex");
// 3. Timing-safe compare. Buffers must be equal length first.
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
crypto.timingSafeEqual takes the same amount of time whether the strings differ at character 1 or character 60, so it doesn’t leak the answer. The length check before it matters, because timingSafeEqual throws if the buffers are different sizes.
Then handle failure loudly
When verification fails, return a 400 and log it. Don’t swallow the error and return 200, and never process the payload “just in case.” A failed signature means either a misconfigured secret or an attacker. Both are things you want to see in your logs.
Or, we do it for you
Webhook signature checks, replay protection, and secret rotation are part of every production hardening pass we do. If you’re not sure whether your endpoints verify anything today, get a free audit and we’ll tell you exactly what’s exposed.
FAQ
What if my webhook provider doesn’t sign requests at all?
Then treat the endpoint as public and untrusted. Don’t take any irreversible action based on the payload alone. Instead, use the webhook only as a nudge, then call the provider’s API yourself to confirm the real state before acting. Better still, pick a provider that signs.
Do I need HTTPS if I’m already verifying signatures?
Yes, both. Signatures prove the message is genuine and unchanged. HTTPS keeps it private in transit and stops anyone reading secrets or tokens along the way. They solve different problems, so use both.
Where should the webhook secret live?
In your secrets manager or environment variables, never in the code and never committed to git. If it leaks, an attacker can forge valid signatures and you’re back to square one. Rotate it if you ever suspect it’s exposed.
Why does re-parsing the body break verification?
The signature is computed over the exact bytes the sender transmitted. Parse JSON and stringify it again and you can change spacing, key order, or number formatting. Even one different byte produces a completely different hash. Always hash the raw body before any parsing.