Webhook-signaturen lyver ikke, hvis du lærer at læse den rigtigt
“Det er bare en POST, hvor slemt kan det være?”
Min første “rigtige” webhook var fra en betalingsudbyder. Jeg pegede den mod et endpoint, loggede payloaden, gemte noget i databasen og tænkte: færdig. Ingen signatur, ingen tidsstempler, ingen idempotens. Bare ren optimisme.
Det gik faktisk fint. Længe. Indtil en aften hvor en kunde tilsyneladende fik refunderet den samme ordre to gange. Logs viste tre næsten identiske webhook-kald, og min kode troede pænt på dem alle sammen. Jeg havde hverken verificeret signatur eller timestamp, og jeg havde slet ikke tænkt på retries.
Efter den weekend med manuelt rod i databasen besluttede jeg mig for at lave en standard-opskrift på “seriøse” webhooks. Den får du her, uden afhængighed til en bestemt udbyder, og med alle de små ting som docs ofte springer let hen over.
Hvad du reelt er oppe imod med webhooks
Jeg plejer at se webhook-trusler i tre kategorier. Ikke for at gøre dig bange, bare så du ikke bygger noget naivt (igen, som jeg gjorde).
De tre ting er:
- Spoofing – nogen sender et request der ligner din udbyder, men ikke er det.
- Replay – et ægte request bliver sendt igen senere, bevidst eller ved et uheld.
- Dubletter – udbyderen leverer samme event flere gange, typisk pga. retries.
Hvis du ikke både verificerer og gør dine handler idempotente, rammer du før eller siden en af dem. Ofte på det mindst praktiske tidspunkt.
Den lille webhook-pipeline jeg ville ønske, jeg startede med
I stedet for at tænke “en controller der gør det hele”, er det lettere at se dit webhook-flow som en fast pipeline.
Jeg deler den typisk op sådan her:
- 1) Modtag request og læs rå body
- 2) Verificer signatur + timestamp
- 3) Persistér event (i database eller kø)
- 4) Dedupliker og processér i baggrunden
Det vigtige er, at din HTTP-handler gør så lidt som muligt: verificerer, gemmer, svarer 2xx. Selve “forretningen” sker et andet sted. Det er den samme tankegang som i mange andre dele af backend til web: få ting hurtigt væk fra nettet og ind i noget kontrolleret.
Signaturverificering uden magi
De fleste seriøse udbydere bruger en variant af HMAC til webhook-signaturer. Grundideen er den samme uanset Stripe, GitHub eller noget tredje.
Opskriften ser sådan her ud:
- Du får en secret fra udbyderen (del den aldrig offentligt).
- Når de sender en webhook, laver de en HMAC af payloaden (ofte inkl. timestamp).
- De sender signaturen som en header.
- Du genskaber selv HMAC’en og sammenligner.
Eksempel i Node: verificer webhook signatur med HMAC
Her er et minimalt eksempel i Node/Express. Ideen er den samme i andre sprog.
import crypto from "crypto";
import express from "express";
const app = express();
// VIGTIGT: brug raw body middleware til netop denne route
app.post("/webhooks/payment", express.raw({ type: "application/json" }), (req, res) => {
const signatureHeader = req.header("x-webhook-signature");
const timestampHeader = req.header("x-webhook-timestamp");
if (!signatureHeader || !timestampHeader) {
return res.status(400).send("Missing signature headers");
}
const secret = process.env.WEBHOOK_SECRET;
const rawBody = req.body; // Buffer
const payloadToSign = `${timestampHeader}.${rawBody.toString("utf8")}`;
const expected = crypto
.createHmac("sha256", secret)
.update(payloadToSign)
.digest("hex");
if (!safeCompare(signatureHeader, expected)) {
return res.status(400).send("Invalid signature");
}
// Her ved du, at payloaden kommer fra din udbyder
// ... gem eventet og svar 200
res.status(200).send("ok");
});
function safeCompare(a, b) {
// Undgå timing attacks
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
To ting kan drille her:
- Du skal bruge den rå body, ikke den JSON-parsede version.
- Præcist format af
payloadToSignafhænger af udbyderen, så tjek deres docs.
Men selve mønsteret er altid: secret + HMAC + timing-safe sammenligning.
Timestamps og anti-replay i praksis
En signatur alene stopper ikke replay angreb. En angriber kan snuppe et ægte, signeret request og sende det igen i morgen. Derfor har mange udbydere et timestamp med i signaturen.
Din opgave er at afvise events der er for gamle eller for langt ude i fremtiden.
Et lille tolerance-vindue
Jeg plejer at gøre noget i den her stil:
const now = Math.floor(Date.now() / 1000); // sekunder
const ts = parseInt(timestampHeader, 10);
const maxSkew = 5 * 60; // 5 minutter
if (Math.abs(now - ts) > maxSkew) {
return res.status(400).send("Timestamp outside tolerance");
}
Det beskytter dig mod at nogen gensender en gammel event timevis eller dage senere. Samtidig tåler du lidt clock drift mellem dig og udbyderen. Hvis du har meget skæve ure, har du typisk andre problemer, og så er det alligevel værd at få styr på din server-tid.
Log det vigtige, ikke hemmelighederne
Webhook-fejl uden logging er noget nær umulige at forstå. Men webhook-logging fyldt med secrets er næsten lige så dumt.
Jeg går typisk efter det her kompromis:
- Log request-id, event-id, endpoint, statuskode, varighed.
- Log aldrig hele Authorization headers eller webhook secrets.
- Maskér følsomme felter i payload (email, kort-id, tokens).
Hvis du er ny til logs, er det værd at kigge på dine egne mønstre for logs og stacktraces generelt. De samme principper går igen: nok data til at kunne fejlsøge, men ikke så meget at du skaber nye problemer.
Idempotens: sådan undgår du at samme event kører flere gange
Selv hvis du verificerer signatur og timestamp korrekt, vil din udbyder stadig sende dubletter ind imellem. Det er en feature, ikke en bug. De vil være sikre på, at du har fået beskeden.
Det betyder, at din egen behandling af webhooken skal være idempotent. En event må kunne behandles flere gange uden at have ny effekt.
Minimal dedupe-strategi
Den model jeg er endt med flest gange er en kombination:
- Brug event-id fra udbyderen, hvis den findes.
- Supplér med en payload-hash for ekstra sikkerhed.
I databasen kan tabellen se sådan her (pseudo):
webhook_events
-------------
id (PK)
provider_event_id (nullable)
payload_hash
status (received | processed | failed)
received_at
data (jsonb / text)
UNIQUE(provider_event_id)
UNIQUE(payload_hash)
Flowet i din kode:
- Efter signatur-check beregner du et hash af rå payload.
- Du forsøger at
INSERTen række med event-id + hash. - Hvis insert fejler på unik constraint, ved du at du har set den før og kan nøjes med at returnere 200.
I mange databaser kan du gøre det med noget ala INSERT ... ON CONFLICT DO NOTHING og så tjekke om der faktisk blev indsat noget.
Retries og dead letters i mini-format
Selv med alt ovenstående vil enkelte events fejle i selve behandlingen: din database er nede, din interne API fejler, eller der er en uforudset edge case.
Her vil du gerne have to ting:
- Automatiske retries med fornuftig backoff.
- Et sted hvor “umulige” events ender, så du kan kigge på dem manuelt.
Hvis du ikke har køer og big setup, kan du køre et meget simpelt mønster:
- Gem eventet i databasen med status =
received. - Lad en baggrunds-job (cron, worker, hvad du har) hente events med status =
receivedog forsøge at behandle dem. - Ved fejl: øg et
attempt_countfelt og sæt evt.next_attempt_attil lidt ude i fremtiden. - Hvis
attempt_countoverstiger f.eks. 10, sæt status =dead_letterog stop automatiske forsøg.
Det er ikke en komplet kø-løsning, men det er markant bedre end bare at fejle hårdt og håbe på, at udbyderen prøver igen på et magisk tidspunkt.
Lokal udvikling uden at få grå hår
Webhook-udvikling lokalt kan føles bøvlet, fordi du pludselig skal have noget udefra til at ramme din maskine. Jeg plejer at bygge det op i lag.
1. Start med fixtures
Det mest oversete trin er at teste din signaturverificering helt uden netværk. Tag et eksempel fra docs eller produktionslog: rå payload, timestamp, signatur, secret.
Lav en lille test-fil der kalder din verify-funktion med de værdier og forventer succes. Skift så en karakter i payloaden og forvent fejl. Det tvinger dig til at få detaljerne rigtigt.
2. Brug en tunneling-løsning, når du er klar
Når selve signatur-koden er testet, kan du bruge ngrok, cloudflared tunnel eller noget lignende til at eksponere din lokale port midlertidigt. Så kan din udbyder sende rigtige webhooks ind i din udviklermaskine.
Her giver det mening at have din lille pipeline på plads, så du både kan logge pænt og sætte breakpoints i behandlingen. Det føles meget som første gang man åbnede Chrome DevTools Network og alt pludselig gav mening, sådan som jeg beskrev i artiklen om DevTools og netværk.
Hvilke metrics du vil savne, hvis du ikke har dem
Webhook-problemer kommer ofte snigende. Pludselig opdager du, at noget ikke er blevet behandlet i flere timer, men du ved ikke hvornår det startede.
Jeg vil mindst have de her metrics fra dag 1:
- Antal modtagne webhooks per provider per endpoint.
- Antal valideringsfejl (signatur/timestamp) per endpoint.
- Antal dubletter (forsøg på at indsætte samme event-id igen).
- Behandlingstid fra modtaget til behandlet.
- Antal events i status =
dead_letter.
Det kan være simple tællere i en dashboard-løsning eller bare regelmæssige queries mod databasen. Pointen er, at du gerne vil opdage en bølge af fejl før en kollega ringer og siger “kunden siger at fakturaer ikke bliver sendt ud”.
Et samlet eksempel-flow i pseudo
For lige at binde det hele sammen får du her et pseudo-flow for et webhook-endpoint med signaturverificering, anti-replay og idempotens. Du kan oversætte det til dit eget sprog og stack.
// HTTP handler
rawBody = readRawBody(request)
headers = request.headers
if (!verifySignature(rawBody, headers, SECRET)) {
return 400
}
if (!verifyTimestamp(headers["x-webhook-timestamp"], 5 minutes)) {
return 400
}
payloadHash = sha256(rawBody)
eventId = extractEventId(rawBody)
inserted = insertWebhookEvent({
provider_event_id: eventId,
payload_hash: payloadHash,
data: rawBody,
status: "received",
})
if (!inserted) {
// Dublet, men signeret og inden for tidsvindue
return 200
}
return 200
// Baggrunds-job
while (true) {
events = findEvents({ status: "received", next_attempt_at <= now })
for (event of events) {
try {
processBusinessLogic(event.data)
markEventProcessed(event.id)
} catch (err) {
incrementAttemptCount(event.id)
if (event.attempt_count > MAX_ATTEMPTS) {
markDeadLetter(event.id)
} else {
scheduleNextAttempt(event.id)
}
}
}
sleep(SOME_INTERVAL)
}
Det ser måske lidt langt ud på skrift, men i kode er det ofte 150 linjer i alt. Og du slipper for at rette ting i hånden i databasen søndag aften.
Hvor du kan bygge videre
Hvis du først er i gang med at gøre dine integrationer lidt mere voksne, hænger webhook-sikkerhed ofte sammen med andre emner som secrets, drift og debugging.
Det kan være værd at kigge på hvordan du håndterer API-nøgler og andre secrets, og generelt lidt mere om it sikkerhed for udviklere, hvis du føler, at tingene vokser fra “lille hobbyprojekt” til “vi tager faktisk betalinger nu”.
Og hvis du ender med at bygge dig et helt lille webhook-system en sen aften, er du i godt selskab. Jeg har også siddet og justeret en backoff-algoritme kl. 23.47 uden at nogen andre i huset anede, hvorfor det var spændende.









Send kommentar
Du skal være logget ind for at skrive en kommentar.