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:
- 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.
- Hoe de digest is gecodeerd. Hex of base64, met een schemavoorvoegsel zoals
v1=ofsha256=. - 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.
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.
| Header | Waarde | Wat je ermee doet |
|---|---|---|
X-Elido-Signature | v1= + 64 hextekens in kleine letters | Vergelijk met je berekende waarde |
X-Webhook-Signature | Dezelfde waarde als hierboven | Oudere alias; lees er één, niet beide |
X-Webhook-Timestamp | Unix-seconden, bijv. 1789000000 | Deel van het ondertekende bericht; controleer de leeftijd |
X-Elido-Signature-Previous | v1= + hex, ondertekend met het oude secret | Alleen aanwezig tijdens een rotatie-uitloopvenster |
X-Webhook-Event | Eventnaam, bijv. link.created | Routeer het event (na verificatie) |
X-Webhook-Delivery | Numerieke delivery-ID, stabiel bij nieuwe pogingen | Ontdubbelsleutel 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 meerv1=-waarden in éénStripe-Signature-header, ondertekenttimestamp.bodymet 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 inX-Hub-Signature-256en adviseert een vergelijking in constante tijd (GitHub-docs). Er is geen timestamp om te controleren. - Standard Webhooks gebruikt
webhook-id,webhook-timestampenwebhook-signature, ondertekentid.timestamp.body, codeert in base64 met het voorvoegselv1,en laat de tolerantiewaarde aan jou over (spec). DeX-Webhook-Deliveryvan 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 secretX-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.
Waarom verificatie van webhook-handtekeningen mislukt
Als ik iemand help met debuggen, is het bijna altijd dezelfde korte lijst. Loop hem op volgorde af:
- Opnieuw geserialiseerde JSON.
JSON.stringify(req.body)ofjson.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. - 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 vanechonaar een secretbestand doet hetzelfde, en daarom roept het Python-voorbeeld.strip()aan. - 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. - De verkeerde timestamp. Gebruik de headerstring
X-Webhook-Timestamp, niet hettimestamp-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
- Webhooks voor linkevents - eventtypes, payload-envelope en het retrybeleid.
- Webhooks vs. polling voor kliktracking - wanneer push wint van pull, en wanneer niet.
- Zelf gehoste linkautomatisering met n8n - reverse proxy, queue-modus en handtekeningcontroles in één stack.
- URL-verkorter-API: rate limits, retries, idempotentie - de ontdubbelpatronen vanuit de andere richting.
- Beveiligingschecklist voor URL-verkorters - negen controles om bij elke aanbieder na te lopen.
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.