How to verify HMAC-SHA256 signed webhooks in Node and Python
If your app exposes an endpoint that another service calls (a webhook, a scheduled job URL, a callback), anyone who discovers the URL can call it too. Signed requests fix that. The sender computes an HMAC of the request
If your app exposes an endpoint that another service calls (a webhook, a scheduled job URL, a callback), anyone who discovers the URL can call it too. Signed requests fix that. The sender computes an HMAC of the request with a shared secret, sends it in a header, and you recompute it and compare. Here's how to do it correctly, including the parts most implementations get wrong.
How HMAC signing works
- You and the sender share a secret, for example a long random string.
- For each request, the sender computes
HMAC-SHA256(secret, message), where the message is usually a timestamp plus the raw request body. - The sender puts the signature (and the timestamp) in headers.
- You compute the same HMAC over the same message and compare.
If someone changes the body, or doesn't know the secret, the signatures won't match.
The exact header names and message format vary by sender, so check their docs. The examples below assume headers called X-Signature (hex) and X-Timestamp (Unix seconds), signing timestamp + "." + body.
Rule 1: use the raw body
The signature covers the exact bytes that were sent. If your framework parses JSON and you re-serialize it, key order and whitespace can change, and the signature won't match. Capture the raw body.
Rule 2: compare in constant time
A normal == stops at the first different character, which leaks timing information. Use a constant-time compare.
Rule 3: check the timestamp
Without a timestamp check, an attacker who captures one valid request can replay it forever. Reject requests older than a few minutes.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
app.post("/jobs/run", express.raw({ type: "*/*" }), (req, res) => {
const signature = req.get("X-Signature") || "";
const timestamp = req.get("X-Timestamp") || "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > TOLERANCE_SECONDS) {
return res.status(401).send("stale or missing timestamp");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(req.body) // raw Buffer
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
// ... do the work
res.status(200).json({ ok: true });
});
app.listen(3000);
express.raw() keeps the body as a Buffer. timingSafeEqual throws if lengths differ, hence the length check first.
Python (Flask)
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"].encode()
TOLERANCE = 300
@app.post("/jobs/run")
def run_job():
signature = request.headers.get("X-Signature", "")
timestamp = request.headers.get("X-Timestamp", "")
try:
if abs(time.time() - int(timestamp)) > TOLERANCE:
abort(401)
except ValueError:
abort(401)
body = request.get_data() # raw bytes
expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
payload = json.loads(body)
# ... do the work
return {"ok": True}
Call request.get_data() before anything touches request.json, so you get the original bytes.
Test it locally
Generate a signed request by hand:
SECRET='test-secret'
TS=$(date +%s)
BODY='{"task":"cleanup"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -s -X POST localhost:3000/jobs/run \
-H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
-H 'content-type: application/json' -d "$BODY"
Then change one character in the body, or use an old timestamp, and confirm you get a 401.
Rotating secrets
Support two secrets during a rotation: accept a signature that matches either one, switch the sender to the new secret, then remove the old one. Keep secrets in environment variables or a secret manager, never in the repo.
Combine with idempotency
A valid signature proves who sent the request, not that it's the first time you've seen it. Retries are legitimate duplicates with valid signatures, so handlers should still be idempotent.
Signed scheduled calls
delay.run signs the HTTP requests it sends with HMAC-SHA256 and a timestamp, so the endpoint it calls on a schedule can be verified with code like the above.
Related reading
- Make scheduled job handlers idempotent: safe to run twice
- Heartbeat checks: how to know when a scheduled job didn't run
- How to log cron job output so you can debug it later
- Cron vs systemd timers: which should run your scheduled jobs?
- Dead letter queues for HTTP jobs: what they are and how to use one
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.