11 min leestijdEngineering

Webhook-handtekening verifiëren: HMAC in Node, Python, Go

Zo verifieer je een webhook-handtekening met HMAC-SHA256: raw body, constant-time vergelijking, replay-venster en secret-rotatie, met Node en Python.

Marius Voß
DevRel · edge infra

Om een webhook-handtekening te verifiëren bereken je een HMAC opnieuw over precies de bytes die de afzender heeft ondertekend, met het secret dat je met hem deelt, en vergelijk je jouw waarde in constante tijd met de handtekeningheader. Controleer daarna of de ondertekende timestamp recent is. Voor Elido-webhooks betekent dat: HMAC-SHA256 met je volledige whsec_...-secret als sleutel, over {X-Webhook-Timestamp}.{raw body}, hex-gecodeerd, met het voorvoegsel v1=, vergeleken met X-Elido-Signature.

Dat is het hele algoritme. De problemen zitten in de details eromheen: een bodyparser die te vroeg draaide, een secret dat is gedecodeerd, een hex-digest die met een base64-digest wordt vergeleken. Dit artikel behandelt HMAC-verificatie van webhook-handtekeningen in het algemeen, daarna werkende Elido-code voor Node, Python, Go en een n8n Code-node, plus het replay-venster en de secret-rotatie die de meeste quickstarts overslaan.

Heb je nog geen endpoint ingesteld? Begin dan bij webhooks voor linkevents, waar elk eventtype en de payload-envelope staan. Deze pagina begint zodra een ondertekend verzoek op je server aankomt.

Hoe HMAC-webhookhandtekeningen werken

Een webhook-endpoint is een publieke URL. Iedereen die hem vindt, kan een JSON-body POSTen die op een echt event lijkt, dus de ontvanger heeft bewijs van herkomst nodig. HMAC levert dat goedkoop: afzender en ontvanger delen een secret, de afzender berekent HMAC-SHA256(secret, message) en zet het resultaat in een header, en de ontvanger doet dezelfde berekening en vergelijkt. Zonder het secret kan niemand een overeenkomende waarde maken, en als je één byte van het bericht wijzigt, verandert de hele digest.

Drie details verschillen per aanbieder, en elk daarvan breekt de verificatie als je het verkeerd doet:

  1. Wat in het bericht zit. GitHub ondertekent alleen de raw body. Stripe en Elido ondertekenen een timestamp, een punt en de body. De Standard Webhooks-spec ondertekent een bericht-ID, de timestamp en de body.
  2. Hoe de digest is gecodeerd. Hex of base64, met een schemavoorvoegsel zoals v1= of sha256=.
  3. Wat de sleutel is. Sommige aanbieders base64-decoderen het secret na het voorvoegsel. Elido niet: de sleutel is de volledige whsec_-string als UTF-8-bytes.

Het is belangrijk dat de timestamp in het ondertekende bericht zit. Zo kan een aanvaller geen oude, geldig ondertekende body combineren met een verse timestamp-header, en alleen daardoor is een replay-venster af te dwingen.

Hoe je een webhook-handtekening verifieert: Elido voegt de unix-timestamp, een punt en de raw body samen, hasht dat met HMAC-SHA256 onder het whsec_-secret en stuurt v1= hex in X-Elido-Signature; de ontvanger berekent dezelfde waarde opnieuw uit de raw bytes en de timestamp-header en vergelijkt in constante tijd Het formulier van Elido voor het toevoegen van een webhook-endpoint met de endpoint-URL, de eventselectie en het signing secret dat ontvangers voor HMAC-verificatie gebruiken

Wat Elido ondertekent en welke headers de handtekening dragen

Elke levering aan een event- of siem-endpoint is een POST met Content-Type: application/json en een body in de vorm {"type", "workspace_id", "data", "timestamp"}. Endpointsoorten in chatvorm (Discord, Telegram, Sentry) authenticeren via hun URL en hebben geen HMAC-headers, dus alles hieronder geldt alleen voor de eerste twee soorten.

