HttpFixerBlogHeaders → Securing Webhooks With Signature Headers — Stripe, GitHub, Custom
Headers

Securing Webhooks With Signature Headers — Stripe, GitHub, Custom

Updated April 2026

Reading this? Verify your fix in real-time. Scan your API security headers → HeadersFixer

Your Stripe webhook endpoint processes payment events. Your GitHub webhook triggers deployments. Both accept POST requests from any IP address on the internet. Signature verification is the only thing stopping attackers from sending fake events.

How webhook signatures work

The sender (Stripe, GitHub, your own system) generates an HMAC-SHA256 of the request body using a shared secret, then includes it in a request header. Your endpoint computes the same HMAC and compares. If they match, the request is genuine.

Verify Stripe webhook signatures

const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), // raw body required (req, res) => { const sig = req.headers["stripe-signature"]; let event; try { event = stripe.webhooks.constructEvent( req.body, // raw buffer sig, process.env.STRIPE_WEBHOOK_SECRET // from Stripe dashboard ); } catch (err) { console.error(`Signature verification failed: ${err.message}`); return res.status(400).send(`Webhook Error: ${err.message}`); } // Signature valid — process the event switch (event.type) { case "payment_intent.succeeded": // fulfill order break; } res.json({ received: true }); }
);

Verify GitHub webhook signatures

const crypto = require("crypto");

function verifyGitHubSignature(payload, signature, secret) { const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(payload, "utf8") .digest("hex"); // Constant-time comparison — prevents timing attacks return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) );
}

app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["x-hub-signature-256"]; const body = req.body; if (!signature || !verifyGitHubSignature(body, signature, process.env.GITHUB_WEBHOOK_SECRET)) { return res.status(401).send("Invalid signature"); } const event = req.headers["x-github-event"]; // process event... res.sendStatus(200);
});

Build your own webhook system with signatures

# Sending side — sign the payload
const crypto = require("crypto");

function signPayload(payload, secret) { return "sha256=" + crypto .createHmac("sha256", secret) .update(JSON.stringify(payload)) .digest("hex");
}

async function sendWebhook(url, payload, secret) { const signature = signPayload(payload, secret); await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "X-Webhook-Signature": signature, "X-Webhook-Timestamp": Date.now().toString() }, body: JSON.stringify(payload) });
}

# Receiving side — verify
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["x-webhook-signature"]; const timestamp = req.headers["x-webhook-timestamp"]; // Reject requests older than 5 minutes (replay attack protection) if (Date.now() - parseInt(timestamp) > 300000) { return res.status(400).send("Request too old"); } const expected = "sha256=" + crypto .createHmac("sha256", process.env.WEBHOOK_SECRET) .update(req.body) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { return res.status(401).send("Invalid signature"); } // Safe to process const event = JSON.parse(req.body); res.sendStatus(200);
});

Critical: use raw body for signature verification

# If you parse JSON before verifying, the body bytes change
# Use express.raw() not express.json() for webhook endpoints

# Wrong — JSON parsing changes the bytes
app.use(express.json());
app.post("/webhook", (req, res) => { // req.body is now a JS object — signature will not match ❌
});

# Right — raw body preserved
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { // req.body is the original Buffer — signature matches ✅
});
Scan your API security headers → HeadersFixer
Check if your domain is on the HSTS preload list → HSTS Preload Checker