13 min de lecturaIngeniería

Verificar firmas de webhook: HMAC-SHA256 en Node, Python, Go

Cómo verificar la firma de un webhook con HMAC-SHA256: cuerpo en bruto, comparación en tiempo constante, ventana de repetición y rotación de secretos.

Marius Voß
DevRel · edge infra

Para verificar la firma de un webhook, vuelve a calcular un HMAC sobre exactamente los bytes que firmó el remitente, usando el secreto que compartes con él, y compara tu valor con la cabecera de firma en tiempo constante. Luego comprueba que la marca de tiempo firmada sea reciente. Para los webhooks de Elido eso significa HMAC-SHA256 con tu secreto whsec_... completo como clave, sobre {X-Webhook-Timestamp}.{raw body}, codificado en hexadecimal, con el prefijo v1=, y comparado con X-Elido-Signature.

Ese es todo el algoritmo. Los fallos vienen de los detalles que lo rodean: un analizador de cuerpo que se ejecutó demasiado pronto, un secreto que se decodificó, un resumen hexadecimal comparado con uno en base64. Este artículo cubre la verificación de firmas HMAC de webhooks en general, y luego código funcional de Elido para Node, Python, Go y un nodo Code de n8n, además de la ventana de repetición y la rotación de secretos que la mayoría de las guías rápidas omiten.

Si aún no has configurado un endpoint, empieza con webhooks para eventos de enlaces, que lista todos los tipos de evento y la envoltura de la carga útil. Esta página continúa cuando una petición firmada llega a tu servidor.

Cómo funcionan las firmas HMAC de webhooks

Un endpoint de webhook es una URL pública. Cualquiera que la encuentre puede enviar por POST un cuerpo JSON que parezca un evento real, así que el receptor necesita una prueba de origen. HMAC la da a bajo coste: remitente y receptor comparten un secreto, el remitente calcula HMAC-SHA256(secret, message) y pone el resultado en una cabecera, y el receptor hace el mismo cálculo y compara. Sin el secreto, nadie puede producir un valor que coincida, y cambiar un solo byte del mensaje cambia todo el resumen.

Tres detalles difieren entre proveedores, y cada uno rompe la verificación si te equivocas:

  1. Qué entra en el mensaje. GitHub firma solo el cuerpo en bruto. Stripe y Elido firman una marca de tiempo, un punto y el cuerpo. La especificación Standard Webhooks firma un ID de mensaje, la marca de tiempo y el cuerpo.
  2. Cómo se codifica el resumen. Hexadecimal o base64, con un prefijo de esquema como v1= o sha256=.
  3. Cuál es la clave. Algunos proveedores decodifican en base64 el secreto tras su prefijo. Elido no: la clave es la cadena whsec_ completa como bytes UTF-8.

Que la marca de tiempo esté dentro del mensaje firmado importa. Impide que un atacante empareje un cuerpo antiguo con firma válida con una cabecera de marca de tiempo reciente, que es lo que hace que una ventana de repetición se pueda hacer cumplir.

Cómo verificar la firma de un webhook: Elido une la marca de tiempo unix, un punto y el cuerpo en bruto, le aplica HMAC-SHA256 con el secreto whsec_ y envía v1= en hexadecimal en X-Elido-Signature. El formulario de añadir endpoint de webhook de Elido con la URL del endpoint, la selección de eventos y el secreto de firma que usan los receptores para la verificación HMAC

Qué firma Elido y qué cabeceras lo llevan

Cada entrega a un endpoint event o siem es un POST con Content-Type: application/json y un cuerpo con la forma {"type", "workspace_id", "data", "timestamp"}. Los tipos de endpoint con forma de chat (Discord, Telegram, Sentry) se autentican mediante su URL y no llevan cabeceras HMAC, así que todo lo que sigue se aplica solo a los dos primeros tipos.