HeaderWaardeWat je ermee doet
X-Elido-Signaturev1= + 64 hextekens in kleine lettersVergelijk met je berekende waarde
X-Webhook-SignatureDezelfde waarde als hierbovenOudere alias; lees er één, niet beide
X-Webhook-TimestampUnix-seconden, bijv. 1789000000Deel van het ondertekende bericht; controleer de leeftijd
X-Elido-Signature-Previousv1= + hex, ondertekend met het oude secretAlleen aanwezig tijdens een rotatie-uitloopvenster
X-Webhook-EventEventnaam, bijv. link.createdRouteer het event (na verificatie)
X-Webhook-DeliveryNumerieke delivery-ID, stabiel bij nieuwe pogingenOntdubbelsleutel voor idempotente verwerking

Je krijgt het secret bij het aanmaken: whsec_ gevolgd door 64 hextekens. Het komt één keer terug in het create-antwoord en daarna nooit meer, dus het gaat meteen naar je secretopslag.

Hier is een testvector waartegen je je code kunt draaien: met het secret whsec_test_only_do_not_use, de timestamp 1789000000 en de body {"type":"link.created","workspace_id":42} is de juiste headerwaarde:

v1=b9369aa411a8b7ce705bcd5bba112dea9d72d2e787aa88959ff62f33942d1a15

Je kunt hem vanuit een shell reproduceren, en dat is mijn eerste zet wanneer een ontvanger het niet eens is met de afzender:

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

Eén valkuil: het eigen timestamp-veld van de body is het moment waarop het event plaatsvond, terwijl de ondertekende timestamp die uit de X-Webhook-Timestamp-header is, ingesteld op het moment van verzenden. Haal ze niet door elkaar.

Een webhook-handtekening valideren in Node

In Express is de oplossing voor de meeste fouten één regel: koppel express.raw() aan de webhookroute, zodat req.body een Buffer is met de exact ontvangen bytes. Registreer deze route vóór elke globale app.use(express.json()), want zodra de JSON-parser de stream heeft opgebruikt, valt er voor de raw-parser niets meer te lezen.

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);
  },
);

De lengtecontrole is geen versiering. timingSafeEqual gooit een fout bij buffers van verschillende lengte in plaats van false terug te geven. De TypeScript-SDK uit de API-en-SDK-quickstart staat nog niet op npm, dus schrijf de vergelijking zelf zoals hierboven.

Webhook-HMAC-SHA256-verificatie in Python en Go

De standaardbibliotheek van Python dekt het. Met FastAPI geeft await request.body() de raw bytes terug; in Flask roep je request.get_data() aan voordat iets request.json aanraakt.

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}

In Go lees je de body één keer, begrens je de grootte en gebruik je hmac.Equal uit crypto/hmac, dat in constante tijd vergelijkt. Ik bouw het bericht op uit de headerstring precies zoals ontvangen, in plaats van het geparste gehele getal opnieuw te formatteren.

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
}

Alle drie de versies antwoorden 401 bij een slechte handtekening en parsen pas JSON nadat de controle is geslaagd. Elido telt elke niet-2xx als een mislukte poging, dus als je verifier fout is, stapelen echte leveringen zich op als 401's in het leveringslogboek van het endpoint. Dat is de eerste plek om te kijken na een deploy.

Wil je dit tegen live verkeer zien? Maak in een werkruimte een endpoint aan via de webhooks-featurepagina en wijs het naar een lokale tunnel; het leveringslogboek toont per poging de statuscode die je verifier teruggaf.

De webhook-handtekening verifiëren in een n8n Code-node

n8n kan dezelfde controle uitvoeren zonder extra service. Zet de optie Raw Body in de Webhook-node aan, die het onaangeroerde verzoek als binaire data opslaat, en voeg er direct achter een Code-node aan toe. De ingebouwde crypto-module is toegestaan in de n8n Code-node, dus dit draait zoals het is:

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")) }];

Een gegooide fout stopt de uitvoering, dus er draait niets verderop op een vervalst event. Een valkuil bij zelf hosten: als je instantie N8N_BLOCK_ENV_ACCESS_IN_NODE=true instelt, kan de Code-node $env niet lezen, komt het secret leeg terug en faalt elke levering de controle. De gids voor zelf gehost n8n behandelt de kant van de reverse proxy, en de n8n-URL-verkorterpost laat zien wat je kunt bouwen zodra er events binnenkomen.

Hoe Elido verschilt van Stripe, GitHub en Standard Webhooks

