Blog
Anyone Can POST to Your Webhook
- laravel
- typescript
- webhooks
- security
A webhook endpoint is a public URL that accepts a POST and then changes something in your database. Marks an order paid. Cancels a subscription. Upgrades an account.
Say that sentence out loud and it sounds a lot less like plumbing.
The provider knows the URL. So does anybody who reads your frontend bundle, your logs, a screenshot in a support ticket, or just guesses /webhooks/stripe. The URL is not a secret. It was never supposed to be.
The signature is what tells you the request actually came from who it says it did.
What the signature is
Most providers do some version of the same thing. They take a shared secret, compute an HMAC (a hash keyed with that secret) over the request body, and put the result in a header. Many also include a timestamp in what they sign.
Your job is to compute the same HMAC on your side and check that it matches. If it doesn't, the request didn't come from someone holding the secret, and you stop right there.
The exact header names and signing format are different per provider. Read their docs. The examples below use made up headers (X-Signature and X-Signature-Timestamp) and a timestamp.body signing format to show the shape.
Laravel: sign the raw body
public function __invoke(Request $request)
{
$payload = $request->getContent();
$timestamp = $request->header('X-Signature-Timestamp');
$signature = $request->header('X-Signature');
if (! $timestamp || ! $signature || abs(time() - (int) $timestamp) > 300) {
abort(401);
}
$expected = hash_hmac(
'sha256',
$timestamp.'.'.$payload,
config('services.acme.webhook_secret'),
);
if (! hash_equals($expected, $signature)) {
abort(401);
}
HandleAcmeEvent::dispatch(json_decode($payload, true));
return response()->noContent();
}Three details in there matter more than they look.
$request->getContent() is the raw body, exactly as it arrived. Not $request->all() and then json_encode() it back. Decoding and re-encoding JSON can change whitespace, key order, and escaping, and the HMAC is over bytes, not meaning. Re-encoded JSON is a different string, so the signature fails, and somebody "fixes" it by commenting out the check.
hash_equals() instead of ===. A normal string compare can bail out at the first wrong character, which leaks timing information. hash_equals takes the same time either way.
The timestamp check. More on that below.
One Laravel gotcha: webhook routes in web.php will hit CSRF protection and fail with a 419. Either put them in api.php or exclude them. On Laravel 11 and up that lives in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})Excluding the route from CSRF is fine. The signature check is the protection now. Excluding it and skipping the signature is the bug.
TypeScript: don't let the framework parse it first
This is where Node apps usually trip. By the time your handler runs, a JSON body parser has already turned the body into an object, and the original bytes are gone.
In a Next.js route handler, read the text yourself:
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const ts = req.headers.get("x-signature-timestamp") ?? "";
const sig = req.headers.get("x-signature") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (!ts || !Number.isFinite(age) || age > 300) {
return new Response("stale", { status: 401 });
}
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(`${ts}.${body}`)
.digest("hex");
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
await enqueue(JSON.parse(body));
return new Response(null, { status: 204 });
}Note the length check. timingSafeEqual throws if the two buffers aren't the same length, so without it a garbage header turns into a 500 instead of a 401.
In Express, give the webhook route its own raw parser so req.body is a Buffer:
app.post(
"/webhooks/acme",
express.raw({ type: "application/json" }),
handleAcmeWebhook,
);If app.use(express.json()) runs before that route, you're back to signing a parsed object. Register the webhook route first, or scope the JSON parser to the routes that need it.
Old requests are still valid requests
A signature proves the provider sent this payload. It doesn't prove they sent it just now.
If someone captures one real request (from a log, a proxy, a debugging tool), they can send it again and the signature will still match. That's a replay. Signing a timestamp along with the body and rejecting anything older than a few minutes closes most of that window. Five minutes is a common tolerance.
The rest of the window is covered by the thing you should already be doing: store the event id and skip events you've processed. I wrote about that in Make It Safe to Run Twice, so I won't repeat it here.
Use the SDK when there is one
If the provider ships a verification helper, use it. Stripe's libraries have Webhook::constructEvent() in PHP and stripe.webhooks.constructEvent() in Node, and they know Stripe's header format and tolerance better than your hand rolled version does. Laravel Cashier verifies Stripe webhooks for you when the webhook secret is configured.
Hand rolling it is for providers that only give you a paragraph in the docs. Which is a lot of them.
Trust the event, not the details
Even a correctly signed payload is a message about something that happened. It can be out of order, a retry of something you already handled, or describing state that has since changed.
For anything that moves money or access, I like treating the webhook as a nudge. Take the id from the event, fetch the current object from the provider's API, and act on that. It costs an extra request. It also means you're never acting on a stale snapshot.
And it's still input. Validate the parts you use, same as any other request.
Test the rejection
The happy path gets tested because the feature doesn't work without it. The rejection path rarely gets a test, which is how a "temporary" bypass ships.
public function test_it_rejects_a_bad_signature(): void
{
$this->postJson('/webhooks/acme', ['type' => 'order.paid'], [
'X-Signature-Timestamp' => (string) time(),
'X-Signature' => 'nope',
])->assertUnauthorized();
Queue::assertNothingPushed();
}(With Queue::fake() in your setup.) Add one for a stale timestamp too. Two tiny tests, and nobody can quietly turn the lock off.
The short version
Your webhook URL is public. Verify the signature over the raw body, compare it with a constant time function, reject old timestamps, dedupe by event id, and treat the payload as a hint to go check. Then hand the real work to a queue and return fast.
It's maybe twenty lines. It's also the difference between "the provider told us the order was paid" and "someone with curl told us the order was paid."