CabeceraValorQué hacer con ella
X-Elido-Signaturev1= + 64 caracteres hexadecimales en minúsculasCompárala con tu valor calculado
X-Webhook-SignatureMismo valor que el anteriorAlias antiguo; lee cualquiera, no ambos
X-Webhook-TimestampSegundos Unix, p. ej. 1789000000Parte del mensaje firmado; comprueba su antigüedad
X-Elido-Signature-Previousv1= + hexadecimal, firmado con el secreto antiguoPresente solo durante una ventana de gracia de rotación
X-Webhook-EventNombre del evento, p. ej. link.createdEnruta el evento (después de verificar)
X-Webhook-DeliveryID numérico de entrega, estable entre reintentosClave de deduplicación para un procesamiento idempotente

El secreto lo recibes al crearlo: whsec_ seguido de 64 caracteres hexadecimales. Se devuelve una vez en la respuesta de creación y nunca más, así que va directo a tu almacén de secretos.

Aquí tienes un vector de prueba contra el que ejecutar tu código: con el secreto whsec_test_only_do_not_use, la marca de tiempo 1789000000 y el cuerpo {"type":"link.created","workspace_id":42}, el valor correcto de la cabecera es:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Puedes reproducirlo desde una shell, que es mi primer movimiento siempre que un receptor discrepa del remitente:

printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
  | openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r

Una trampa aquí: el campo timestamp del propio cuerpo es el momento en que ocurrió el evento, mientras que la marca de tiempo firmada es la de la cabecera X-Webhook-Timestamp, establecida cuando se envía la petición. No las confundas.

Validar la firma de un webhook en Node

En Express, la solución para la mayoría de los fallos es una línea: monta express.raw() en la ruta del webhook para que req.body sea un Buffer con los bytes exactos recibidos. Registra esta ruta antes de cualquier app.use(express.json()) global, porque una vez que el analizador JSON ha consumido el flujo, el analizador en bruto no tiene nada que leer.

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.ELIDO_WEBHOOK_SECRET; // the full whsec_... string
const TOLERANCE_SEC = 300;

function matches(expected, got) {
  const a = Buffer.from(expected);
  const b = Buffer.from(got ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post(
  "/webhooks/elido",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ts = req.get("X-Webhook-Timestamp") ?? "";
    if (
      !/^\d+$/.test(ts) ||
      Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC
    ) {
      return res.status(400).send("stale or missing timestamp");
    }
    const expected =
      "v1=" +
      createHmac("sha256", SECRET)
        .update(`${ts}.`)
        .update(req.body)
        .digest("hex");
    const ok = [
      req.get("X-Elido-Signature"),
      req.get("X-Elido-Signature-Previous"),
    ].some((got) => matches(expected, got));
    if (!ok) return res.status(401).send("bad signature");

    const event = JSON.parse(req.body.toString("utf8"));
    // enqueue event, then acknowledge fast
    res.sendStatus(200);
  },
);

La comprobación de longitud no es decoración. timingSafeEqual lanza una excepción con buffers de distinta longitud en lugar de devolver false. Si usas el SDK de TypeScript de la guía rápida de API y SDK, webhooks.verify() en @elido/sdk hace el mismo HMAC y la misma comparación en tiempo constante. Pasa { maxSkewSec: 300 } explícitamente, sin embargo: sin esa opción no comprueba la antigüedad de la marca de tiempo en absoluto.

Verificación HMAC SHA256 de webhooks en Python y Go

La biblioteca estándar de Python lo cubre. Con FastAPI, await request.body() devuelve los bytes en bruto; en Flask, llama a request.get_data() antes de que nada toque request.json.

import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

SECRET = os.environ["ELIDO_WEBHOOK_SECRET"].strip().encode()
TOLERANCE = 300
app = FastAPI()


def verify(raw: bytes, ts: str, candidates: list) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE:
        return False
    digest = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "v1=" + digest
    return any(c and hmac.compare_digest(c, expected) for c in candidates)


@app.post("/webhooks/elido")
async def elido_webhook(request: Request):
    raw = await request.body()
    h = request.headers
    sigs = [h.get("x-elido-signature"), h.get("x-elido-signature-previous")]
    if not verify(raw, h.get("x-webhook-timestamp", ""), sigs):
        raise HTTPException(status_code=401, detail="bad signature")
    event = json.loads(raw)
    # enqueue event
    return {"ok": True}

En Go, lee el cuerpo una vez, limita su tamaño y usa hmac.Equal de crypto/hmac, que compara en tiempo constante. Construyo el mensaje a partir de la cadena de la cabecera exactamente como se recibió, en lugar de volver a formatear el entero analizado.

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"strconv"
	"time"
)