Heb je een verifier voor een andere aanbieder geschreven, kopieer die dan niet blind. Gecontroleerd tegen de documentatie van elke leverancier op 2026-10-25:

  • Stripe zet t= en een of meer v1=-waarden in één Stripe-Signature-header, ondertekent timestamp.body met HMAC-SHA256 in hex, en zijn bibliotheken hanteren standaard een tolerantie van vijf minuten (Stripe-docs). Elido zet de timestamp in een eigen header.
  • GitHub ondertekent alleen de raw body, stuurt sha256= plus hex in X-Hub-Signature-256 en adviseert een vergelijking in constante tijd (GitHub-docs). Er is geen timestamp om te controleren.
  • Standard Webhooks gebruikt webhook-id, webhook-timestamp en webhook-signature, ondertekent id.timestamp.body, codeert in base64 met het voorvoegsel v1, en laat de tolerantiewaarde aan jou over (spec). De X-Webhook-Delivery van Elido speelt de rol van het ID, maar maakt geen deel uit van het ondertekende bericht.

Het eigen schema van Elido is dat van signing.Sign in api-core: het bericht is de Unix-timestamp, een punt en de body, de sleutel is de secret-string zoals opgeslagen, en het resultaat is v1= plus hex in kleine letters. Elido kent geen link.clicked-event, verwacht dus geen leveringen op klikniveau op dit kanaal.

Replay-vensters en secret-rotatie

Een geldige handtekening bewijst wie een verzoek stuurde, niet wanneer. Wie één ondertekende levering onderschept, uit een logregel of via een verkeerd geconfigureerde proxy, kan hem een week later opnieuw sturen en de HMAC klopt nog steeds. De timestamp-controle dicht dat gat, en die is jouw taak: de delivery-worker van Elido ondertekent de timestamp, maar dwingt aan jouw kant geen venster af. Ik gebruik 300 seconden. Houd de klok van de ontvanger gesynchroniseerd met NTP, want een server die een paar minuten afdrijft, gaat eerlijk verkeer weigeren.

Nieuwe pogingen halen het venster niet onderuit. Elke poging krijgt een verse X-Webhook-Timestamp en een nieuwe handtekening, terwijl X-Webhook-Delivery gelijk blijft. Die splitsing geeft je beide verdedigingen: de timestamp begrenst hoe lang een onderschept verzoek bruikbaar blijft, en een unieke index op de delivery-ID voorkomt dat een legitieme nieuwe poging twee keer wordt verwerkt. De post over rate limits en idempotentie behandelt hetzelfde patroon aan de kant van de inkomende API.

Rotatie werkt via POST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret, of de knop Rotate op de endpointpagina. Het antwoord bevat het nieuwe secret één keer, plus grace_window_days (7) en previous_expires_at. Die zeven dagen bevat elke levering twee handtekeningen:

  • X-Elido-Signature, gemaakt met het nieuwe secret
  • X-Elido-Signature-Previous, gemaakt met het oude

Daarom controleert elk fragment hierboven beide headers tegen het ene secret dat het kent. Een ontvanger die nog het oude secret draait, komt overeen met de tweede header; nadat je het nieuwe hebt gedeployed, met de eerste. Ertussenin faalt niets. Zie de header met de vorige sleutel wel als een brug en niet als een garantie, en lever het nieuwe secret vroeg in de week.

Tijdlijn van webhook-secret-rotatie: vóór de rotatie wordt alleen X-Elido-Signature verstuurd; na rotate-secret bevatten leveringen gedurende een uitloopvenster van zeven dagen X-Elido-Signature met de nieuwe sleutel en X-Elido-Signature-Previous met de oude sleutel, zodat een ontvanger met een van beide secrets verifieert.

Waarom verificatie van webhook-handtekeningen mislukt

Als ik iemand help met debuggen, is het bijna altijd dezelfde korte lijst. Loop hem op volgorde af:

  1. Opnieuw geserialiseerde JSON. JSON.stringify(req.body) of json.dumps(payload) levert andere bytes op dan de afzender heeft gehasht: sleutelvolgorde, spaties, geëscapete slashes, unicode-escapes. Hash de raw body. Als je framework hem al heeft geparsed, repareer dan de volgorde van de middleware in plaats van de string te willen reconstrueren.
  2. De verkeerde sleutelbytes. Elido gebruikt de hele whsec_...-string als HMAC-sleutel. Het voorvoegsel weghalen, de rest hex-decoderen of base64-decoderen (wat Standard Webhooks-bibliotheken doen) geeft een andere sleutel. Een afsluitende nieuwe regel van echo naar een secretbestand doet hetzelfde, en daarom roept het Python-voorbeeld .strip() aan.
  3. Coderingsmismatch. Vergelijk v1= plus hex in kleine letters met de header. Een base64-digest, een hexstring in hoofdletters of een ontbrekend voorvoegsel komt nooit overeen.
  4. De verkeerde timestamp. Gebruik de headerstring X-Webhook-Timestamp, niet het timestamp-veld van de body en ook geen getal dat je hebt geparsed en opnieuw geformatteerd.

