Skip to content
StackPractices
intermediate Por Mathias Paulenko

Seguridad de Webhooks — Entrega, Verificación y Protección

Guía práctica para asegurar webhooks: verificación de firmas, prevención de ataques de repetición, cifrado de payloads y endurecimiento de endpoints para entrega confiable.

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión General

Los webhooks son la infraestructura de integración moderna. Stripe envía eventos de pago a tu endpoint. GitHub notifica tu pipeline CI sobre pushes. Slack envía interacciones de usuario a tu servidor. Pero cada webhook es un request HTTP entrante no solicitado a tu infraestructura — y cada request entrante es una superficie de ataque. A continuación: cómo asegurar webhooks con verificación de firmas, protección contra repetición, cifrado de payloads y endurecimiento de endpoints.

Cuándo Usar

Usa esta guía cuando:

  • Estás implementando endpoints webhook por primera vez
  • Recibes webhooks de proveedores de terceros y necesitas validar su autenticidad
  • Tu endpoint webhook está experimentando spam, ataques de repetición o payloads maliciosos

Solución

Arquitectura de Seguridad de Webhook

CapaMecanismoImplementación
TransporteTLS 1.2+ obligatorioRechazar conexiones HTTP sin redirección
AutenticaciónFirma HMAC-SHA256 del payloadVerificar contra secret compartido
Protección de repeticiónTimestamp + nonce, rechazar requests > 5 minComparar timestamp contra reloj del servidor
PayloadCifrado opcional para datos sensiblesAES-256-GCM con clave derivada del secreto
EndpointRate limiting, validación de schema, lista blanca de IPsRechazar requests malformados antes de procesar
MonitoreoLogs de entrega, reintentos con backoff exponencialAlertar en múltiples fallos consecutivos

Verificación de Firma

import hmac
import hashlib
import time

def verify_webhook(payload: bytes, signature: str, secret: bytes, tolerance=300) -> bool:
    """
    Verifica firma HMAC-SHA256 de webhook.
    Formato esperado de signature: 't=<timestamp>,v1=<hex>'
    """
    try:
        # Parsear header de firma
        parts = dict(p.split('=') for p in signature.split(','))
        timestamp = int(parts['t'])
        expected_sig = parts['v1']

        # Verificar freshness
        if abs(time.time() - timestamp) > tolerance:
            return False

        # Recomputar firma
        signed_payload = f"{timestamp}.".encode() + payload
        computed = hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()

        # Comparación constant-time
        return hmac.compare_digest(computed, expected_sig)
    except (ValueError, KeyError):
        return False

Prevención de Repetición

import redis

class ReplayProtection:
    def __init__(self, redis_client, window_seconds=300):
        self.redis = redis_client
        self.window = window_seconds

    def is_fresh(self, event_id: str, timestamp: float) -> bool:
        """Rechaza eventos duplicados o muy antiguos."""
        now = time.time()
        if now - timestamp > self.window:
            return False

        # Deduplicación con TTL de ventana
        key = f"webhook:{event_id}"
        if self.redis.set(key, "1", nx=True, ex=self.window):
            return True
        return False  # Duplicate

Endpoint Webhook Hardened

from fastapi import FastAPI, Request, HTTPException
import json

app = FastAPI()

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    # 1. Rate limiting (middleware)
    # 2. Extraer firma
    sig_header = request.headers.get("Stripe-Signature")
    if not sig_header:
        raise HTTPException(400, "Missing signature")

    # 3. Leer raw payload — NO parsear JSON antes de verificar firma
    payload = await request.body()

    # 4. Verificar firma
    if not verify_stripe_signature(payload, sig_header, WEBHOOK_SECRET):
        raise HTTPException(401, "Invalid signature")

    # 5. Parsear payload solo después de verificación
    event = json.loads(payload)

    # 6. Procesar evento
    handle_stripe_event(event)
    return {"status": "ok"}

Explicación

La verificación de firma es tu línea de defensa más importante. Funciona porque tanto tú como el emisor conocen un secreto que nunca viaja por la red. El emisor firma el payload con HMAC-SHA256; tú recalculas la misma firma. Si coinciden, el payload no fue modificado y proviene del emisor legítimo. Nunca parsees el payload JSON antes de verificar la firma — un atacante podría enviar JSON malformado que cause excepciones y filtre detalles de implementación.

La protección contra repetición previene que un atacante capture un webhook legítimo y lo re-envíe. Incluso sin el secreto, un webhook re-played puede causar daño (ej. procesar un pago dos veces). El timestamp en la firma asegura que el webhook es reciente; el nonce/event_id previene re-entrega exacta.

El cifrado de payload es raramente necesario si ya usas TLS, pero puede requerirse para datos altamente sensibles (información de salud, financiera). En ese caso, el emisor cifra el payload con AES-256-GCM y tú descifras antes de procesar. Esto protege contra intermediarios maliciosos incluso si TLS es comprometido.

Variantes

ProveedorFormato de FirmaSecretDocumentación
Stripet=<ts>,v1=<sig>Secreto de endpoint webhookRequiere timestamp + v1
GitHubsha256=<sig>Secreto de webhookFirma directa del payload
Slackx-slack-signatureSigning secretSimilar a Stripe con timestamp
Shopifyhmac=<sig>Clave APIQuery string HMAC para webhooks de app
GenéricoX-Webhook-SignatureSecreto compartidoImplementación personalizada recomendada

Lo que funciona

  1. Usa TLS 1.3 y rechaza HTTP plano — redirige a HTTPS sin procesar el payload
  2. Almacena secretos en variables de entorno o secret managers, nunca en código
  3. Implementa idempotencia en el procesamiento — el mismo event_id no debe ejecutar acción dos veces
  4. Responde con 200 OK rápidamente y procesa asíncronamente; timeouts causarán reintentos
  5. Rota secretos periódicamente usando mecanismos de doble secreto del proveedor si disponible

