Webhook-signaturen lyver ikke, hvis du lærer at læse den rigtigt

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:

  1. Du får en secret fra udbyderen (del den aldrig offentligt).
  2. Når de sender en webhook, laver de en HMAC af payloaden (ofte inkl. timestamp).
  3. De sender signaturen som en header.
  4. 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 body, ikke den JSON-parsede version.
  • Præcist format af payloadToSign afhæ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:

  1. Efter signatur-check beregner du et hash af rå payload.
  2. Du forsøger at INSERT en række med event-id + hash.
  3. 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:

  1. Gem eventet i databasen med status = received.
  2. Lad en baggrunds-job (cron, worker, hvad du har) hente events med status = received og forsøge at behandle dem.
  3. Ved fejl: øg et attempt_count felt og sæt evt. next_attempt_at til lidt ude i fremtiden.
  4. Hvis attempt_count overstiger f.eks. 10, sæt status = dead_letter og 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.

Et typisk vindue er 2-5 minutter, men vælg ud fra dine netværksforhold og hvor følsom din forretning er. Synk dine servere med NTP, accepter et lille skub i begge retninger, og log eller mål afviste tidsstempler så du kan justere grænsen hvis legitime requests fejler.
Brug først leverandørens event-id hvis det findes; det er oftest det mest pålidelige. Mangler det, gem en hash af payload + event-type eller signaturen, og sæt en unik constraint i databasen for at sikre atomisk deduplikering.
Brug en idempotency-key eller event-id i et opdateret/insert-flow: 'upsert' eller en unik constraint kombineret med en processed-flag og transaktioner for at sikre kun-et-gang-effekt. Alternativt gem resultaterne af første kørsel og returner dem ved gentagne requests i stedet for at genkøre sideeffekter.
Planlæg rotation ved at acceptere både ny og gammel secret i overgangsperioden, så du ikke mister events under udrulning. Ved kompromis: fjern den gamle secret, roter hurtigt, informer udbyderen hvis relevant, og gennemgå logs for misbrug.

Jonas Kirkeby har skrevet kode siden han som teenager forsøgte at lave en helt simpel hjemmeside til sin fars lille vvs-firma – og endte med at sidde oppe hele natten for at få en knap til at skifte farve. Siden da har han lært sig det meste ved at prøve sig frem, kopiere andres eksempler, ødelægge dem og langsomt forstå, hvorfor tingene virker, som de gør.

Til daglig arbejder han slet ikke med IT, men bruger aftener og morgener på små projekter: en lille side til en forening, et simpelt værktøj til at holde styr på familiens madplan eller et Python-script, der rydder op i rodede filer. Det er den slags konkrete hverdags-behov, der har formet hans måde at tænke kodning på – hvad kan jeg bygge nu, som faktisk hjælper mig eller nogen, jeg kender?

På Coding Class deler Jonas de guides, han selv ville ønske, han havde haft: korte, konkrete forløb, hvor du kan se noget på skærmen efter få minutters læsning. Han viser hele vejen fra idé til færdig løsning, inklusive de typiske fejl og små snubletråde på vejen, så du ikke kun får den pæne, polerede version.

Hans mål er, at du som begynder eller let øvet hurtigt får følelsen af: “Det her kan jeg faktisk selv finde ud af” – uanset om du vil bygge din første lille hjemmeside, forstå JavaScript-funktioner eller bruge Python til at automatisere en kedelig opgave.

Send kommentar

You May Have Missed