Nog twee kleinere: vergelijken met == werkt maar lekt timing, dus gebruik de constante-tijdfunctie die je taal meelevert; en een proxy die bodies decomprimeert of opnieuw codeert, breekt de verificatie ook, al is dat zeldzaam bij gewone JSON-POST's.

Als beide kanten het nog steeds oneens zijn, log dan de timestamp, de lengte van de body en de eerste paar hextekens van je digest, en draai de openssl-regel van eerder op dezelfde invoer. Welke kant ook met openssl overeenkomt, is de juiste. Voor de plaats van ondertekening tussen de andere controles die bij elke aanbieder het nalopen waard zijn, zie de beveiligingschecklist voor URL-verkorters.

Lees de cornerstone: webhooks voor linkevents.

Gerelateerd op de blog

Veelgestelde vragen

Hoe verifieer ik een webhook-handtekening?

Bereken de HMAC opnieuw over precies datgene wat de afzender heeft ondertekend, met het gedeelde secret, en vergelijk je resultaat in constante tijd met de handtekeningheader. Voor Elido betekent dat HMAC-SHA256 over de waarde van X-Webhook-Timestamp, een punt en de raw body, hex-gecodeerd met het voorvoegsel v1=. Weiger het verzoek als er niets overeenkomt of als de timestamp verouderd is.

Waarom mislukt de verificatie van mijn webhook-handtekening steeds?

Vrijwel altijd omdat je andere bytes hebt gehasht dan de afzender. Er draaide eerst een JSON-bodyparser en je hebt het object opnieuw geserialiseerd, of je hebt het secret gedecodeerd, of je vergeleek hex met base64. Hash de raw request-bytes, gebruik de secret-string precies zoals die is uitgegeven en log beide waarden naast elkaar.

Wat is HMAC in een webhook?

HMAC is een hash met sleutel: de afzender mengt een secret dat hij met jou deelt door een SHA-256-hash van het bericht. Alleen wie het secret kent, kan een overeenkomende waarde maken. Een geldige handtekening bewijst dus dat het verzoek van de afzender komt en dat de body onderweg niet is gewijzigd.

Hoe voorkom ik replay-aanvallen op webhooks?

Onderteken de timestamp samen met de body en weiger elk verzoek waarvan de timestamp meer dan een paar minuten oud is; vijf minuten is een gangbaar venster. Sla daarna de delivery-ID op en sla ID's over die je al hebt verwerkt. Elido ondertekent de timestamp, maar laat de controle op versheid aan jouw ontvanger over.

Moet ik hex of base64 gebruiken voor een HMAC-SHA256-webhookhandtekening?

Wat de afzender documenteert, want de twee coderingen van dezelfde digest zijn nooit gelijk. Elido stuurt kleine letters in hex na het voorvoegsel v1=. Shopify en de Standard Webhooks-spec gebruiken base64, en GitHub gebruikt hex na sha256=. Codeer je digest op dezelfde manier voordat je vergelijkt.

Hoe roteer ik een webhook-secret zonder events te verliezen?

Gebruik een afzender die een tijdje met beide sleutels ondertekent. Nadat je het secret van een Elido-endpoint hebt geroteerd, bevat elke levering zeven dagen lang X-Elido-Signature met de nieuwe sleutel en X-Elido-Signature-Previous met de oude. Accepteer beide headers, deploy het nieuwe secret en het oude verloopt vanzelf.

Probeer Elido

Plak een URL, krijg een werkende korte link

Geen aanmelding nodig. Link blijft 30 dagen actief. Meld je aan om hem voor altijd te bewaren.

Geen account nodig. Meld je gratis aan voor 50 links per maand.

Probeer Elido

In de EU gehoste URL-shortener met aangepaste domeinen, uitgebreide analyses en een open API. Gratis abonnement - geen creditcard nodig.

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

Verder lezen