Errores Comunes

  1. Verificar firma con comparación de strings normal en lugar de hmac.compare_digest; vulnerable a timing attacks
  2. Parsear JSON antes de verificar firma; expones la aplicación a payloads maliciosos
  3. No validar timestamps; aceptar webhooks de cualquier edad permite ataques de repetición
  4. Procesar webhooks síncronamente en el hilo del request; timeouts causan reintentos en cascada
  5. Loggear payloads completos incluyendo PII; los logs no deben contener datos sensibles

Preguntas Frecuentes

¿Necesito cifrar el payload si ya uso HTTPS?

HTTPS (TLS) protege el payload en tránsito contra sniffing pasivo. El cifrado de payload adicional protege contra:

  • Compromiso de certificados TLS
  • Almacenamiento de payload en logs de proxy/intermediario
  • Re-envío de webhook a un endpoint comprometido

Para la mayoría de casos, TLS + verificación de firma es suficiente. Agrega cifrado de payload solo si tus datos son regulados (HIPAA, PCI) o si el emisor no soporta firmas.

¿Cómo manejo reintentos del emisor si mi endpoint está caído?

Diseña para idempotencia desde el inicio. Cada evento debe tener un ID único; almacénalo con estado “procesado” en tu base de datos. Si el mismo evento llega de nuevo (reintento), devuelve 200 OK sin re-ejecutar la acción. Usa un TTL en tu tabla de deduplicación (24-72 horas típicamente). No dependas de que el emisor te avise que es un reintento — algunos no lo hacen.

¿Qué pasa si el secreto de firma se filtra?

Rota inmediatamente. La mayoría de proveedores permiten configurar un nuevo secreto mientras el anterior sigue funcionando (ventana de migración). Genera un nuevo secreto, actualiza tu aplicación, verifica que los webhooks nuevos funcionan, luego invalida el anterior. Si no hay soporte de doble secreto, acepta un breve periodo de fallo mientras rotas.

Temas Avanzados

Escenario: Verificacion de Webhooks de Stripe

// Verificacion de firma HMAC (Node.js)
const crypto = require("crypto");

function verifyStripeSignature(payload, signature, secret) {
  // Stripe envia: t=timestamp,v1=signature
  const elements = signature.split(",");
  const timestamp = elements.find(e => e.startsWith("t="))?.split("=")[1];
  const sig = elements.find(e => e.startsWith("v1="))?.split("=")[1];

  // Prevenir replay attacks: rechazar > 5 min
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    throw new Error("Webhook timestamp fuera de rango");
  }

  // Calcular firma esperada
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Comparacion segura contra timing attacks
  if (crypto.timingSafeEqual(
    Buffer.from(sig),
    Buffer.from(expectedSig)
  )) {
    return true;
  }
  throw new Error("Firma invalida");
}

// Endpoint del webhook
app.post("/webhooks/stripe", (req, res) => {
  const sig = req.headers["stripe-signature"];
  const rawBody = req.rawBody; // Importante: body crudo, no parsed

  try {
    verifyStripeSignature(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(rawBody);
  // Idempotencia: verificar si ya procesamos este evento
  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");
  }

  // Procesar evento
  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(`Evento no manejado: ${event.type}`);
  }

  // Marcar como procesado
  await db.query(
    "INSERT INTO processed_webhooks (id, type, created_at) VALUES ($1, $2, NOW())",
    [event.id, event.type]
  );

  res.status(200).send("OK");
});

// Hardening:
//   1. Usar body crudo (no JSON.parse antes de verificar)
//   2. timingSafeEqual para prevenir timing attacks
//   3. Timestamp check para prevenir replay
//   4. Idempotencia con DB unique constraint
//   5. TTL en processed_webhooks (7 dias)
//   6. Rate limiting en el endpoint
//   7. IP allowlist de Stripe (3.18.12.63, 3.130.6.84, ...)

Como manejo webhooks de multiples proveedores?

Usa endpoints separados por proveedor: /webhooks/stripe, /webhooks/github, /webhooks/slack. Cada endpoint tiene su propia logica de verificacion, IP allowlist y parsing. Un bug en el parser de un proveedor no afecta a otros. Monitoreo granular por endpoint. Si necesitas un endpoint generico, rutea por path o header a handlers especificos.

End of document. Review and update quarterly.

Troubleshooting

  • Authentication bypass in tests: ensure test users cannot reach production endpoints. Use separate credentials and environments for CI.
  • False positives in scanning tools: tune rules against the risk profile. Distinguish between reachable vulnerabilities and theoretical issues.
  • Secrets appear in logs: configure log filters to redact tokens, passwords, and keys. Audit log sinks for sensitive patterns.
  • CSP breaks legitimate functionality: use report-only mode first, then enforce. Iterate on allowed sources based on real violations.
  • Incident response stalls: run tabletop exercises. Document escalation paths, evidence collection steps, and communication templates in advance.

Errores Comunes en Producción

  • Tratar la guía como un checklist para completar una vez en lugar de una práctica por evolucionar.
  • Adoptar cada recomendación de golpe en lugar de comenzar con un cambio medido.
  • Saltar la evaluación de madurez e imponer prácticas avanzadas a un equipo no preparado.
  • No actualizar runbooks y expectativas de guardia al introducir nuevas prácticas.
  • Ignorar datos reales de incidentes al priorizar qué partes de la guía aplicar primero.
  • No asignar un responsable que revise decisiones trimestralmente.
  • Copiar ejemplos sin adaptarlos a las herramientas y restricciones reales del equipo.
  • Olvidar medir resultados antes de agregar la siguiente mejora.