const toleranceSec = 300

// Verify returns the raw body when the request carries a valid Elido signature.
func Verify(w http.ResponseWriter, r *http.Request, secret []byte) ([]byte, bool) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		return nil, false
	}
	tsHeader := r.Header.Get("X-Webhook-Timestamp")
	ts, err := strconv.ParseInt(tsHeader, 10, 64)
	if err != nil {
		return nil, false
	}
	if age := time.Now().Unix() - ts; age > toleranceSec || age < -toleranceSec {
		return nil, false
	}

	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(tsHeader + "."))
	mac.Write(body)
	expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))

	for _, name := range []string{"X-Elido-Signature", "X-Elido-Signature-Previous"} {
		if got := r.Header.Get(name); got != "" && hmac.Equal([]byte(got), expected) {
			return body, true
		}
	}
	return nil, false
}

Las tres versiones responden 401 ante una firma incorrecta y solo analizan el JSON después de que la comprobación pase. Elido cuenta cualquier respuesta no 2xx como un intento fallido, así que si tu verificador es incorrecto, las entregas genuinas se acumulan como 401 en el registro de entregas del endpoint. Ese es el primer lugar donde mirar después de un despliegue.

¿Quieres verlo con tráfico real? Crea un endpoint en un espacio de trabajo desde la página de la función de webhooks y apúntalo a un túnel local; el registro de entregas muestra el código de estado que devolvió tu verificador en cada intento.

Verificar la firma del webhook dentro de un nodo Code de n8n

n8n puede hacer la misma comprobación sin ningún servicio adicional. Activa la opción Raw Body en el nodo Webhook, que guarda la petición sin tocar como datos binarios, y añade un nodo Code justo después. El módulo crypto integrado está permitido en el nodo Code de n8n, así que esto se ejecuta tal cual:

const crypto = require("crypto");
const h = $input.first().json.headers;
const raw = await this.helpers.getBinaryDataBuffer(0, "data");

const ts = h["x-webhook-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
  throw new Error("stale delivery");
}
const expected = Buffer.from(
  "v1=" +
    crypto
      .createHmac("sha256", $env.ELIDO_WEBHOOK_SECRET)
      .update(`${ts}.`)
      .update(raw)
      .digest("hex"),
);
const ok = ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
  const got = Buffer.from(h[name] ?? "");
  return (
    got.length === expected.length && crypto.timingSafeEqual(got, expected)
  );
});
if (!ok) throw new Error("bad signature");
return [{ json: JSON.parse(raw.toString("utf8")) }];

Un error lanzado detiene la ejecución, así que nada posterior se ejecuta con un evento falsificado. Una pega al autoalojar: si tu instancia establece N8N_BLOCK_ENV_ACCESS_IN_NODE=true, el nodo Code no puede leer $env, el secreto llega vacío y todas las entregas fallan la comprobación. La guía de n8n autoalojado cubre el lado del proxy inverso, y el artículo del nodo de n8n para acortar URL muestra qué construir una vez que llegan los eventos.

En qué se diferencia Elido de Stripe, GitHub y Standard Webhooks

