Seguridad de webhook: firmas HMAC, idempotencia y protección de reproducción
Tres patrones de código detienen cargas útiles falsificadas, entregas duplicadas y solicitudes de webhooks reproducidas. Ejemplos de Node.js con HMAC-SHA256 y comprobaciones de marca de tiempo.
Su controlador de webhook acepta una solicitud POST, analiza el JSON y ejecuta la lógica empresarial. Eso funciona hasta que alguien envía una carga útil falsificada a su punto final. O hasta que el proveedor vuelva a intentar una entrega y su manejador le cobre un cliente dos veces. O hasta que un atacante registre una solicitud legítima y la reproduzca seis horas después.
Tres protecciones solucionan este problema: verificación de firma HMAC, claves de idempotencia y rechazo de reproducción basado en marca de tiempo. Este tutorial cubre cada uno con el código Node.js funcional.
Cómo funciona la firma de webhooks
Todos los principales proveedores de webhooks (Stripe, GitHub, Shopify, Twilio) firman cargas útiles salientes con HMAC-SHA256. el El proveedor combina el cuerpo de la solicitud sin formato con un secreto compartido, calcula un hash y lo envía en un encabezado. Su servidor vuelve a calcular el hash con el mismo secreto y lo compara. Si los hashes coinciden, la carga útil es auténtica.
La fórmula de firma se ve así:
HMAC-SHA256(secret, timestamp + "." + raw_body) = signature
Stripe envía la firma en Stripe-Signature. GitHub lo envía X-Hub-Signature-256.
El algoritmo es el mismo; sólo difieren el nombre y el formato del encabezado.
Verificar una firma de webhook de Stripe en Node.js
raya Stripe-Signature El encabezado contiene una marca de tiempo (t=) y uno o más versionados
firmas (v1=). A continuación se explica cómo verificarlo sin el SDK de Stripe:
const crypto = require('crypto');
function verifyStripeSignature(payload, header, secret) {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
const signature = parts.find(p => p.startsWith('v1=')).slice(3);
const signedPayload = timestamp + '.' + payload;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (expected !== signature) {
throw new Error('Invalid signature');
}
// Replay protection: reject if older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
throw new Error('Timestamp too old');
}
return JSON.parse(payload);
}
Detalles clave: Stripe concatena la marca de tiempo y el cuerpo sin formato con un separador de puntos. El v1
la firma utiliza HMAC-SHA256. La verificación de la marca de tiempo al final proporciona protección de reproducción (que se explica en detalle a continuación).
Verificar una firma de webhook de GitHub en Node.js
El formato de GitHub es más simple. El X-Hub-Signature-256 el encabezado contiene sha256= seguido
por el hexadecimal HMAC del cuerpo bruto:
const crypto = require('crypto');
function verifyGitHubSignature(payload, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signatureHeader, 'utf8');
if (trusted.length !== untrusted.length) {
throw new Error('Invalid signature');
}
if (!crypto.timingSafeEqual(trusted, untrusted)) {
throw new Error('Invalid signature');
}
return JSON.parse(payload);
}
Aviso crypto.timingSafeEqual. Un estándar === La comparación filtra información sobre el tiempo.
Un atacante puede medir los tiempos de respuesta para adivinar la firma correcta byte a byte. Comparación en tiempo constante
elimina ese vector.
Calcule firmas HMAC con la API hash de botoi
Si necesita calcular o verificar firmas HMAC-SHA256 desde un script, canalización de CI o función sin servidor
sin importar crypto, Usa la /v1/hash/hmac punto final:
curl -X POST https://api.botoi.com/v1/hash/hmac \\
-H "Content-Type: application/json" \\
-d '{
"text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
"key": "whsec_your_stripe_secret",
"algorithm": "sha256"
}'
Respuesta:
{
"success": true,
"data": {
"hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
"algorithm": "sha256"
}
}
Esto devuelve el HMAC codificado en hexadecimal. Compárelo con el encabezado de firma del proveedor del webhook.
El punto final admite ambos sha256 y sha512 algoritmos.
Cree un middleware Express para la verificación HMAC
Envuelva la verificación de firma en middleware reutilizable para que cada ruta de webhook obtenga protección sin duplicar código:
const crypto = require('crypto');
function webhookAuth(secret) {
return (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Step 1: Replay protection
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(401).json({ error: 'Request too old' });
}
// Step 2: HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}
// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);
Este middleware rechaza solicitudes a las que les faltan encabezados, marcas de tiempo obsoletas o firmas no válidas antes de su El controlador se ejecuta. La ventana de 300 segundos coincide con la tolerancia predeterminada de Stripe.
Idempotencia: omitir entregas duplicadas
Los proveedores de webhooks vuelven a intentar las entregas fallidas. Si su servidor devolvió un 500 después de procesar el evento pero antes Al enviar la respuesta 200, el proveedor envía el mismo evento nuevamente. Sin idempotencia, su controlador ejecuta el efectos secundarios dos veces: cargos dobles, correos electrónicos duplicados, actualizaciones repetidas de inventario.
La solución: almacene cada ID de evento procesado y omita los eventos que haya visto antes.
Idempotencia en memoria (desarrollo)
const processedEvents = new Map(); // Use Redis in production
async function handleWebhook(req, res) {
const eventId = req.body.id;
// Check if already processed
if (processedEvents.has(eventId)) {
return res.status(200).json({ status: 'duplicate', eventId });
}
// Mark as processing before side effects
processedEvents.set(eventId, Date.now());
try {
await processEvent(req.body);
return res.status(200).json({ status: 'processed', eventId });
} catch (err) {
// Remove on failure so retries work
processedEvents.delete(eventId);
return res.status(500).json({ error: 'Processing failed' });
}
}
La Map obras para el desarrollo local. En producción, utilice un almacén compartido para que todas las instancias del servidor
ver el mismo conjunto de eventos procesados.
Idempotencia respaldada por Redis (producción)
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function handleWebhook(req, res) {
const eventId = req.body.id;
const lockKey = \`webhook:processed:\${eventId}\
SET NX es atómico: solo un proceso gana el bloqueo. El TTL de 72 horas (259200 segundos)
poda automáticamente las entradas antiguas. Si el procesamiento falla, el controlador elimina la clave para que el siguiente reintento pueda tener éxito.
Protección de reproducción: rechazar solicitudes obsoletas
La verificación HMAC confirma que la carga útil proviene del proveedor. Pero una solicitud firmada válida capturada por un El atacante de red sigue siendo válido para siempre a menos que agregue una verificación de tiempo. La protección de reproducción rechaza las cargas útiles con marcas de tiempo anteriores a un umbral.
function rejectStaleRequests(req, res, next) {
const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);
if (isNaN(timestamp)) {
return res.status(401).json({ error: 'Missing timestamp' });
}
const now = Math.floor(Date.now() / 1000);
const age = now - timestamp;
// Reject anything older than 5 minutes or from the future
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
next();
}
La tolerancia futura de 30 segundos maneja una desviación menor del reloj entre su servidor y el proveedor. cualquier cosa con más de 5 minutos o más de 30 segundos en el futuro se rechaza.
Combine los tres en un solo middleware
Aquí está el middleware completo que ejecuta las tres comprobaciones en secuencia: marca de tiempo, HMAC y luego idempotencia.
const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
function secureWebhook(secret) {
return async (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const eventId = req.body?.id;
// 1. Reject missing headers
if (!signature || !timestamp || !eventId) {
return res.status(401).json({ error: 'Missing required headers or event ID' });
}
// 2. Replay protection: reject stale requests
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300 || age < -30) {
return res.status(401).json({ error: 'Timestamp out of range' });
}
// 3. HMAC verification
const payload = JSON.stringify(req.body);
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex');
const trusted = Buffer.from(expected, 'utf8');
const untrusted = Buffer.from(signature, 'utf8');
if (trusted.length !== untrusted.length ||
!crypto.timingSafeEqual(trusted, untrusted)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 4. Idempotency: skip duplicates
const lockKey = \`webhook:processed:\${eventId}\
El orden importa. La marca de tiempo es la verificación más barata (sin E/S), por lo que se ejecuta primero. La verificación HMAC es Limitado a la CPU pero aún rápido. La idempotencia requiere una llamada a Redis, por lo que se ejecuta en último lugar. Esta secuencia rechaza la mayor cantidad de solicitudes al menor costo.
Pruebe su controlador de webhook con la bandeja de entrada de webhook de botoi
Usar botones /v1/webhook/inbox para crear una URL desechable, calcule una firma HMAC con
/v1/hash/hmacy envíe una carga útil de prueba firmada. Sin túneles, sin servidor local.
# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"
# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"
HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
-H "Content-Type: application/json" \\
-d "{
\\"text\\": \\"\$SIGNED_PAYLOAD\\",
\\"key\\": \\"test_secret_key\\",
\\"algorithm\\": \\"sha256\\"
}" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"
# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Signature: \$HMAC" \\
-H "X-Webhook-Timestamp: \$TIMESTAMP" \\
-d "\$PAYLOAD"
# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'
El punto final de la lista devuelve todas las cargas útiles recibidas:
[
{
"id": "p_abc123",
"received_at": "2026-04-05T14:30:00Z",
"body": {
"id": "evt_001",
"event": "order.created",
"amount": 2999
}
}
]
Este flujo de trabajo le permite probar el flujo de firma completo desde la línea de comando. Cambie la URL de la bandeja de entrada por su punto final de producción para ejecutar pruebas de integración en CI.
Cómo Stripe, GitHub y Shopify firman webhooks
| Proveedora | Encabezamiento | Algoritmo | Formato de carga firmada | Marca de tiempo incluida |
|---|---|---|---|---|
| Raya | Stripe-Signature |
HMAC-SHA256 | t=UNIX.v1=HEX |
Sí |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 | sha256=HEX |
No |
| comprar | X-Shopify-Hmac-Sha256 |
HMAC-SHA256 | Codificado en Base64 | No |
| Twilio | X-Twilio-Signature |
HMAC-SHA1 | URL + parámetros ordenados | No |
Stripe es el único que incluye una marca de tiempo con la firma, lo que te brinda protección de reproducción lista para usar. Para GitHub y Shopify, agrega tu propio encabezado de marca de tiempo o verifica la hora de creación del evento en el cuerpo de la carga útil.
Lista de verificación antes de pasar a producción.
- Almacene el secreto del webhook en una variable de entorno, no en el código fuente.
- Analice el cuerpo de la solicitud sin formato, no la versión analizada en JSON. HMAC firma los bytes exactos en el cable.
- Usar
crypto.timingSafeEqualpara comparar firmas. Nunca usar===. - Devuelve 200 antes de ejecutar un procesamiento lento. Poner en cola el trabajo y acusar recibo de la entrega.
- Registre las solicitudes rechazadas (firma no válida, marca de tiempo obsoleta, duplicada) para depuración y alertas.
- Establece un TTL en tu tienda de idempotencia. 72 horas cubren la mayoría de los períodos de reintento del proveedor.
- Pruebe con una bandeja de entrada desechable y firmas HMAC calculadas antes de conectarse a un proveedor en vivo.
FAQ
- ¿Por qué utilizar HMAC-SHA256 en lugar de un secreto compartido en un parámetro de consulta?
- Un parámetro de consulta viaja en texto plano a través de servidores proxy y registros de acceso. HMAC-SHA256 firma todo el cuerpo de la solicitud con el secreto, por lo que un atacante que intercepte la URL aún no puede falsificar una firma válida.
- ¿Cuánto tiempo debo conservar los ID de eventos procesados por idempotencia?
- Guárdalos durante al menos 24 a 72 horas. La mayoría de los proveedores de webhooks vuelven a intentarlo dentro de esa ventana. Después de 72 horas, puedes eliminar de forma segura las identificaciones antiguas de tu tienda.
- ¿Qué tolerancia de marca de tiempo debo utilizar para la protección de reproducción?
- Cinco minutos (300 segundos) es el estándar. Stripe usa 300 segundos. Los plazos más cortos corren el riesgo de rechazar entregas legítimas retrasadas por la congestión de la red.
- ¿Puedo usar el punto final botoi hash/hmac para verificar los webhooks entrantes?
- Sí. PUBLICAR el cuerpo de la carga útil y su secreto en /v1/hash/hmac con el algoritmo sha256. Compare el HMAC devuelto con el encabezado de firma del proveedor del webhook.
- ¿Necesito las tres protecciones o puedo elegir una?
- La verificación HMAC es la mínima. Agregue idempotencia si su controlador de webhook desencadena efectos secundarios como cargos o correos electrónicos. Agregue protección de reproducción si su sistema maneja transacciones financieras o eventos sensibles a la seguridad.
Empieza a construir con botoi
150+ endpoints de API para consultas, procesamiento de texto, generacion de imagenes y utilidades para desarrolladores. Plan gratuito, sin tarjeta de credito.