What "signing a request" actually means
When a webhook provider signs a request, they take the body bytes (and sometimes a timestamp), pass them through a cryptographic function with a key only they and you know, and put the resulting bytes in a header. You repeat the same function with the same key and compare results. If they match, the body is provably from them, unmodified.
That's the whole concept. The interesting part is which cryptographic function — and which kind of key — the provider picked.
The three families
HMAC (symmetric) — both sides hold the same secret. Compute HMAC-SHA-256 over the body, compare. Cheap, fast, simple. Used by Stripe, GitHub, Shopify, Slack, almost everyone. The downside: anyone with the secret can sign — so the secret can never leak (and that's why it's never exposed in client-side code).
Ed25519 (asymmetric) — the provider holds a private key and gives you their public key. They sign with private; you verify with public. The public key is safe to publish. Used by Discord, Mastodon, ActivityPub. The advantage: even a leaked verification key on your side doesn't let an attacker forge requests.
RSA-PSS / RSA-SHA-256 (asymmetric) — same idea as Ed25519 but with RSA. Older, slower, larger keys. Used by SAML, some bank APIs, some legacy enterprise integrations.
For new projects, you'll see HMAC if speed and simplicity matter (>99% of webhook providers) and Ed25519 if the provider wants to publish a verification key (Discord, anything ActivityPub-shaped).
Why this isn't TLS
A common confusion: "isn't HTTPS already authenticated?" HTTPS proves the server to the client (that you're talking to the right host). It says nothing about who originated the request to that server. If an attacker compromises the network between Stripe and you (DNS poisoning, BGP hijack, malicious proxy), HTTPS doesn't help — they can serve a valid TLS cert for their host. Request signing proves the body came from the actual provider.
The pattern, regardless of algorithm
1. Provider computes signature over (timestamp + body) using their key.
2. Sends the request with the signature and timestamp in headers.
3. You re-compute the signature using your copy of the key.
4. Compare in constant time (timing attacks are real).
5. Verify the timestamp is recent (5 minutes is the usual window).
Constant-time compare matters. signature === computed short-circuits as soon as it finds a mismatching byte. An attacker measuring response time can use this to brute-force the signature byte-by-byte. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), subtle.ConstantTimeCompare (Go).
The replay window
Every provider includes a timestamp in the signing payload and rejects requests older than ~5 minutes. This is to prevent replay — even if an attacker captures a valid signed request off the wire (good luck with TLS, but humor us), they can only re-deliver it for a few minutes.
The downside: your servers' clocks must be within ~30s of the provider's. NTP solves this; if you're seeing intermittent signature failures, the first thing to check is clock drift.
Verifying without breaking middleware
The #1 cause of "my signature doesn't match" bugs: middleware that parses the body before you verify. Body-parsers (express.json(), @nestjs/common's default pipe, FastAPI's request.json()) re-serialize the JSON. The re-serialized bytes are not byte-for-byte the original — keys may be reordered, whitespace may change. The signature was computed over the original; verification fails.
Fix: capture the raw request body before any parser runs, verify against that, then parse.
// Express
app.post('/stripe-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, // raw Buffer, untouched
req.headers['stripe-signature'],
WEBHOOK_SECRET,
)
// ...
})
If you need both raw and parsed, store the raw bytes on req.rawBody first, then run the JSON parser.
Signature verification tools
The /tools/webhook-signature-verifier on lrok.io is a browser-side verifier for Stripe, GitHub, and Shopify signatures — paste the body, paste the header, paste the secret, get a yes/no. No upload, no server.
For local debugging, the request inspector at /dashboard captures the raw body bytes verbatim — so when verification fails, you can copy the exact bytes the provider sent and run them against your verifier.
The right thing to ship
Whatever your provider uses, do all three of:
- Verify the signature against raw body bytes.
- Verify the timestamp is within 5 minutes.
- Reject mismatches with HTTP 401 (not 400 — 400 implies "client error", retry-able).
Skip any of these and a sufficiently motivated attacker can forge events.