Ir al contenido

Verificar la firma de un webhook

Cualquiera que conozca la dirección de tu endpoint puede enviarle un POST. La firma demuestra que el aviso lo envió foliios y que nadie lo modificó. Verifícala siempre antes de procesar el evento.

X-Webhook-Signature: t=1790000000,v1=<64 dígitos hexadecimales>
  • t: hora Unix (segundos) en que se firmó la petición. Cada intento firma de nuevo con su hora.
  • v1: HMAC-SHA256, en hexadecimal minúscula, con el secreto del endpoint como clave, calculado sobre el texto "<t>.<cuerpo>": el valor de t, un punto y los bytes crudos del cuerpo.

Durante una rotación de secreto la cabecera puede traer varias entradas v1. Basta con que coincida alguna.

  1. Lee el cuerpo crudo, antes de interpretarlo como JSON. Si lo vuelves a serializar, los bytes cambian y la firma no coincide.
  2. Separa t y todos los v1 de la cabecera.
  3. Rechaza si t está lejos de tu hora actual. Se recomienda una tolerancia de 5 minutos: evita que alguien reenvíe un aviso viejo.
  4. Calcula HMAC-SHA256(secreto, t + "." + cuerpo) en hexadecimal.
  5. Compáralo con cada v1 en tiempo constante (no con ==).
  6. Si ninguno coincide, responde 400 y no proceses el evento.

Guarda esto como verificar_firma.py:

import hashlib
import hmac
import time
def verificar(secreto: str, cabecera: str, cuerpo: bytes, tolerancia: int = 300) -> bool:
"""True si la firma corresponde al cuerpo y a la hora de la cabecera."""
t = None
firmas = []
for parte in cabecera.split(","):
clave, _, valor = parte.strip().partition("=")
if clave == "t":
t = valor
elif clave == "v1":
firmas.append(valor)
if t is None or not t.isdigit() or not firmas:
return False
if abs(time.time() - int(t)) > tolerancia:
return False
esperada = hmac.new(secreto.encode(), t.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperada, firma) for firma in firmas)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verificar(secreto, cabecera, cuerpo, tolerancia = 300) {
// `cuerpo` es un Buffer con los bytes crudos de la petición.
let t = null;
const firmas = [];
for (const parte of cabecera.split(',')) {
const [clave, ...resto] = parte.trim().split('=');
if (clave === 't') t = resto.join('=');
else if (clave === 'v1') firmas.push(resto.join('='));
}
if (t === null || !/^\d+$/.test(t) || firmas.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerancia) return false;
const esperada = createHmac('sha256', secreto).update(`${t}.`).update(cuerpo).digest();
return firmas.some((firma) => {
const recibida = Buffer.from(firma, 'hex');
return recibida.length === esperada.length && timingSafeEqual(recibida, esperada);
});
}

Con un servidor HTTP de Node, acumula los Buffer del cuerpo y pásalos a verificar sin convertirlos antes a texto.

Firma un cuerpo tú mismo, con un secreto de prueba, y comprueba que tu función lo acepta y que rechaza uno alterado:

import hashlib
import hmac
import time
from verificar_firma import verificar
secreto = "secreto-de-prueba"
cuerpo = b'{"id":"01JEJEMPLO","type":"document.accepted"}'
t = str(int(time.time()))
v1 = hmac.new(secreto.encode(), t.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
cabecera = f"t={t},v1={v1}"
assert verificar(secreto, cabecera, cuerpo)
assert not verificar(secreto, cabecera, cuerpo + b" ") # cuerpo alterado
assert not verificar("otro-secreto", cabecera, cuerpo) # secreto equivocado
vieja = f"t={int(t) - 3600},v1={v1}"
assert not verificar(secreto, vieja, cuerpo) # hora fuera de tolerancia

Si quieres un vector fijo que no dependa de la hora, este sirve para comprobar solo el cálculo (sin la tolerancia): con el secreto Jefe, t=0 y el cuerpo what do ya want for nothing?, el v1 es 37f471929915ccd2cbbe79feb84ffcff4f2bb25e15fc41c2506687331ae179cc. Puedes reproducirlo con printf '0.what do ya want for nothing?' | openssl dgst -sha256 -hmac Jefe.

Para una prueba de punta a punta, dispara un evento con POST /v1/sandbox/events (webhooks) y verifica el que llegue a tu endpoint.

  • Verificar sobre el JSON ya interpretado. El cuerpo se firma tal como se envió; usa los bytes crudos.
  • Comparar con ==. Usa la comparación en tiempo constante.
  • Olvidar la tolerancia de la hora. Sin ella, un aviso capturado vale para siempre.
  • Usar el secreto de otro ambiente. El secreto es del endpoint de cada empresa y ambiente.
  • Guardar el secreto en el código. Va en una variable de entorno o un gestor de secretos, como las claves de API (ambientes y claves).