Si has escrito un verificador para otro proveedor, no lo copies a ciegas. Comprobado con la documentación de cada proveedor el 2026-10-25:

  • Stripe pone t= y uno o más valores v1= en una única cabecera Stripe-Signature, firma timestamp.body con HMAC-SHA256 en hexadecimal, y sus bibliotecas usan por defecto una tolerancia de cinco minutos (documentación de Stripe). Elido separa la marca de tiempo en su propia cabecera.
  • GitHub firma solo el cuerpo en bruto, envía sha256= más hexadecimal en X-Hub-Signature-256 y te dice que uses una comparación en tiempo constante (documentación de GitHub). No hay marca de tiempo que comprobar.
  • Standard Webhooks usa webhook-id, webhook-timestamp y webhook-signature, firma id.timestamp.body, codifica en base64 con un prefijo v1, y deja el valor de tolerancia a tu criterio (especificación). El X-Webhook-Delivery de Elido hace el papel del ID, pero no forma parte del mensaje firmado.

El esquema propio de Elido es el de signing.Sign en api-core: el mensaje es la marca de tiempo Unix, un punto y el cuerpo, la clave es la cadena del secreto tal como se almacena, y el resultado es v1= más hexadecimal en minúsculas. Elido no tiene un evento link.clicked, así que no esperes entregas a nivel de clic en este canal.

Ventanas de repetición y rotación de secretos

Una firma válida demuestra quién envió una petición, no cuándo. Alguien que capture una entrega firmada, desde una línea de registro o un proxy mal configurado, podría enviarla de nuevo una semana después y el HMAC seguiría coincidiendo. La comprobación de la marca de tiempo cierra esa brecha, y es tu trabajo: el worker de entregas de Elido firma la marca de tiempo pero no impone ninguna ventana de tu lado. Yo uso 300 segundos. Mantén el reloj del receptor sincronizado con NTP, ya que un servidor que se desvíe unos minutos empezará a rechazar tráfico honesto.

Los reintentos no activan la ventana. Cada intento recibe un X-Webhook-Timestamp nuevo y una firma nueva, mientras que X-Webhook-Delivery sigue igual. Esa división te da ambas defensas: la marca de tiempo limita cuánto tiempo sigue siendo utilizable una petición capturada, y un índice único sobre el ID de entrega impide que un reintento legítimo se procese dos veces. El artículo de límites de tasa e idempotencia cubre el mismo patrón en el lado de la API de entrada.

La rotación funciona mediante POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, o el botón Rotate en la página del endpoint. La respuesta contiene el secreto nuevo una vez, además de grace_window_days (7) y previous_expires_at. Durante esos siete días cada entrega lleva dos firmas:

  • X-Elido-Signature, hecha con el secreto nuevo
  • X-Elido-Signature-Previous, hecha con el antiguo

Por eso cada fragmento de arriba comprueba ambas cabeceras con el único secreto que tiene. Un receptor que aún ejecuta el secreto antiguo coincide con la segunda cabecera; después de que despliegues el nuevo, coincide con la primera. Nada falla entre medias. Trata la cabecera de la clave anterior como un puente y no como una garantía, y despliega el secreto nuevo pronto en la semana.

Línea de tiempo de rotación del secreto de un webhook: antes de la rotación solo se envía X-Elido-Signature; tras rotate-secret, durante una ventana de gracia de siete días, las entregas llevan X-Elido-Signature con la clave nueva y X-Elido-Signature-Previous con la antigua.

Por qué falla la verificación de firmas de webhooks

Cuando ayudo a alguien a depurar esto, casi siempre es la misma lista corta. Recórrela en orden:

  1. JSON vuelto a serializar. JSON.stringify(req.body) o json.dumps(payload) produce bytes distintos a los que hasheó el remitente: orden de claves, espaciado, barras escapadas, escapes unicode. Calcula el hash del cuerpo en bruto. Si tu framework ya lo analizó, arregla el orden del middleware en lugar de intentar reconstruir la cadena.
  2. Los bytes de la clave incorrectos. Elido usa la cadena whsec_... completa como clave HMAC. Quitar el prefijo, decodificar en hexadecimal el resto o decodificarlo en base64 (como hacen las bibliotecas de Standard Webhooks) te da una clave distinta. Un salto de línea final de echo en un archivo de secretos hace lo mismo, por eso el ejemplo de Python llama a .strip().
  3. Desajuste de codificación. Compara v1= más hexadecimal en minúsculas con la cabecera. Un resumen en base64, una cadena hexadecimal en mayúsculas o un prefijo ausente nunca coinciden.
  4. La marca de tiempo incorrecta. Usa la cadena de la cabecera X-Webhook-Timestamp, no el campo timestamp del cuerpo, y no un número que analizaste y volviste a formatear.

