Seguridad de Webhooks — Entrega, Verificación y Protección
Guía práctica para asegurar webhooks: verificación de firma HMAC, prevención de replay, idempotencia, hardening de endpoints y rotación de secretos.
Visión General
Un endpoint de webhooks es una ruta POST sin autenticación expuesta a internet. Stripe, GitHub y Slack la golpean con eventos que disparan trabajo real — crear pedidos, dar acceso, borrar datos. Si cualquiera que descubra la URL puede enviar un invoice.paid falsificado y tu código se lo cree, tienes un problema de escalación de privilegios que no requiere credenciales.
Esta guía cubre los controles que hacen un webhook confiable de extremo a extremo: qué puede hacer un atacante, cómo la verificación de firma HMAC prueba autenticidad, cómo los timestamps y las claves de idempotencia detienen los ataques de repetición, cómo endurecer el endpoint, y cómo manejar reintentos, rotación de secretos y (raramente) cifrado del payload. Los ejemplos son en Python, FastAPI y Node.js.
Cuándo Usar Esta Guía
Úsala si estás construyendo un producto SaaS que envía webhooks a endpoints de clientes, o si consumes webhooks de terceros — Stripe, GitHub, Slack, Shopify — donde un evento falsificado o repetido tiene consecuencias. También es la guía a la que acudir tras un incidente: un cargo duplicado por una re-entrega, un evento forjado que disparó trabajo real, o un secreto whsec_ filtrado.
Es menos útil si tus “webhooks” nunca salen de una red privada con autenticación mutua a nivel de infraestructura — por ejemplo, entrega de eventos dentro de un service mesh con mTLS entre servicios que controlas. En ese caso el transporte ya aporta autenticidad, e invertirías el esfuerzo en validación del payload en lugar de verificación de firma. Salta también la sección de cifrado de payload salvo que manejes datos regulados; TLS más firmas cubre el resto.
El Modelo de Amenazas
Antes de escribir código, entiende contra qué te defiendes. Un endpoint de webhooks sin firmar o débilmente protegido está expuesto a cinco ataques:
| Ataque | Qué hace el atacante | Qué te cuesta |
|---|---|---|
| Spoofing | Forja un evento con aspecto legítimo (payment_intent.succeeded) | Concedes acceso, envías mercancía o confirmas pagos que nunca existieron |
| Replay | Captura un webhook válido y lo reenvía | Cargos duplicados, pedidos duplicados, efectos secundarios dobles |
| Tampering | Modifica el payload en tránsito | Importes incorrectos, destinatarios incorrectos, estado corrupto |
| Sniffing | Lee el payload en un salto HTTP (sin TLS) | Datos de clientes filtrados, secretos embebidos en el payload expuestos |
| Flooding | Envía miles de requests al endpoint | Tus workers se ahogan en procesamiento síncrono — un DDoS autoinfligido |
Los arreglos mapean de forma limpia: las firmas matan el spoofing y el tampering, HTTPS mata el sniffing, timestamps + deduplicación matan el replay, y el procesamiento asíncrono con rate limiting mata el flooding. El resto de esta guía implementa cada uno.
Verificación de Firmas: La Defensa Central
HMAC-SHA256 es el mecanismo que usa casi todo proveedor. El emisor firma los bytes crudos del payload con un secreto compartido y pone el resultado en una cabecera. Tú recalculas la firma sobre los bytes recibidos y comparas. Si coinciden, el payload vino del emisor y no fue modificado — el secreto nunca viaja por la red.
Dos detalles hacen o rompen esto. Primero, firma y verifica el body crudo del request — si parseas JSON y lo re-serializas, diferencias de espacios u orden de claves producen una secuencia de bytes distinta y la firma no coincidirá. Segundo, compara con hmac.compare_digest() o crypto.timingSafeEqual(), nunca con ==: una comparación ingenua corta en el primer byte incorrecto, y la diferencia de tiempo es medible hasta para fuerza bruta de la firma byte a byte.
Implementando la Verificación de Firmas
Del lado del productor, firma los bytes exactos que estás por enviar:
import hmac
import hashlib
import json
import time
import uuid
import requests
WEBHOOK_SECRET = "whsec_..." # cargado desde vault o variable de entorno, nunca commiteado
def deliver_webhook(customer_url: str, payload: dict):
payload_bytes = json.dumps(payload, separators=(",", ":")).encode()
signature = hmac.new(
WEBHOOK_SECRET.encode(), payload_bytes, hashlib.sha256
).hexdigest()
headers = {
"X-Webhook-Signature": f"v1={signature}",
"X-Webhook-ID": str(uuid.uuid4()), # único por intento de entrega
"X-Webhook-Timestamp": str(int(time.time())),
}
requests.post(customer_url, data=payload_bytes, headers=headers, timeout=10)
La firma cubre el body serializado, el ID se genera por entrega y el timestamp le dice al receptor qué tan viejo es el mensaje. El consumidor invierte el proceso:
import hmac
import hashlib
import time
WEBHOOK_SECRET = "whsec_..."
MAX_AGE_SECONDS = 300 # 5 minutos
def verify_webhook(body: bytes, signature_header: str,
timestamp_header: str, webhook_id: str) -> bool:
"""Devuelve True solo si el webhook es auténtico, fresco y no visto."""
if not signature_header.startswith("v1="):
return False
expected_sig = signature_header[3:]
computed = hmac.new(
WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed, expected_sig):
return False
if abs(time.time() - int(timestamp_header)) > MAX_AGE_SECONDS:
return False # reenviado o muy retrasado
return not already_processed(webhook_id)
Protección contra Repetición e Idempotencia
El timestamp solo no basta: un atacante puede reenviar un webhook capturado dentro de la ventana de cinco minutos. Un almacén de IDs de evento tampoco basta — si tu deduplicación está caída o lenta, los duplicados se cuelan y los requests viejos se aceptan. Usa ambos.
Redis convierte la deduplicación en una única operación atómica:
import redis
r = redis.Redis()
DEDUP_TTL_SECONDS = 24 * 3600 # guarda los IDs más que lo que reintenta el proveedor
def already_processed(event_id: str) -> bool:
"""True si este evento ya se vio. SET NX es atómico, así que dos
entregas concurrentes del mismo evento no pueden pasar ambas."""
return r.set(f"webhook:seen:{event_id}", "1",
nx=True, ex=DEDUP_TTL_SECONDS) is None
Dos propiedades importan aquí. La comprobación es atómica (SET ... NX), así que un reintento del proveedor corriendo contra una primera entrega lenta no puede procesar dos veces. Y devolver 200 para duplicados es deliberado — dile al proveedor “recibido” para que deje de reintentar, mientras tu código omite los efectos secundarios. Diseña también los efectos para ser idempotentes: “marcar la factura X como pagada” es seguro de ejecutar dos veces; “incrementar el contador de pagadas” no.
Endureciendo el Endpoint
El handler debería rechazar basura antes de llegar a la lógica de negocio. Esta versión con FastAPI ordena las comprobaciones de más barata a más cara:
import json
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
ALLOWED_IPS = set(os.environ.get("WEBHOOK_IP_ALLOWLIST", "").split(","))
MAX_BODY = 1_000_000 # 1 MB
@app.post("/webhooks/billing")
async def billing_webhook(request: Request):
if request.client.host not in ALLOWED_IPS: # si el proveedor publica sus IPs
raise HTTPException(403, "Forbidden")
if request.headers.get("content-type") != "application/json":
raise HTTPException(415, "Solo JSON")
body = await request.body() # bytes crudos, sin parsear
if len(body) > MAX_BODY:
raise HTTPException(413, "Demasiado grande")
sig = request.headers.get("X-Webhook-Signature", "")
ts = request.headers.get("X-Webhook-Timestamp", "0")
wid = request.headers.get("X-Webhook-ID", "")
if not verify_webhook(body, sig, ts, wid):
raise HTTPException(400, "Firma inválida")
event = json.loads(body) # parsea solo tras verificar
await queue.enqueue("webhook.billing", event) # deriva, no proceses aquí
return {"status": "accepted"}
El orden es el punto: los rechazos baratos (IP, content-type, tamaño) corren antes del trabajo criptográfico, la verificación de firma corre antes del parseo JSON, y el trabajo pesado se deriva a una cola para que el endpoint responda en milisegundos.
Reintentos, Backoff y Dead Letters
La entrega es at-least-once por diseño: los proveedores reintentan hasta ver un 2xx, así que los duplicados son tráfico normal, no un caso límite. Tus códigos de estado llevan significado:
- 2xx — “entregado, deja de reintentar”. Devuélvelo para webhooks válidos aunque solo hayas encolado el trabajo, y para duplicados.
- 4xx — “permanentemente mal, no reintentar”. Firma incorrecta, payload malformado, tipo de evento desconocido.
- 5xx — “intenta más tarde”. Tu base de datos cayó, la cola estaba llena. Los proveedores lo respetan y hacen backoff.
El último es fácil de equivocar. Si tu endpoint devuelve 500 porque una dependencia transitoria falló, el proveedor reintenta — bien. Pero si devuelve 500 por un payload que jamás podrás procesar (un bug, un evento desconocido), el proveedor te seguirá golpeando. Loguéalo, alerta, pero devuelve 4xx o trágalo con un 200 y mándalo a una dead-letter queue para inspección.
Como productor, reintenta con backoff exponencial y jitter — 1s, 2s, 4s, 8s, 16s más jitter aleatorio para que miles de endpoints no reintenten en bloque — y limita la ventana de reintentos a unas 24-72 horas antes de mandar la entrega a dead-letter.
Del lado del monitoreo, alerta sobre dos señales: respuestas 4xx repetidas (alguien está probando o tu secreto se desactualizó) y 5xx repetidos (tu endpoint falla y el proveedor está acumulando reintentos — la próxima ráfaga será mayor). Un pico repentino de valores X-Webhook-ID nunca vistos, desde una IP fuera de los rangos del proveedor, merece despertar a alguien.
Gestión y Rotación de Secretos
El secreto compartido es todo el modelo de seguridad, así que trátalo como una contraseña: cárgalo desde un vault o variable de entorno, nunca lo commitees, nunca lo pongas en una URL, y usa un secreto por entorno y por consumidor.
Rótalo en calendario (90 días es un default razonable) e inmediatamente tras cualquier sospecha de filtración. El patrón seguro es una ventana de doble secreto: configura tu verificador para aceptar firmas del secreto viejo y del nuevo por un periodo corto, cambia los productores al nuevo, y luego retira el viejo. El dashboard de Stripe soporta exactamente este rollover; si implementas el tuyo propio, verifica contra una lista de secretos activos en lugar de uno solo.
Si un secreto se filtra, el orden de operaciones importa: habilita el nuevo primero, confirma que las entregas nuevas verifican, y luego mata el viejo. Rotar en el orden contrario crea una caída donde toda entrega falla la verificación.
Cuando TLS No Basta: Cifrado del Payload
Firma + HTTPS cubre casi todo lo real. El cifrado del payload es la excepción — reservado para payloads con datos regulados (salud, registros financieros) o trayectos donde un intermediario no debe leer el contenido. Es cifrado simétrico encima de la firma, no un reemplazo:
import json
import os
from cryptography.fernet import Fernet
# Compartida out-of-band — genera una vez, guarda en el vault para ambos lados.
ENCRYPTION_KEY = os.environ["WEBHOOK_ENCRYPTION_KEY"].encode()
fernet = Fernet(ENCRYPTION_KEY)
# Productor: cifra el payload serializado, luego firma el texto cifrado.
ciphertext = fernet.encrypt(json.dumps(payload).encode())
# Consumidor: verifica primero la firma sobre el ciphertext, luego descifra.
if verify_signature(headers, ciphertext):
payload = json.loads(fernet.decrypt(ciphertext))
El orden es deliberado: verifica la firma sobre el ciphertext antes de descifrar — nunca quieres descifrar y parsear entrada controlada por un atacante antes de probar que vino del emisor. Si tu proveedor no ofrece cifrado de payload (la mayoría no), esto solo aplica a webhooks que produces tú.
Diferencias entre Proveedores
La mecánica es la misma en todas partes; los detalles difieren. No escribas un verificador genérico — cada proveedor tiene su formato de cabecera y sus rarezas:
| Proveedor | Cabecera de firma | Timestamp | Notas |
|---|---|---|---|
| Stripe | Stripe-Signature (t=…,v1=…) | Embebido en el payload firmado | Firma timestamp.body; tolerancia configurable; rotación de doble secreto en el dashboard |
| GitHub | X-Hub-Signature-256 (sha256=…) | Ninguno | Firma solo el body crudo; usa el GUID X-GitHub-Delivery para replay |
| Slack | X-Slack-Signature (v0=…) | X-Slack-Request-Timestamp | Firma v0:timestamp:body; tolerancia de 5 minutos |
| Shopify | X-Shopify-Hmac-Sha256 | Ninguno | HMAC en base64, no hex — fuente clásica de bugs de verificación |
| Custom | Lo que diseñes | Lo que incluyas | Copia el esquema de Stripe; es el más probado |
Ejemplo Completo: Verificación de Stripe en Node.js
Este es el flujo completo del consumidor para el caso de producción más común — verificar un webhook de Stripe con Express. El detalle crítico en el que fallan la mayoría de implementaciones es el body parser: express.json() consume y re-serializa el body, así que debes capturar los bytes crudos para la ruta primero.
const crypto = require("crypto");
const express = require("express");
const app = express();
// Captura el body crudo para la verificación — antes de cualquier parser JSON.
app.use("/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["stripe-signature"];
if (!signature) return res.status(400).send("Missing signature");
// Stripe envía "t=timestamp,v1=signature"
const parts = Object.fromEntries(
signature.split(",").map(e => e.split("="))
);
const { t: timestamp, v1: sig } = parts;
if (!timestamp || !sig) return res.status(400).send("Malformed signature");
// Rechaza entregas viejas (protección anti-replay)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("Timestamp out of range");
}
// Recalcula sobre "timestamp.rawBody"
const signedPayload = `${timestamp}.${req.body.toString()}`;
const expected = crypto
.createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const valid = crypto.timingSafeEqual(
Buffer.from(sig), Buffer.from(expected)
);
if (!valid) return res.status(400).send("Invalid signature");
const event = JSON.parse(req.body.toString());
// Idempotencia: el índice único hace imposible el doble procesamiento.
const processed = await db.query(
"SELECT id FROM processed_webhooks WHERE id = $1", [event.id]
);
if (processed.rows.length > 0) {
return res.status(200).send("Already processed");
}
switch (event.type) {
case "payment_intent.succeeded":
await handlePaymentSuccess(event.data.object);
break;
case "payment_intent.payment_failed":
await handlePaymentFailure(event.data.object);
break;
default:
console.log(`Tipo de evento no manejado: ${event.type}`);
}
await db.query(
"INSERT INTO processed_webhooks (id, type, created_at) VALUES ($1, $2, NOW())",
[event.id, event.type]
);
res.status(200).send("OK");
}
);
Probando la Seguridad de tus Webhooks
No puedes verificar los controles de seguridad solo con el botón de test del proveedor — fabrica los ataques tú mismo:
- Envía una firma válida — firma un payload de prueba con tu secreto; el endpoint debe aceptarlo.
- Envía una firma incorrecta — cambia un carácter hex; el endpoint debe rechazarla.
- Envía un timestamp viejo — firma correctamente pero con
tde hace una hora; debe rechazarlo. - Reenvía el mismo evento — envía exactamente el mismo request dos veces; el segundo debe ser un no-op que devuelva 200.
- Envía sin firma — sin cabecera de firma; debe rechazar antes de tocar el payload.
La mayoría de proveedores también tienen un CLI (stripe listen + stripe trigger de Stripe, el replay de recent-deliveries de GitHub) que permite probar el viaje completo en local antes de exponer un endpoint.
Lo Que Funciona
- Verifica el body crudo, siempre. Firma y verifica bytes, no objetos parseados — las diferencias de serialización rompen las firmas.
- Devuelve 200 rápido, procesa asíncrono. El trabajo del endpoint es autenticar, deduplicar y encolar — no correr tu lógica de negocio dentro del timeout del proveedor.
- Guarda los IDs de evento más que lo que el proveedor reintenta. La mayoría reintenta 24-72 horas; un TTL de deduplicación menor es un bug de doble procesamiento esperando ocurrir.
- Un endpoint por proveedor. El esquema de firma de Stripe ≠ el de GitHub ≠ el de Slack. Endpoints por proveedor mantienen la verificación, las IP permitidas y el monitoreo aislados.
- Loguea cada entrega — ID de evento, proveedor, timestamp, resultado de firma y resultado — pero loguea metadatos, no payloads, o tus logs se convierten en una fuga de datos.
Errores Comunes
- Comparar firmas con
==. Usahmac.compare_digest()/crypto.timingSafeEqual()— la comparación normal es un oráculo de timing. - Parsear JSON antes de verificar.
JSON.parsesobre un body forjado mete entrada del atacante por tu parser antes de autenticar nada. - Solo timestamp contra replay. Cinco minutos da tiempo de sobra para reenviar un webhook capturado — necesitas deduplicación por ID de evento también.
- Devolver 500 para payloads improcesables. Si el payload nunca podrá procesarse, un 5xx dispara reintentos infinitos; mándalo a dead-letter y devuelve 4xx o 200.
- Procesar dentro del request. Una ráfaga de webhooks trabajando síncrono tumba tu servicio — justo lo que la tormenta de reintentos del proveedor empeora después.
- Asumir que el secreto está seguro en el repo. Una vez que un secreto de webhook se commitea, trátalo como comprometido y rótalo inmediatamente.
Véase También
- Ejemplos de Webhook Security en el repositorio — código ejecutable de productor, consumidor y deduplicación.
- API Security Checklist — Authentication to Encryption — la checklist más amplia de seguridad de API a la que esta guía se conecta.
- Guía de Seguridad de Aplicaciones Web — autenticación, sesiones y el resto de la superficie de ataque.
Lectura Recomendada
- Stripe — Verify webhook signatures — la implementación de referencia del esquema
t,v1. - GitHub — Validating webhook deliveries — cómo funciona
X-Hub-Signature-256de GitHub. - OWASP Cheat Sheet — Webhook Security — la checklist de la comunidad para productores y consumidores.
Preguntas frecuentes
¿Qué hago si mi proveedor de webhooks no firma los payloads?
Tienes tres alternativas, en orden de preferencia: lista blanca de IPs (restringe el endpoint a los rangos IP publicados del proveedor), un secreto compartido en una cabecera personalizada negociado out-of-band, y TLS mutuo si el proveedor soporta certificados de cliente. Si ninguna está disponible, trata el webhook como entrada no confiable: valida cada campo y nunca dispares acciones irreversibles — pagos, borrados, concesión de acceso — a partir de eventos sin firmar.
¿Basta con HTTPS o necesito cifrar el payload?
HTTPS solo es suficiente para casi todo el mundo. El cifrado del payload añade protección contra una terminación TLS comprometida (un proxy malicioso o mal configurado leyendo el body) y solo merece la pena para datos regulados — HIPAA, PCI — o cuando el emisor lo exige. Las firmas, no el cifrado, son lo que prueba autenticidad.
¿Cómo manejo reintentos y entregas duplicadas?
Asume que cada webhook llegará al menos dos veces. Guarda cada ID de evento en un almacén de deduplicación con un TTL mayor que la ventana de reintentos del proveedor (24-72h), compruébalo atómicamente antes de procesar, y devuelve 200 para duplicados para que el proveedor deje de reintentar. Diseña los efectos secundarios para ser idempotentes también — "poner estado X" es seguro de reejecutar, "incrementar X" no.
¿Qué hago si el secreto de firma se filtra?
Rótalo inmediatamente, en este orden: configura un secreto nuevo, actualiza tu verificador para aceptar viejo y nuevo (ventana de doble secreto), confirma que las entregas nuevas verifican, y luego revoca el viejo. La mayoría de proveedores (Stripe incluido) soportan este rollover nativamente. Trata cualquier secreto commiteado a git como filtrado.
¿Un endpoint para todos los proveedores o uno por proveedor?
Uno por proveedor. Los formatos de firma, los rangos IP y las formas de payload difieren; un endpoint compartido acopla sus parsers de modo que un bug en uno expone a los demás. Endpoints separados también te dan monitoreo y alertas por proveedor. Si tienes docenas de proveedores, enruta internamente por path a handlers específicos en lugar de compartir un solo verificador.
¿Qué código de estado devuelvo para una entrega fallida?
Depende de por qué falló. 4xx para problemas permanentes (firma incorrecta, payload malformado — no reintentar, no se arreglará solo). 5xx para los transitorios (base de datos caída — reintentar, se arreglará). 2xx para todo lo que aceptaste, incluyendo duplicados que deduplicaste pero no reprocesaste.
Recursos Relacionados
Checklist de Seguridad API: Autenticación a Encriptación
Checklist de seguridad esencial para APIs: autenticación, autorización, validación de entrada, rate limiting, encriptación, logging y endurecimiento de despliegue.
GuideSeguridad de Aplicaciones Web (OWASP Top 10)
Un recorrido enfocado en desarrolladores por el OWASP Top 10 — inyección, XSS, control de acceso roto, SSRF y el resto — con código de prevención y un flujo de threat modeling.
RecipeImplementar Rate Limiting de APIs con Redis
Protege APIs del abuso con limitadores token bucket y ventana deslizante respaldados por Redis, con ráfagas controladas y coordinación distribuida
RecipeAutenticacion y Patrones de Seguridad para WebSockets
Como autenticar conexiones WebSocket, implementar validacion de tokens y manejar autorizacion para mensajeria en tiempo real en produccion
RecipeValidación de Datos con Esquemas de Zod en TypeScript
Valida y sanea datos entrantes con esquemas de Zod, inferencia de TypeScript, refinamientos personalizados y formateo de errores para APIs y formularios