Descargo de responsabilidad: Este artículo es material educativo de seguridad aplicada a integraciones financieras. La implementación incorrecta de verificación de webhooks puede resultar en aceptación de eventos fraudulentos o pérdida de notificaciones legítimas. Validar siempre contra la documentación oficial vigente de cada proveedor de pagos. El uso de este contenido es bajo exclusiva responsabilidad del desarrollador u operador.
1. Por Qué los Webhooks Son el Vector de Ataque Más Subestimado
Los webhooks son el mecanismo mediante el cual pasarelas de pago notifican eventos asíncronos: confirmaciones de pago, chargebacks, reembolsos y cambios de estado de suscripciones. A diferencia de las respuestas síncronas de API que ocurren dentro de un flujo de request autenticado, los webhooks son peticiones HTTP entrantes no solicitadas desde servidores externos. Esto los convierte en objetivo prioritario para atacantes que buscan:
- Falsificar eventos: Enviar webhook falso de «pago aprobado» para activar servicio sin pago real.
- Replay attacks: Capturar webhook legítimo y reenviarlo múltiples veces para duplicar créditos/servicios.
- Exfiltrar datos: Si el endpoint responde con información sensible en cuerpo de respuesta 200, atacante puede interceptarla.
- Denegación de servicio: Saturar endpoint con requests masivos para bloquear procesamiento de eventos legítimos.
La defensa requiere múltiples capas: verificación criptográfica obligatoria, protección contra replay, procesamiento idempotente y respuesta mínima sin fuga de información. Ninguna capa es opcional en producción financiera.
2. Verificación HMAC-SHA256: Implementación Correcta y Errores Fatales
Toda pasarela seria firma webhooks con HMAC-SHA256 usando secreto compartido. Pero implementaciones incorrectas son más comunes que las correctas. Los errores fatales incluyen:
- Usar payload parseado en lugar de crudo:
json_decode()+json_encode()altera orden de claves, espacios y formato numérico. La firma se calcula sobre bytes exactos recibidos (file_get_contents('php://input')). - Comparación con === en lugar de hash_equals(): Operador === es vulnerable a timing attacks. Atacante mide tiempo de comparación byte por byte para forjar firma válida.
- No normalizar header de firma: Algunos proveedores incluyen prefijo (
sha256=,v1=) o timestamp concatenado. Extraer solo el hash antes de comparar. - Aceptar requests sin header de firma: Endpoint debe rechazar 401/403 inmediatamente si header ausente. Nunca procesar «por compatibilidad».
<?php
declare(strict_types=1);
namespace App\Webhooks\Security;
class WebhookVerifier
{
private string $secret;
private int $timestampTolerance;
public function __construct(string $secret, int $timestampToleranceSeconds = 300)
{
if (strlen($secret) < 16) {
throw new \InvalidArgumentException("Webhook secret too short (min 16 bytes)");
}
$this->secret = $secret;
$this->timestampTolerance = $timestampToleranceSeconds;
}
/**
* Verifica firma HMAC + timestamp para prevenir replay attacks
* Compatible con Stripe, Mercado Pago, Kushki, dLocal
*/
public function verify(string $rawPayload, string $signatureHeader): VerificationResult
{
// Paso 1: Parsear header según formato del proveedor
$parsed = $this->parseSignatureHeader($signatureHeader);
if ($parsed === null) {
return new VerificationResult(false, 'INVALID_SIGNATURE_FORMAT');
}
// Paso 2: Validar timestamp (prevención de replay)
$age = abs(time() - $parsed['timestamp']);
if ($age > $this->timestampTolerance) {
return new VerificationResult(false, 'TIMESTAMP_OUT_OF_TOLERANCE');
}
// Paso 3: Recalcular firma esperada sobre payload CRUDO
// Formato típico: timestamp.payload (Stripe-like)
$signedContent = $parsed['timestamp'] . '.' . $rawPayload;
$expectedSignature = hash_hmac('sha256', $signedContent, $this->secret);
// Paso 4: Comparación de tiempo constante OBLIGATORIA
if (!hash_equals($expectedSignature, $parsed['signature'])) {
return new VerificationResult(false, 'SIGNATURE_MISMATCH');
}
return new VerificationResult(true, null);
}
/**
* Parsea header de firma multi-formato
* Stripe: t=1697385600,v1=abc123...
* MP/Kushki: abc123... (directo)
*/
private function parseSignatureHeader(string $header): ?array
{
// Formato Stripe-like con timestamp
if (preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $matches)) {
return [
'timestamp' => (int) $matches[1],
'signature' => $matches[2]
];
}
// Formato directo (sin timestamp) → usar tiempo actual como fallback
// ADVERTENCIA: Sin timestamp nativo, replay protection depende de nonce/idempotencia
if (preg_match('/^[a-f0-9]{64}$/', trim($header))) {
return [
'timestamp' => time(),
'signature' => trim($header)
];
}
return null;
}
}
readonly class VerificationResult
{
public function __construct(
public bool $isValid,
public ?string $failureReason
) {}
}
| Proveedor | Header | Formato Firma | Timestamp Nativo | Tolerancia Recomendada |
|---|---|---|---|---|
| Stripe | Stripe-Signature | t={ts},v1={sig} | Sí | 300s |
| Mercado Pago | X-Signature | HMAC directo | No | N/A (usar event_id) |
| Kushki | X-Kushki-Signature | HMAC directo | No | N/A (usar transaction_id) |
| dLocal | X-dLocal-Signature | HMAC + ts | Sí | 300s |
| PayPal | PAYPAL-TRANSMISSION-SIG | JWT RS256 | Sí (en JWT) | Verificar exp claim |
3. Prevención de Replay Attacks: Timestamps + Nonces + Idempotencia
La verificación HMAC sola no previene replay attacks si el atacante captura un webhook válido dentro de la ventana de tolerancia temporal. Se requieren mecanismos adicionales:
- Timestamp tolerance estricta: Rechazar webhooks con timestamp >5 minutos de antigüedad. Reduce ventana de replay a segundos.
- Nonce/event_id tracking: Almacenar IDs de eventos procesados en Redis con TTL = 2x tolerancia temporal. Si ID ya existe, descartar silenciosamente (retornar 200 para evitar reintentos del proveedor).
- Idempotencia en procesamiento: Incluso si webhook pasa verificación y no es replay detectado, la lógica de negocio debe ser idempotente. Misma transacción procesada dos veces = mismo resultado sin efectos secundarios duplicados.
<?php
// Middleware completo de seguridad para endpoint webhook
$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// 1. Verificar firma + timestamp
$verifier = new WebhookVerifier(getenv('STRIPE_WEBHOOK_SECRET'));
$verification = $verifier->verify($rawBody, $signatureHeader);
if (!$verification->isValid) {
http_response_code(403);
error_log("[WEBHOOK_REJECTED] Reason: {$verification->failureReason} | IP: {$_SERVER['REMOTE_ADDR']}");
exit; // Sin cuerpo descriptivo
}
// 2. Parsear evento DESPUÉS de verificación exitosa
$event = json_decode($rawBody, true);
$eventId = $event['id'] ?? null;
if (!$eventId) {
http_response_code(400);
exit;
}
// 3. Check de replay vía Redis (deduplicación)
$redis = new Redis();
$redis->connect('redis-internal', 6379);
$dedupKey = "webhook:processed:{$eventId}";
if ($redis->exists($dedupKey)) {
// Ya procesado: retornar 200 para evitar reintentos del proveedor
http_response_code(200);
echo '{"status":"already_processed"}';
exit;
}
// 4. Marcar como procesado ANTES de ejecutar lógica de negocio
// Previene race condition en procesamiento concurrente
$redis->setex($dedupKey, 600, '1'); // TTL 10 min > tolerancia timestamp
// 5. Encolar para procesamiento asíncrono (no procesar inline)
$redis->lPush('queue:webhooks', json_encode([
'event_id' => $eventId,
'type' => $event['type'],
'raw_payload' => $rawBody,
'received_at' => time()
]));
// 6. Responder INMEDIATAMENTE (<100ms)
http_response_code(200);
header('Content-Type: application/json');
echo '{"status":"accepted"}';
exit;
4. Procesamiento Seguro: Principio de Mínimo Privilegio
El worker que procesa webhooks encolados debe operar con privilegios mínimos y aislamiento estricto:
- Sin acceso a red externa innecesaria: Worker de webhooks no necesita llamar APIs externas salvo las estrictamente necesarias para reconciliación. Firewall egress restringido.
- Credenciales específicas: Token de BD con permisos INSERT/UPDATE solo en tablas de eventos y ledger. Sin DROP/ALTER/GRANT. Sin acceso a tablas de usuarios/PII.
- Timeouts estrictos: Cada job tiene timeout máximo (ej: 30s). Jobs que exceden timeout se marcan como fallidos y reencolan con backoff. Previenen workers zombies.
- Logging estructurado sin PII: Loguear event_id, type, status, duración. Nunca loguear payload completo ni datos de clientes. Sanitización automática obligatoria.
5. Auditoría Forense y Retención de Evidencia
En disputas de chargeback o investigaciones regulatorias, los webhooks son evidencia primaria. La retención y estructura de logs determina si puedes defender tu posición:
- Almacenar payload crudo firmado: Guardar body original + signature header + timestamp de recepción. Permite re-verificar firma meses después si secreto rota.
- Registrar decisión de verificación: Log inmutable de ACCEPT/REJECT con razón. Esencial para demostrar que sistema rechazó eventos sospechosos proactivamente.
- Vincular a transacción interna: Cada webhook procesado debe referenciar transaction_id interno. Trazabilidad bidireccional: desde webhook → transacción y desde transacción → todos los webhooks relacionados.
- Retención regulatoria: Mínimo 5 años en LATAM para eventos financieros. Archivar a cold storage (S3/R2) tras 90 días hot. Formato Parquet/ORC para consultas analíticas eficientes.
6. Testing de Seguridad de Webhooks
Verificar que la implementación resiste ataques reales requiere testing específico:
- Firma inválida: Enviar webhook con firma modificada en 1 bit. Debe retornar 403 sin procesar.
- Timestamp expirado: Enviar webhook válido pero con timestamp de hace 10 minutos. Debe retornar 403.
- Replay inmediato: Enviar mismo webhook válido dos veces en <1s. Primera vez: procesar. Segunda: retornar 200 sin re-procesar.
- Payload malformado: JSON inválido, campos faltantes, tipos incorrectos. Debe retornar 400 sin crash ni stack trace expuesto.
- Flood test: 1000 requests/segundo durante 1 minuto. Endpoint debe mantener latencia <100ms y no perder eventos legítimos.
Automatizar estos tests en CI/CD. Ejecutar semanalmente en staging con secretos de prueba. Cualquier regresión bloquea deploy.
7. Conclusión y Recursos Oficiales
La seguridad de webhooks financieros es disciplina de defensa en profundidad: verificación criptográfica correcta previene falsificación, timestamps y nonces previenen replay, idempotencia previene duplicación, y auditoría inmutable permite defensa legal post-incidente. Implementar solo una de estas capas deja vulnerabilidades explotables. En fintech, donde cada webhook representa dinero real moviéndose entre cuentas, la seguridad del endpoint de notificaciones es tan crítica como la del propio ledger.
Referencias oficiales:
- Stripe Webhook Signature Verification: Referencia canónica de implementación.
- Mercado Pago Webhooks Security: Documentación específica LATAM.
- OWASP Webhook Security Cheat Sheet: Mejores prácticas generales.
- HTTP Message Signatures RFC Draft: Estándar emergente para firmas HTTP.
Nota técnica: Este artículo utiliza marcado Schema.org Article coexistente con el nodo FinancialService corporativo. Los metadatos semánticos se indexan automáticamente vía The SEO Framework.