NOTES & IDEAS
Anyone Can Knock on Your Webhook - Here's How to Check It's Really GitHub
A webhook is a public URL that makes your app do real, billable work. If you don't check who sent the request, anyone who finds the URL can set it off. Here's how to verify it's really GitHub, explained with a wax seal.
When you set up a webhook, you hand a company a public URL and say "send a request here whenever something happens." In AI PR Reviewer, GitHub hits that URL on every pull request, and the endpoint kicks off real, billable work - pulling the code, calling an AI, posting comments.
Here's the uncomfortable bit: that URL is reachable by anyone. Webhook URLs leak
- into logs, into browser history, into misconfigured proxies. If the endpoint trusts every request it gets, anyone who finds the URL can send fake "pull request" events and make your app burn money on command. So before doing anything, the endpoint has to answer one question: did this really come from GitHub?
Let me explain how, with a wax seal.
How GitHub proves a letter is really from GitHub
Think of each webhook request as a letter dropping through your mail slot. You want to be sure it's genuinely from GitHub and not a forgery.
So when you create the webhook, you and GitHub agree on a secret password that only the two of you know. On every letter, GitHub does this: it mixes the secret password together with the letter's exact contents through a special one-way blender (its name is HMAC-SHA256) and stamps the resulting smudge on the envelope. That smudge is the "wax seal."
On your end, you do the same blend with your copy of the password and the letter you received. If your smudge matches the one stamped on the envelope, two things are proven at once:
- It's really GitHub - only someone with the secret password could produce a matching smudge.
- Nobody tampered with it - change one word of the letter and the smudge wouldn't match anymore.
You can't fake the seal without the password, and you can't work the password backward out of the seal. That's the whole guarantee.
The check in code
In the AI PR reviewer, this runs before any webhook handler gets to do its thing:
import { createHmac, timingSafeEqual } from 'crypto';
export const verifyGitHubWebhook = (req, res, next) => {
const signature = req.headers['x-hub-signature-256'] as string; // GitHub's seal
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET; // our password
if (!signature) {
return res.status(401).json({ message: 'Forbidden: Missing signature' });
}
// make our own seal from the letter + our password
const hmac = createHmac('sha256', webhookSecret);
const digest = Buffer.from('sha256=' + hmac.update(JSON.stringify(req.body)).digest('hex'), 'utf8');
const checksum = Buffer.from(signature, 'utf8');
// compare our seal to GitHub's; reject if they differ
if (checksum.length !== digest.length || !timingSafeEqual(digest, checksum)) {
return res.status(401).json({ message: 'Forbidden: Invalid signature' });
}
next();
};
Make your own seal, compare it to the one on the envelope, reject on a mismatch. Simple - but there are two subtle traps in that comparison.
Trap 1: don't compare the seals with a normal ===
The obvious way to compare is if (digest === checksum). It also quietly opens a
door.
A normal comparison is lazy: it goes character by character and stops the instant it finds one that doesn't match. So a guess that's wrong on the first character is rejected a tiny bit faster than a guess that's wrong on the twentieth. An attacker who can measure how long your replies take can use that to feel out a valid seal one character at a time. It sounds like spy-movie stuff, but these "timing attacks" are real and well documented.
crypto.timingSafeEqual compares in constant time - it always checks every
character, so the time it takes never hints at where the difference was. Any time
you compare secrets, seals, or tokens, reach for this, never ===.
(The length check just before it is there because timingSafeEqual errors out if the
two sides are different lengths - so you guard against that first.)
Trap 2: seal the exact letter GitHub sealed
GitHub makes its smudge from the raw bytes of the letter. If your web framework opens the letter, reads it into an object, and you then re-print it back into text to blend it (which the snippet above does), you have to be sure your re-printed version is byte-for-byte identical to what GitHub blended - same ordering, same spacing, same everything. Any tiny difference and a genuine GitHub request fails the check.
The bulletproof fix is to grab the raw letter before anything reshapes it, and blend that. If I were hardening this further, that's the first change I'd make - it kills a whole family of "works for most requests, mysteriously fails for some" bugs.
One last footgun
During local development it's tempting to skip the check when the password isn't set.
Fine on your laptop, dangerous everywhere else - because if GITHUB_WEBHOOK_SECRET
is ever missing in production, the endpoint silently accepts everything. If you
allow a dev shortcut, make it loud (warn at startup) and lock it behind an
explicit "only when not in production" check so it can never accidentally ship wide
open.
Takeaway
A webhook is an open, public door until you put a lock on it. Checking the signature
is that lock: it turns "anyone can set this off" into "only the company holding the
shared password can." The recipe is small - re-make the seal over the exact bytes,
compare it with a constant-time check, and reject anything that doesn't match -
but every piece earns its place. Skip the check and you've built a remote trigger for
strangers; use === instead of the constant-time compare and you've left a side door
cracked. Get both right, and the endpoint only ever works when GitHub really knocked.