Para verificar una firma de 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 el encabezado de firma en tiempo constante. Después comprueba que la marca de tiempo firmada sea reciente. Para los webhooks de Elido, esto significa HMAC-SHA256 con la totalidad de tu secreto whsec_... como clave, sobre {X-Webhook-Timestamp}.{raw body}, codificado en hexadecimal, con el prefijo v1=, y cotejado con X-Elido-Signature.
Ese es todo el algoritmo. Los fallos proceden de los detalles que lo rodean: un analizador de cuerpo ejecutado demasiado pronto, un secreto que se decodificó, un resumen hexadecimal comparado con uno base64. Esta publicación aborda la verificación de firmas de webhook HMAC en general y luego incluye código funcional de Elido para Node, Python, Go y un nodo Code de n8n, además de la ventana contra repeticiones y la rotación de secretos que la mayoría de las guías rápidas omiten.
Si aún no configuraste un endpoint, comienza con webhooks para eventos de enlaces, donde se enumeran todos los tipos de eventos y el contenedor de la carga útil. Esta página continúa a partir del momento en que una solicitud firmada llega a tu servidor.
Cómo funcionan las firmas HMAC de webhook
Un endpoint de webhook es una URL pública. Cualquiera que la encuentre puede enviar mediante POST un cuerpo JSON que parezca un evento real, por lo que el receptor necesita una prueba de origen. HMAC la proporciona de forma sencilla: remitente y receptor comparten un secreto, el remitente calcula HMAC-SHA256(secret, message) y coloca el resultado en un encabezado, y el receptor hace el mismo cálculo y compara. Sin el secreto, nadie puede producir un valor coincidente, y cambiar un solo byte del mensaje modifica todo el resumen.
Tres detalles varían entre proveedores, y cada uno hace fallar la verificación si te equivocas:
- Qué se incluye en el mensaje. GitHub firma solo el cuerpo sin procesar. 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.
- Cómo se codifica el resumen. Hexadecimal o base64, con un prefijo de esquema como
v1=osha256=. - Cuál es la clave. Algunos proveedores decodifican en base64 el secreto después de su prefijo. Elido no: la clave es la cadena
whsec_completa como bytes UTF-8.
Incluir la marca de tiempo en el mensaje firmado es importante. Impide que un atacante combine un cuerpo antiguo firmado válidamente con un encabezado de marca de tiempo reciente, que es lo que hace aplicable una ventana contra repeticiones.
Qué firma Elido y qué encabezados la contienen
Cada entrega a un endpoint event o siem es una solicitud POST con Content-Type: application/json y un cuerpo con la forma {"type", "workspace_id", "data", "timestamp"}. Los tipos de endpoint con formato de chat (Discord, Telegram, Sentry) se autentican mediante su URL y no incluyen encabezados HMAC, por lo que todo lo siguiente se aplica solo a los dos primeros tipos.
| Encabezado | Valor | Qué hacer con él |
|---|---|---|
X-Elido-Signature | v1= + 64 caracteres hexadecimales en minúsculas | Comparar con el valor calculado |
X-Webhook-Signature | Mismo valor que arriba | Alias anterior; leer uno u otro, no ambos |
X-Webhook-Timestamp | Segundos Unix, p. ej. 1789000000 | Parte del mensaje firmado; comprobar su antigüedad |
X-Elido-Signature-Previous | v1= + hexadecimal, firmado con el secreto anterior | Presente solo durante una ventana de gracia de rotación |
X-Webhook-Event | Nombre del evento, p. ej. link.created | Enrutar el evento (después de verificarlo) |
X-Webhook-Delivery | ID numérico de entrega, estable entre reintentos | Clave de deduplicación para procesamiento idempotente |
El secreto se genera para ti al crear el endpoint: whsec_ seguido de 64 caracteres hexadecimales. Se devuelve una vez en la respuesta de creación y nunca más, así que debe ir directamente a tu almacén de secretos.
Aquí tienes un vector de prueba para ejecutar con 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 del encabezado es:
v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15
Puedes reproducirlo desde una consola, que es lo primero que hago cuando un receptor no coincide con el remitente:
printf '%s.%s' 1789000000 '{"type":"link.created","workspace_id":42}' \
| openssl dgst -sha256 -hmac 'whsec_test_only_do_not_use' -r
Hay una trampa aquí: el campo timestamp del propio cuerpo es la hora en que ocurrió el evento. La marca de tiempo firmada es la del encabezado X-Webhook-Timestamp, establecida cuando se envía la solicitud. No las confundas.
Validar una firma de 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 sin procesar ya 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 un adorno. timingSafeEqual lanza una excepción con búferes de longitudes distintas en vez de devolver falso. Si usas el SDK de TypeScript de la guía rápida de API y SDK, webhooks.verify() de @elido/sdk realiza el mismo HMAC y la comparación en tiempo constante. Sin embargo, pasa { maxSkewSec: 300 } explícitamente: sin esa opción no comprueba en absoluto la antigüedad de la marca de tiempo.
Verificación HMAC SHA256 de webhook en Python y Go
La biblioteca estándar de Python lo cubre. Con FastAPI, await request.body() devuelve los bytes sin procesar; en Flask, llama a request.get_data() antes de que algo acceda a 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 del encabezado exactamente como se recibió, en vez 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 JSON después de que la comprobación se aprueba. Elido considera fallido cualquier intento que no sea 2xx, así que si tu verificador está mal, las entregas auténticas se acumulan como 401 en el registro de entregas del endpoint. Ese es el primer lugar que debes revisar 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 para cada intento.
Verificar la firma de webhook dentro de un nodo Code de n8n
n8n puede hacer la misma comprobación sin servicio adicional. Activa la opción Raw Body en el nodo Webhook, que almacena la solicitud intacta como datos binarios, y después añade un nodo Code justo a continuación. El módulo integrado crypto 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, por lo que no se ejecuta nada posterior con un evento falsificado. Una consideración al alojarlo tú mismo: si tu instancia establece N8N_BLOCK_ENV_ACCESS_IN_NODE=true, el nodo Code no puede leer $env, el secreto queda vacío y todas las entregas fallan la comprobación. La guía de n8n autoalojado cubre la parte del proxy inverso, y la publicación sobre el acortador de URL de n8n muestra qué crear cuando llegan los eventos.
Ventanas contra repeticiones y rotación de secretos
Una firma válida demuestra quién envió una solicitud, 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 responsabilidad: el worker de entregas de Elido firma la marca de tiempo, pero no aplica ninguna ventana de tu lado. Uso 300 segundos. Mantén sincronizado el reloj del receptor con NTP, ya que un servidor cuyo reloj se desfase unos minutos empezará a rechazar tráfico legítimo.
Los reintentos no activan la ventana. Cada intento recibe un nuevo X-Webhook-Timestamp y una firma nueva, mientras que X-Webhook-Delivery se mantiene igual. Esa separación te da ambas defensas: la marca de tiempo limita cuánto tiempo puede usarse una solicitud capturada, y un índice único sobre el ID de entrega evita que un reintento legítimo se procese dos veces. La publicación sobre límites de velocidad e idempotencia cubre el mismo patrón desde el lado de la API entrante.
La rotación funciona mediante POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, o con el botón Rotar de la página del endpoint. La respuesta contiene el secreto nuevo una sola vez, además de grace_window_days (7) y previous_expires_at. Durante esos siete días, cada entrega incluye dos firmas:
X-Elido-Signature, creada con el secreto nuevoX-Elido-Signature-Previous, creada con el secreto anterior
Por eso cada fragmento de código anterior comprueba ambos encabezados con el único secreto que contiene. Un receptor que todavía usa el secreto anterior coincide con el segundo encabezado; después de desplegar el nuevo, coincide con el primero. No hay fallos entre ambos momentos. Sin embargo, considera el encabezado de clave anterior como un puente, no como una garantía, y despliega el secreto nuevo al inicio de la semana.
Por qué falla la verificación de firmas de webhook
Cuando ayudo a alguien a depurar esto, casi siempre es la misma lista corta. Revísala en orden:
- JSON vuelto a serializar.
JSON.stringify(req.body)ojson.dumps(payload)produce bytes distintos de aquellos a los que el remitente aplicó el hash: orden de claves, espacios, barras escapadas, escapes Unicode. Aplica el hash al cuerpo sin procesar. Si tu marco de trabajo ya lo analizó, corrige el orden del middleware en vez de intentar reconstruir la cadena. - Bytes de clave incorrectos. Elido usa toda la cadena
whsec_...como clave HMAC. Quitar el prefijo, decodificar el resto como hexadecimal o decodificarlo como base64 (lo que hacen las bibliotecas Standard Webhooks) te da una clave distinta. Una nueva línea final deechoen un archivo de secretos causa lo mismo, por eso el ejemplo de Python llama a.strip(). - Codificación distinta. Compara
v1=más hexadecimal en minúsculas con el encabezado. Un resumen base64, una cadena hexadecimal en mayúsculas o un prefijo ausente nunca coinciden. - Marca de tiempo incorrecta. Usa la cadena del encabezado
X-Webhook-Timestamp, no el campotimestampdel cuerpo ni un número que hayas analizado y vuelto a formatear.
Hay dos casos menores: comparar con == funciona, pero filtra información de tiempo, así que usa la función de tiempo constante que incluye tu lenguaje; y un proxy que descomprime o recodifica cuerpos también romperá la verificación, aunque es poco común en solicitudes POST JSON simples.
Cuando ambos lados sigan sin coincidir, registra la marca de tiempo, la longitud del cuerpo y los primeros caracteres hexadecimales de tu resumen; luego ejecuta la línea de openssl anterior con las mismas entradas. El lado que coincida con openssl es el correcto. Para saber dónde encaja la firma entre los demás controles que conviene comprobar con cualquier proveedor, consulta la lista de verificación de seguridad para acortadores de URL.
Lee el artículo central: webhooks para eventos de enlaces.
Contenido relacionado en el blog
- Webhooks para eventos de enlaces - tipos de eventos, contenedor de carga útil y política de reintentos.
- Webhooks frente a sondeo para el seguimiento de clics - cuándo el envío (push) supera a la consulta (pull) y cuándo no.
- Automatización de enlaces autoalojada con n8n - proxy inverso, modo de cola y comprobaciones de firma en una sola pila.
- API de acortador de URL: límites de velocidad, reintentos e idempotencia - los patrones de deduplicación desde la otra dirección.
- Lista de verificación de seguridad para acortadores de URL - nueve controles que verificar con cualquier proveedor.
Preguntas frecuentes
¿Cómo verifico una firma de webhook?
Vuelve a calcular el HMAC sobre exactamente lo que firmó el remitente, usando el secreto compartido, y compara tu resultado con el encabezado de firma en tiempo constante. Para Elido, esto significa HMAC-SHA256 sobre el valor de X-Webhook-Timestamp, un punto y el cuerpo sin procesar, codificado en hexadecimal con el prefijo v1=. Rechaza la solicitud si no hay coincidencias o la marca de tiempo no es reciente.
¿Por qué sigue fallando la verificación de mi firma de webhook?
Casi siempre porque aplicaste el hash a bytes distintos de los usados por el remitente. Primero se ejecutó un analizador de cuerpo JSON y volviste a serializar el objeto, o decodificaste el secreto, o comparaste hexadecimal con base64. Aplica el hash a los bytes sin procesar de la solicitud, usa la cadena secreta exactamente como se emitió y registra ambos valores en paralelo.
¿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 posee el secreto puede generar un valor coincidente, por lo que una firma válida demuestra que la solicitud proviene del remitente y que el cuerpo no cambió durante el trayecto.
¿Cómo evito ataques de repetición de webhook?
Firma la marca de tiempo junto con el cuerpo y rechaza cualquier solicitud cuya marca de tiempo tenga más de unos minutos; cinco minutos es una ventana habitual. Después guarda el ID de entrega y omite los ID que ya hayas procesado. Elido firma la marca de tiempo, pero deja la comprobación de vigencia a cargo de tu receptor.
¿Debo usar hexadecimal o base64 para una firma de webhook HMAC-SHA256?
Usa lo que documente el remitente, porque las dos codificaciones del mismo resumen nunca coinciden. Elido envía hexadecimal en minúsculas después de un prefijo v1=. Shopify y la especificación Standard Webhooks usan base64, y GitHub usa hexadecimal después de sha256=. Codifica tu resumen de la misma forma antes de comparar.
¿Cómo roto un secreto de webhook sin perder eventos?
Usa un remitente que firme con ambas claves durante un tiempo. Después de rotar un secreto de endpoint de Elido, cada entrega incluye X-Elido-Signature con la nueva clave y X-Elido-Signature-Previous con la anterior durante siete días. Acepta cualquiera de los dos encabezados, despliega el secreto nuevo y el antiguo vencerá por sí solo.
Prueba Elido
Pega una URL, obtén un enlace corto
Sin registro. El enlace vive 30 días. Crea una cuenta para conservarlo.
Gratis, sin registro · 2 por día