Validar un webhook de GitHub exige resolver tres problemas distintos: confirmar que la entrega provino de GitHub y no fue alterada, impedir que la misma entrega produzca efectos repetidos y responder con suficiente rapidez para que un procesamiento lento no se convierta en un fallo de entrega. Un diseño práctico combina HMAC sobre el cuerpo original, deduplicación con X-GitHub-Delivery, estado idempotente persistente y una cola que saca el trabajo pesado del camino de la respuesta HTTP.
Qué resuelve cada mecanismo
| Mecanismo | Problema que resuelve | Punto de implementación |
|---|---|---|
| HMAC-SHA256 | Autenticidad e integridad de la entrega | Recalcular la firma con el secret y compararla con X-Hub-Signature-256 |
| X-GitHub-Delivery | Identidad de la entrega y protección contra replay | Persistir el GUID y rechazar o absorber repeticiones según el estado |
| Idempotencia | Evitar efectos de negocio duplicados | Usar una clave única, transacción u operación que pueda repetirse de forma segura |
| Cola | Evitar timeout en el endpoint | Validar, registrar, encolar y devolver 2XX rápidamente |
1. Valida el HMAC antes de interpretar el JSON
Cuando un webhook tiene un secret configurado, GitHub envía X-Hub-Signature-256. La firma comienza con sha256= y se calcula con HMAC-SHA256, usando el secret como clave y el contenido del payload como mensaje. La documentación de GitHub recomienda validar la firma antes de continuar con el procesamiento.
El detalle que más suele causar errores es el cuerpo usado en el cálculo. La firma depende del contenido recibido. Si un framework analiza el JSON y luego lo serializa otra vez, los espacios, el orden de las propiedades o la codificación pueden cambiar. Por eso, captura el cuerpo original de la solicitud y calcula el HMAC antes de cualquier transformación. El propio ejemplo oficial en Ruby lee request.body antes del parser, y el ejemplo en TypeScript lee el texto de la solicitud antes de validar.
Ejemplo verificable en Node.js
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8"); const b = Buffer.from(signatureHeader || "", "utf8"); const valid = a.length === b.length && crypto.timingSafeEqual(a, b);
La comprobación de longitud antes de timingSafeEqual es necesaria porque la API de Node.js exige buffers del mismo tamaño. Si falta el header, usa otro prefijo o no coincide con el HMAC calculado, trata la entrega como no autenticada y no permitas que llegue a la lógica de negocio.
GitHub también publica un vector de prueba oficial: con el secret It's a Secret to Everybody y el payload Hello, World!, el valor esperado de X-Hub-Signature-256 comienza con sha256=757107ea.... Esta prueba sirve para confirmar la implementación antes de conectar el endpoint a un webhook real.
2. Usa X-GitHub-Delivery para replay y deduplicación
El header X-GitHub-Delivery es un GUID que identifica la entrega. GitHub recomienda usarlo para proteger el endpoint contra ataques de replay. Hay un detalle esencial: cuando una entrega se reenvía intencionalmente, se conserva el mismo X-GitHub-Delivery. Por eso, el identificador sirve tanto para deduplicar repeticiones no deseadas como para reconocer un reintento legítimo de la misma entrega.
Modela estado, no solo una lista de IDs bloqueados
Una tabla de recepciones puede usar delivery_id como clave única y guardar campos como status, received_at, attempt_count y last_error. Un flujo robusto puede seguir estas reglas:
- Si el GUID nunca fue visto, crea el registro en estado received y encola el trabajo.
- Si el GUID ya está en completed, devuelve 2XX sin repetir el efecto de negocio.
- Si el GUID está en processing, evita crear un segundo consumidor concurrente para la misma entrega.
- Si el GUID está en failed, una reentrega puede iniciar un nuevo intento controlado sin crear dos operaciones simultáneas.
Este diseño es más seguro que añadir el GUID a una lista negra permanente en el primer contacto. Una entrega puede recibirse correctamente y fallar después en la cola. Como la reentrega conserva el mismo GUID, bloquearlo para siempre también impediría una recuperación legítima.
3. Haz idempotente la operación de negocio
Deduplicar el GUID reduce repeticiones, pero no sustituye la idempotencia. Existen ventanas de fallo en las que un consumidor puede completar una operación y caer antes de marcar el registro como completado. En el siguiente intento, el mismo trabajo puede aparecer otra vez.
La protección más fuerte debe estar cerca del efecto de negocio. Ejemplos: usar una clave única (delivery_id, operation), escribir el evento y el cambio de estado en la misma transacción, o usar una clave natural del dominio que impida aplicar la misma mutación dos veces. Para integraciones externas, conserva una clave de idempotencia asociada al efecto enviado cuando la API de destino ofrezca ese recurso.
4. Valida, registra, encola y responde
GitHub recomienda devolver una respuesta 2XX en un máximo de 10 segundos. Si el endpoint tarda más, GitHub termina la conexión y considera la entrega fallida. Por eso, las llamadas a APIs externas, la generación de informes u otros trabajos costosos no deberían ejecutarse normalmente de forma síncrona antes de responder.
- Lee el cuerpo original y los headers necesarios.
- Valida X-Hub-Signature-256.
- Lee X-GitHub-Delivery y X-GitHub-Event.
- Registra la entrega de forma atómica, respetando la clave única del GUID.
- Encola solo el trabajo permitido por el estado actual.
- Devuelve 2XX.
- En el consumidor, ejecuta la lógica idempotente y actualiza el estado a completed o failed.
El header X-GitHub-Event indica el tipo de evento. GitHub también recomienda comprobar el tipo y, cuando exista, la acción del payload antes de procesar. Esto evita que un endpoint genérico aplique el handler equivocado a un evento inesperado.
5. La reentrega intencional debe formar parte del diseño
GitHub no vuelve a enviar automáticamente las entregas fallidas. Un operador puede solicitar una reentrega desde la interfaz o mediante la API, y la documentación permite reenviar entregas recientes de los últimos tres días. Como la reentrega conserva el mismo GUID, el consumidor debe usar el estado persistido para decidir si queda trabajo pendiente o si la entrega ya se completó.
Un patrón seguro es: completed devuelve 2XX sin repetir el efecto; failed permite un nuevo intento controlado; processing requiere un lock, lease u otra protección contra concurrencia; los registros antiguos atascados en processing pueden recuperarse mediante un timeout operativo claramente definido.
Flujo de referencia
| Etapa | Comprobación | Resultado |
|---|---|---|
| Entrada | HTTPS, método esperado y límites de tamaño | Solicitud aceptada para autenticación |
| Firma | HMAC-SHA256 sobre el cuerpo original y comparación resistente a timing | Origen e integridad validados |
| Identidad | X-GitHub-Delivery | GUID asociado al estado persistente |
| Enrutamiento | X-GitHub-Event y action | Handler correcto seleccionado |
| Cola | Inserción idempotente | Respuesta 2XX rápida |
| Consumidor | Transacción, clave única o clave de idempotencia | El efecto de negocio ocurre como máximo una vez desde el punto de vista lógico |
Errores comunes
- Calcular el HMAC sobre JSON serializado de nuevo en lugar del cuerpo recibido.
- Comparar la firma con == en vez de una rutina apropiada resistente a timing.
- Confiar solo en la IP de origen y omitir el secret. Una allow list de IP puede ser una capa adicional, no un sustituto del HMAC.
- Usar solo memoria local para deduplicación en una aplicación con varias instancias.
- Marcar un GUID como procesado antes de que el efecto de negocio termine realmente.
- Devolver 2XX antes de registrar de forma duradera el trabajo que aún debe procesarse.
- Tratar toda repetición del mismo GUID como un ataque y bloquear así una reentrega legítima de un intento anterior que falló.
Checklist de implementación
- Secret aleatorio de alta entropía almacenado fuera del código fuente.
- X-Hub-Signature-256 validado antes del parseo y de la lógica de negocio.
- Cuerpo original preservado para el cálculo del HMAC.
- Comparación de firma resistente a timing.
- X-GitHub-Delivery con restricción única y estado persistente.
- Handler seleccionado mediante X-GitHub-Event y acción.
- Cola duradera y respuesta 2XX dentro del límite operativo.
- Operación del consumidor idempotente.
- Política clara para los estados processing, failed y completed.
- Procedimiento de reentrega y observabilidad de fallos.




