import crypto from 'crypto';
interface VerifyOptions {
rawBody: Buffer;
signatureHeader: string | undefined;
secret: string;
toleranceSeconds?: number;
}
function parseHeader(header: string): { timestamp: number; signature: string } | null {
const parts = header.split(',').map((p) => p.trim());
let timestamp: number | null = null;
let signature: string | null = null;
for (const part of parts) {
const [key, value] = part.split('=');
if (key === 't') timestamp = Number(value);
if (key === 'v1') signature = value;
}
if (timestamp === null || Number.isNaN(timestamp) || !signature) return null;
return { timestamp, signature };
}
function constructExpectedSignature(timestamp: number, rawBody: Buffer, secret: string): string {
const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
return crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex');
}
export function verifyWebhook(opts: VerifyOptions): boolean {
const tolerance = opts.toleranceSeconds ?? 300;
if (!opts.signatureHeader) return false;
const parsed = parseHeader(opts.signatureHeader);
if (!parsed) return false;
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - parsed.timestamp) > tolerance) return false;
const expected = constructExpectedSignature(parsed.timestamp, opts.rawBody, opts.secret);
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(parsed.signature, 'hex');
// timingSafeEqual throws on length mismatch, so guard first.
if (expectedBuf.length !== providedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
import express, { RequestHandler } from 'express';
declare global {
namespace Express {
interface Request {
rawBody?: Buffer;
}
}
}
export function captureRawBody(): RequestHandler {
return express.raw({
type: 'application/json',
limit: '1mb',
verify: (req: express.Request, _res, buf) => {
req.rawBody = buf;
},
});
}
import { Router, Request, Response } from 'express';
import { verifyWebhook } from './verifyWebhook';
import { captureRawBody } from './rawBody';
import { enqueueEvent, alreadyProcessed } from './events';
const router = Router();
const SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? '';
router.post('/webhooks/stripe', captureRawBody(), async (req: Request, res: Response) => {
const rawBody = req.rawBody;
if (!rawBody) {
return res.status(400).send('missing body');
}
const ok = verifyWebhook({
rawBody,
signatureHeader: req.header('Stripe-Signature'),
secret: SECRET,
});
if (!ok) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(rawBody.toString('utf8')) as { id: string; type: string };
if (await alreadyProcessed(event.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
await enqueueEvent(event.id, event.type, rawBody);
return res.status(200).json({ received: true });
});
export default router;
Webhook endpoints are public URLs, so any request that arrives must be proven to come from the real sender before its payload is trusted. The standard defense is an HMAC signature: the provider signs the raw request body with a shared secret and sends the digest in a header, and the receiver recomputes the same digest and compares. This snippet shows that verification implemented the way Stripe and GitHub do it, with the two subtle details that trip people up: comparing bytes in constant time, and signing the raw body rather than the parsed JSON.
In verifyWebhook.ts, constructExpectedSignature builds the signed payload as ${timestamp}.${rawBody} and runs it through crypto.createHmac('sha256', secret). The comparison uses crypto.timingSafeEqual rather than ===. A naive string compare returns as soon as two characters differ, and that early-exit leaks timing information an attacker can measure to recover the signature byte by byte. timingSafeEqual always examines every byte, so the compare time is independent of where the mismatch is. It throws if the two buffers differ in length, so the code guards with a length check first and treats any length mismatch as a failure.
The function also enforces a toleranceSeconds window on the timestamp. Without this, a valid signature captured off the wire could be replayed forever; rejecting old timestamps bounds the replay window. The header is parsed defensively because a malformed or missing header should fail closed, not throw.
In rawBody.ts, express.raw captures the exact bytes of the body before any JSON parser mutates them. This matters because re-serializing parsed JSON can reorder keys or change whitespace, producing a different digest than the sender computed. The raw Buffer is what gets both verified and, only after verification, parsed.
In stripeWebhook.ts, the route wires it together: it verifies first, returns 400 on any failure, and only then parses and dispatches. It also derefs an idempotency key from the event id so a redelivered event is processed once. The trade-off is that this middleware must run before global body parsers, which is why the raw handler is mounted per-route rather than app-wide.
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.