12 min de lecturaIngeniería

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

Cómo verificar una firma de webhook con HMAC-SHA256: cuerpo sin procesar, comparación en tiempo constante, ventana contra repeticiones y rotación de secretos, con código para Node, Python, Go y n8n.

Marius Voß
DevRel · edge infra
Portada de estilo píxel que muestra cómo verificar encabezados de firma de webhook: una marca de tiempo y un cuerpo sin procesar se procesan con HMAC-SHA256 en un valor hexadecimal v1=, comparado en tiempo constante junto a una clave de píxeles

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:

  1. 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.
  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 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.

Cómo verificar una firma de webhook: Elido une la marca de tiempo Unix, un punto y el cuerpo sin procesar, aplica HMAC-SHA256 con el secreto whsec_ y envía hexadecimal v1= en X-Elido-Signature; el receptor vuelve a calcular el mismo valor a partir de los bytes sin procesar y el encabezado de marca de tiempo, y luego compara en tiempo constante

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.

EncabezadoValorQué hacer con él
X-Elido-Signaturev1= + 64 caracteres hexadecimales en minúsculasComparar con el valor calculado
X-Webhook-SignatureMismo valor que arribaAlias anterior; leer uno u otro, no ambos
X-Webhook-TimestampSegundos Unix, p. ej. 1789000000Parte del mensaje firmado; comprobar su antigüedad
X-Elido-Signature-Previousv1= + hexadecimal, firmado con el secreto anteriorPresente solo durante una ventana de gracia de rotación
X-Webhook-EventNombre del evento, p. ej. link.createdEnrutar el evento (después de verificarlo)
X-Webhook-DeliveryID numérico de entrega, estable entre reintentosClave 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 nuevo
  • X-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.

Cronología de rotación de secretos de webhook: antes de la rotación solo se envía X-Elido-Signature; después de rotate-secret, durante una ventana de gracia de siete días, las entregas incluyen X-Elido-Signature con la clave nueva y X-Elido-Signature-Previous con la clave anterior, de modo que un receptor con cualquiera de los secretos verifica; después de la ventana solo firma la clave nueva

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:

  1. JSON vuelto a serializar. JSON.stringify(req.body) o json.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.
  2. 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 de echo en un archivo de secretos causa lo mismo, por eso el ejemplo de Python llama a .strip().
  3. 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.
  4. Marca de tiempo incorrecta. Usa la cadena del encabezado X-Webhook-Timestamp, no el campo timestamp del 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

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

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