const crypto = require('crypto');
function computeSignature(secret, timestamp, payload) {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`, 'utf8')
.digest('hex');
}
function constantTimeEqual(a, b) {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
function parseSignatureHeader(header) {
const parts = {};
for (const item of String(header).split(',')) {
const [key, value] = item.split('=');
if (key && value) parts[key.trim()] = value.trim();
}
return { timestamp: parts.t, signature: parts.v1 };
}
function verifyWebhook(secret, toleranceSeconds = 300) {
return function (req, res, next) {
const { timestamp, signature } = parseSignatureHeader(req.get('Stripe-Signature'));
if (!timestamp || !signature) {
return res.status(400).json({ error: 'missing signature header' });
}
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) {
return res.status(400).json({ error: 'timestamp outside tolerance' });
}
const payload = req.body.toString('utf8');
const expected = computeSignature(secret, timestamp, payload);
if (!constantTimeEqual(expected, signature)) {
return res.status(400).json({ error: 'signature mismatch' });
}
try {
req.event = JSON.parse(payload);
} catch (err) {
return res.status(400).json({ error: 'invalid json body' });
}
next();
};
}
module.exports = { verifyWebhook, computeSignature, constantTimeEqual };
const express = require('express');
const { verifyWebhook } = require('./verifyWebhook');
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
app.use(express.json());
const seen = new Set();
function alreadyProcessed(eventId) {
if (seen.has(eventId)) return true;
seen.add(eventId);
return false;
}
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
verifyWebhook(SECRET),
async (req, res) => {
const event = req.event;
// Acknowledge fast; heavy work happens off the request path.
if (alreadyProcessed(event.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
break;
}
res.status(200).json({ received: true });
}
);
async function handlePaymentSucceeded(intent) {
console.log('payment succeeded', intent.id, intent.amount);
}
async function handleRefund(charge) {
console.log('charge refunded', charge.id);
}
app.listen(3000, () => console.log('listening on 3000'));
Webhooks are inbound HTTP requests from a third party, which means anyone who learns the endpoint URL could forge a payload. The standard defense is a shared secret plus an HMAC signature: the sender computes HMAC-SHA256(secret, timestamp + '.' + body) and puts it in a header, and the receiver recomputes the same value and compares. This snippet shows that verification wired into an Express app the way Stripe and GitHub actually do it.
The key subtlety appears in verifyWebhook middleware: the raw request bytes must be hashed, not a re-serialized object. Once express.json() parses and re-stringifies the body, key ordering and whitespace change and the signature no longer matches. That is why webhookServer mounts express.raw({ type: 'application/json' }) on the webhook route so req.body stays a Buffer. The middleware parses the header into a timestamp and signature, calls computeSignature to rebuild the expected digest over ${timestamp}.${payload}, and only then parses JSON.
Comparison is done with crypto.timingSafeEqual, not ===. A naive string compare returns early on the first mismatching byte, leaking timing information that can let an attacker recover a valid signature byte by byte. timingSafeEqual always compares the full length, and the code guards against a length mismatch first since the function throws on unequal buffers.
The timestamp check in verifyWebhook middleware rejects anything older than toleranceSeconds (five minutes) to blunt replay attacks, where a captured-but-valid request is resent later. Signature verification alone cannot stop replays, so the timestamp is folded into the signed payload and also range-checked.
Finally webhookServer demonstrates the practical follow-through: verification is necessary but not sufficient. Networks retry, so the same event can arrive twice; the handler treats event.id as a dedupe key via alreadyProcessed before doing real work, making processing idempotent. constantTimeEqual returns false rather than throwing on bad input, keeping the middleware defensive against malformed headers. The trade-off is that raw-body handling only applies to the webhook path, while normal JSON parsing still works everywhere else.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
Soft Validation: Normalize + Validate Email
Share this code
Here's the card — post it anywhere.