Dos más pequeños: comparar con == funciona pero filtra información temporal, así que usa la función de tiempo constante que ofrezca tu lenguaje; y un proxy que descomprime o recodifica cuerpos también romperá la verificación, aunque es raro con POST JSON simples.

Cuando ambos lados siguen sin coincidir, registra la marca de tiempo, la longitud del cuerpo y los primeros caracteres hexadecimales de tu resumen, y luego ejecuta la línea de openssl de antes con las mismas entradas. El lado que coincida con openssl es el correcto. Para ver dónde encaja la firma entre los demás controles que conviene comprobar en cualquier proveedor, consulta la lista de comprobación de seguridad de acortadores de URL.

Lee el artículo pilar: webhooks para eventos de enlaces.

Relacionado en el blog

Preguntas frecuentes

¿Cómo verifico la firma de un webhook?

Vuelve a calcular el HMAC sobre exactamente lo que firmó el remitente, usando el secreto compartido, y compara tu resultado con la cabecera de firma en tiempo constante. Para Elido eso significa HMAC-SHA256 sobre el valor de X-Webhook-Timestamp, un punto y el cuerpo en bruto, codificado en hexadecimal con el prefijo v1=. Rechaza la petición si nada coincide o si la marca de tiempo está caducada.

¿Por qué sigue fallando la verificación de la firma de mi webhook?

Casi siempre porque calculaste el hash de bytes distintos a los del remitente. Se ejecutó antes un analizador de cuerpo JSON y volviste a serializar el objeto, o decodificaste el secreto, o comparaste hexadecimal con base64. Calcula el hash de los bytes en bruto de la petición, usa la cadena del secreto exactamente como se emitió y registra ambos valores lado a lado.

¿Qué es HMAC en un webhook?

HMAC es un hash con clave: el remitente mezcla un secreto que comparte contigo en un hash SHA-256 del mensaje. Solo quien tenga el secreto puede producir un valor que coincida, así que una firma válida demuestra que la petición vino del remitente y que el cuerpo no se alteró por el camino.

¿Cómo evito los ataques de repetición de webhooks?

Firma la marca de tiempo junto con el cuerpo y rechaza cualquier petición cuya marca de tiempo tenga más de unos minutos; cinco minutos es una ventana habitual. Luego guarda el ID de entrega y omite los ID que ya procesaste. Elido firma la marca de tiempo pero deja la comprobación de frescura a tu receptor.

¿Debo usar hexadecimal o base64 para la firma de un webhook HMAC-SHA256?

Lo que documente el remitente, porque las dos codificaciones del mismo resumen nunca son iguales al compararlas. Elido envía hexadecimal en minúsculas tras el prefijo v1=. Shopify y la especificación Standard Webhooks usan base64, y GitHub usa hexadecimal tras sha256=. Codifica tu resumen de la misma forma antes de comparar.

¿Cómo roto el secreto de un webhook sin perder eventos?

Usa un remitente que firme con ambas claves durante un tiempo. Tras rotar el secreto de un endpoint de Elido, cada entrega lleva X-Elido-Signature con la clave nueva y X-Elido-Signature-Previous con la antigua durante siete días. Acepta cualquiera de las cabeceras, despliega el secreto nuevo y el antiguo caduca solo.

Prueba Elido

Pega una URL, obtén un enlace corto

Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.

Sin registro. Crea una cuenta gratis y obtén 50 enlaces al mes.

Prueba Elido

Acortador de URL alojado en la UE: dominios personalizados, análisis profundo y API abierta. Plan gratuito - sin tarjeta de crédito.

Etiquetas
verify webhook signature
webhook signature verification
hmac webhook
webhook hmac sha256
webhook replay attack
webhook secret rotation

Seguir leyendo