En obevakad webhook-endpoint är en öppen dörr rakt in i din applikationslogik. Under 2025 och 2026 har säkerhetsforskare hittat samma fel om och om igen hos olika leverantörer: en signatur kontrolleras, men kontrollen går att kringgå om hemligheten är tom, algoritmen okänd eller headern formaterad fel. Ett exempel är CVE-2026-41432, där en Stripe-webhook-hanterare accepterade en tom webhook-hemlighet och därmed gjorde det möjligt att räkna ut en giltig HMAC-SHA256-signatur utan att känna till den riktiga nyckeln. Ett annat är CVE-2025-53548 i Clerks verifyWebhook()-funktion, som under en period kunde acceptera felaktigt signerade händelser. Den här guiden visar steg för steg hur du bygger en webhook-mottagare som faktiskt stoppar förfalskade anrop, i både Node.js och Python, med kod du kan använda direkt i ett svenskt eller nordiskt produktionssystem.

Du får en fullständig implementation med HMAC-verifiering, skydd mot timing-attacker, replay-skydd och en felsökningslista byggd på verkliga incidenter. Målgruppen är utvecklare som redan bygger eller tar emot webhooks från tjänster som Stripe, GitHub, Shopify eller interna system, och som vill sluta lita på att “signaturen finns” och i stället verifiera att den faktiskt stämmer.

Vad är en webhook – och varför är den en attackyta

En webhook är i grunden ett HTTP-anrop som en extern tjänst skickar till din server när något händer: en betalning går igenom, ett repo får en ny commit, en order skapas. Till skillnad från ett vanligt API-anrop som din applikation själv initierar är webhooken omvänd. Någon annan ringer upp dig, och du måste lita på vem som ringer utan att ha bett om samtalet i förväg.

Alternativet till webhooks är polling, där din applikation själv frågar leverantörens API med jämna mellanrum om något har hänt. Polling är enklare att skydda eftersom din applikation styr varje anrop och redan är autentiserad mot API:et med sin egen nyckel, men det är ineffektivt och introducerar fördröjning mellan att något händer och att du får veta det. Webhooks löser fördröjningen genom att vända på flödet, men priset är att du måste bygga ett eget skyddslager på mottagarsidan i stället för att luta dig mot det skydd som redan finns i ett utgående API-anrop.

Det är precis den omvändningen som gör webhooks till en attackyta. Endpointen måste vara offentligt nåbar för att leverantören ska kunna nå den, vilket betyder att vem som helst som hittar URL:en kan skicka ett anrop dit. Utan verifiering kan en angripare skicka ett falskt “betalning genomförd”-event och få systemet att kreditera ett konto, eller ett falskt “användare skapad”-event som triggar automatisering den inte borde. GitHub beskriver detta rakt av i sin egen dokumentation: “Validate incoming webhook payloads against the token, to verify that they are coming from GitHub and were not tampered with”. Den enda tillförlitliga metoden för det är en kryptografisk signatur, vanligtvis beräknad med HMAC.

Enligt en rapport om Customer.io:s infrastruktur växte volymen av utgående webhooks mellan två och tre gånger under 2025, och GitHub beskrivs behandla miljarder webhook-händelser i sin plattform. Fler webhooks i produktion betyder fler endpoints som måste skyddas, och fler tillfällen där en genväg i koden blir en sårbarhet som ligger kvar i månader innan någon upptäcker den.

Risken skiljer sig också beroende på vad webhooken faktiskt styr. En webhook som bara uppdaterar en statusflagga i ett internt dashboard är relativt ofarlig att förfalska. En webhook som krediterar ett konto, släpper en betalning eller triggar en utskick till tusentals mottagare är en helt annan sak. Innan du skriver en enda rad verifieringskod är det värt att fråga sig vad värsta tänkbara utfall blir om endpointen tar emot ett helt påhittat event, eftersom svaret avgör hur strikt skyddet behöver vara och hur allvarligt ett eventuellt fel ska behandlas internt.

Förkunskaper: verktyg, språk och versioner

Du behöver inget exotiskt för den här guiden. Allt bygger på standardbibliotek som redan finns installerade, inte tredjepartspaket som kan sluta underhållas.

  • Node.js 20 LTS eller senare (inbyggda modulen node:crypto, ingen extra installation krävs)
  • Express 4.x eller senare om du vill bygga en webbserver runt exemplen (valfritt, exemplen fungerar även med ren http-modul)
  • Python 3.10 eller senare med standardmodulerna hmac och hashlib
  • Flask 3.x eller FastAPI om du vill köra Python-exemplen i en riktig server
  • curl eller Postman för att testa endpoints manuellt
  • Grundläggande förståelse för HTTP-headers och request-body
  • En testmiljö (lokal server eller en tjänst som ngrok) för att ta emot riktiga webhooks från Stripe eller GitHub under utveckling

Ingen av komponenterna kräver betald programvara. HMAC-SHA256 är inbyggt i både Node.js och Python sedan länge, vilket är precis poängen: du behöver inte ett tredjepartsbibliotek för att göra det här rätt, du behöver bara göra det i rätt ordning.

Grunderna: så fungerar HMAC-signaturverifiering

HMAC (Hash-based Message Authentication Code) kombinerar en hemlig nyckel med datan som ska skyddas och kör resultatet genom en hashfunktion, oftast SHA-256. Både avsändaren och mottagaren känner till samma hemliga nyckel. Avsändaren beräknar en signatur över requestens body och skickar med den i en header. Mottagaren gör exakt samma beräkning på sin sida och jämför resultatet. Matchar de, vet mottagaren två saker: att meddelandet kommer från någon som känner till hemligheten, och att innehållet inte ändrats på vägen.

Betaltjänsten Cashfree dokumenterar flödet konkret i sin egen referens: “The verification process is as follows: 1. Extract the x-webhook-timestamp from the headers. 2. Concatenate the timestamp and the raw request body (exact payload, unmodified). 3. Generate an HMAC-SHA256 hash of this string using your client secret. 4. Base64-encode the hash. 5. Compare it with the x-webhook-signature header value”. Det är exakt samma mönster som GitHub, Stripe och Shopify använder i olika varianter, bara med skillnader i kodning (hex kontra base64) och om en tidsstämpel ingår i den signerade strängen eller inte.

Tre saker avgör om implementationen faktiskt skyddar något: att den läser den råa, obearbetade requesten (inte en omtolkad JSON-struktur), att jämförelsen sker i konstant tid så att den inte läcker information via svarstider, och att ett saknat eller tomt hemlighetsvärde alltid leder till avslag. De flesta sårbarheterna under 2025 och 2026 bröt mot just den sista regeln.

Varför SHA-256 och inte något nyare? Svaret är att HMAC:s säkerhet inte hänger på att hashfunktionen är den absolut senaste, utan på att den saknar kända praktiska kollisionsattacker i det här användningsfallet. SHA-1 fasades ut som webhook-standard för flera år sedan efter att dess kollisionsresistens knäcktes i andra sammanhang, men SHA-256 har inga motsvarande problem och är dessutom standardiserat av NIST samt inbyggt i varenda modern språkruntime. Att byta till SHA-512 eller en nyare algoritm ger i praktiken inget extra skydd så länge nyckeln är tillräckligt lång och hemlig. Det som faktiskt flyttar säkerhetsnivån är nyckelns längd (minst 32 slumpmässiga tecken rekommenderas av de flesta leverantörer) och hur väl den hemligheten skyddas i din konfiguration, inte vilken hashalgoritm som används för att kombinera den med datan.

Det är också värt att skilja på HMAC och asymmetrisk signering med publika och privata nycklar, eftersom båda dyker upp i olika leverantörers dokumentation. HMAC kräver att båda parter delar samma hemliga nyckel i förväg, vilket är snabbt att beräkna men betyder att nyckeln måste distribueras säkert till varje mottagare. Asymmetrisk signering, där avsändaren signerar med en privat nyckel och mottagaren verifierar med en publik, slipper det problemet eftersom den publika nyckeln kan delas öppet, men kostar mer beräkningskraft per anrop. De flesta konsument- och betaltjänster, inklusive Stripe, Shopify och GitHub, väljer HMAC för webhooks eftersom volymen är hög och relationen mellan avsändare och mottagare redan är känd och betrodd sedan tidigare.

Steg 1–3: Sätt upp projektet och ta emot rå data

Steg 1. Skapa ett nytt Node.js-projekt och installera Express.

mkdir webhook-receiver && cd webhook-receiver
npm init -y
npm install express

Steg 2. Det vanligaste misstaget i hela flödet sker redan här. Express parsear som standard JSON-body till ett objekt, och när du sedan kör JSON.stringify() för att återskapa en sträng att signera, matchar den nästan aldrig byte för byte med det som avsändaren faktiskt skickade. Mellanslag, nyckelordning och unicode-escaping kan alla ändras i processen. Lösningen är att be Express spara den råa bufferten innan parsningen sker.

const express = require("express");
const app = express();

app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf;
    },
  })
);

Steg 3. Skapa en route som bara loggar vad som kommer in, så du kan se headers och body innan du lägger till verifieringslogik.

app.post("/webhooks/incoming", (req, res) => {
  console.log("Headers:", req.headers);
  console.log("Raw body length:", req.rawBody?.length);
  res.status(200).send("received");
});

app.listen(3000, () => console.log("Lyssnar på port 3000"));

Steg 4–6: Beräkna och verifiera HMAC-signaturen i Node.js

Steg 4. Skriv en funktion som beräknar HMAC-SHA256 över den råa bufferten med din delade hemlighet. Använd den inbyggda node:crypto-modulen, dokumenterad av Node.js-projektet självt.

const crypto = require("node:crypto");

function computeSignature(rawBody, secret) {
  return crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
}

Steg 5. Lägg till en kontroll som avvisar requesten om hemligheten saknas i din konfiguration. Det låter trivialt, men det var exakt den bristen som gjorde CVE-2026-41432 möjlig: en tom hemlighet gjorde signaturen beräkningsbar för vem som helst.

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

if (!WEBHOOK_SECRET || WEBHOOK_SECRET.length < 16) {
  throw new Error("WEBHOOK_SECRET saknas eller är för kort — startar inte");
}

Steg 6. Koppla ihop beräkningen med en route som läser signaturen från rätt header. GitHub skickar sin i X-Hub-Signature-256, Stripe i Stripe-Signature, och interna system brukar använda ett eget namn som X-Webhook-Signature.

app.post("/webhooks/incoming", (req, res) => {
  const received = req.headers["x-webhook-signature"];
  if (!received) {
    return res.status(401).send("Signatur saknas");
  }

  const expected = computeSignature(req.rawBody, WEBHOOK_SECRET);

  // Nästa steg lägger till en säker jämförelse här

  res.status(200).send("received");
});

Steg 7–8: Samma flöde i Python med hmac-modulen

Steg 7. Python har motsvarande funktionalitet inbyggd i standardbiblioteket, dokumenterad av Python-projektets officiella referens. Här byggs samma logik med Flask, och precis som i Node.js måste den råa bodyn läsas innan något försöker tolka den som JSON.

import hashlib
import hmac
import os
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")

if len(WEBHOOK_SECRET) < 16:
    raise RuntimeError("WEBHOOK_SECRET saknas eller är för kort")

def compute_signature(raw_body: bytes, secret: str) -> str:
    return hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

Steg 8. Lägg routen ovanpå funktionen. Flask ger dig den råa bodyn direkt via request.get_data() så länge du anropar den innan du läser request.json.

@app.route("/webhooks/incoming", methods=["POST"])
def receive_webhook():
    raw_body = request.get_data()
    received = request.headers.get("X-Webhook-Signature")

    if not received:
        abort(401, "Signatur saknas")

    expected = compute_signature(raw_body, WEBHOOK_SECRET)

    # Nästa steg lägger till en säker jämförelse här

    return "received", 200

Steg 9–10: Timing-safe jämförelse och fail-closed-design

Steg 9. Att jämföra två strängar med == eller === verkar ofarligt, men den typen av jämförelse avslutas så fort den hittar den första skillnaden. Det gör svarstiden en bråkdel kortare för en felaktig gissning som skiljer sig tidigt i strängen, och en bråkdel längre för en gissning som råkar matcha fler tecken i början. Över tusentals försök kan en angripare mäta den skillnaden och gissa sig fram till signaturen tecken för tecken. Lösningen är en jämförelse som alltid tar exakt lika lång tid oavsett var skillnaden ligger.

function verifySignature(rawBody, receivedHex, secret) {
  if (!secret || !receivedHex) return false;

  const expectedHex = computeSignature(rawBody, secret);
  const receivedBuf = Buffer.from(receivedHex, "hex");
  const expectedBuf = Buffer.from(expectedHex, "hex");

  if (receivedBuf.length !== expectedBuf.length) return false;

  return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}

I Python gör hmac.compare_digest() exakt samma jobb, och Node.js dokumentation rekommenderar crypto.timingSafeEqual av samma anledning.

def verify_signature(raw_body: bytes, received_hex: str, secret: str) -> bool:
    if not secret or not received_hex:
        return False
    expected_hex = compute_signature(raw_body, secret)
    return hmac.compare_digest(expected_hex, received_hex)

Steg 10. Den viktigaste regeln i hela guiden är enkel att skriva men lätt att glömma i en stressad deploy: varje väg genom koden som inte explicit verifierar signaturen ska returnera avslag, aldrig godkännande. Det var precis det här som gick fel i flera av 2026 års sårbarheter, där ett saknat konfigurationsvärde eller en okänd algoritm resulterade i att verifieringen hoppades över i stället för att blockera requesten.

Steg 11–12: Skydda mot replay-attacker och dubbletter

Steg 11. En giltig signatur bevisar bara att meddelandet en gång kom från rätt avsändare, inte att det skickas just nu för första gången. Om en angripare fångar upp ett tidigare, giltigt anrop (till exempel via en läckt logg) kan den skicka om det senare och få samma effekt igen, exempelvis en kreditering som redan borde ha registrerats. Lösningen är att kräva en tidsstämpel i den signerade datan och avvisa allt som är äldre än några minuter.

const FIVE_MINUTES = 5 * 60 * 1000;

function isTimestampFresh(timestampHeader) {
  const sentAt = Number(timestampHeader) * 1000;
  if (!sentAt) return false;
  return Math.abs(Date.now() - sentAt) < FIVE_MINUTES;
}

Steg 12. Tidsstämpeln stoppar gamla repriser men inte en snabb dubblett som skickas inom samma femminutersfönster, till exempel om leverantören själv gör ett omförsök efter timeout. Spara därför händelsens unika id (de flesta leverantörer skickar ett) i en databas eller cache med en kort TTL, och avvisa eller ignorera tyst allt som redan setts.

const seenEventIds = new Set(); // Byt mot Redis i produktion

function isDuplicate(eventId) {
  if (seenEventIds.has(eventId)) return true;
  seenEventIds.add(eventId);
  return false;
}

Steg 13: Så skiljer sig signaturformaten mellan leverantörer

Grundprincipen är densamma överallt, men detaljerna skiljer sig tillräckligt för att en implementation som fungerar mot en leverantör kan misslyckas tyst mot en annan. LINE är tydliga med varför steget inte är valfritt: "This verification step is important to confirm that the webhook came from the LINE Platform and wasn't tampered with during transmission". Tabellen nedan visar hur fem vanliga leverantörer löser samma problem på olika sätt.

LeverantörHeaderAlgoritmKodningTidsstämpel i signatur
GitHubX-Hub-Signature-256HMAC-SHA256Hex, prefix "sha256="Nej
StripeStripe-SignatureHMAC-SHA256Hex, kommasepareradJa, inbakad i headern
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256Base64Nej
LINEX-Line-SignatureHMAC-SHA256Base64Nej
Cashfreex-webhook-signatureHMAC-SHA256Base64Ja, separat header

Stripe och Cashfree bakar in en tidsstämpel i det som faktiskt signeras, vilket ger inbyggt replay-skydd utan extra logik. GitHub och Shopify gör det inte, vilket betyder att skyddet i steg 11 blir obligatoriskt om du tar emot webhooks från dem. Stripes egen dokumentation och Shopifys utvecklardokumentation beskriver respektive format i detalj, och det är alltid värt att läsa originalkällan innan du skriver produktionskod, eftersom format kan uppdateras.

Ett vanligt nybörjarfel är att skriva en generisk verifieringsfunktion och anta att den fungerar mot alla leverantörer utan ändring. Stripes Stripe-Signature-header innehåller flera värden separerade med kommatecken, bland annat tidsstämpeln och en eller flera signaturer, vilket kräver att du parsar ut rätt del innan jämförelsen. Shopifys header innehåller bara den rena base64-signaturen utan prefix. Skriver du en funktion som förväntar sig Stripes format och kör den mot en Shopify-webhook kommer varje enskilt anrop att avvisas, inte för att något är fel i säkerheten, utan för att parsningen letar efter ett kommatecken som aldrig finns. Bygg därför en liten adapter per leverantör som extraherar rätt värde ur headern, och låt själva HMAC-jämförelsen vara gemensam kod.

Verkliga sårbarheter 2025–2026: vad gick fel

Teorin blir konkret när man tittar på vad som faktiskt gick sönder hos riktiga produkter de senaste arton månaderna. Mönstret är slående: nästan ingen av sårbarheterna handlar om att HMAC-algoritmen i sig är trasig. De handlar om kod runt omkring algoritmen som tillåter ett undantag.

CVEKomponentGrundorsak
CVE-2026-41432Stripe-webhook-hanterareAccepterade tom webhook-hemlighet, vilket gjorde signaturen beräkningsbar utan rätt nyckel
CVE-2025-53548Clerk, verifyWebhook()Kunde acceptera felaktigt signerade händelser i drabbade versioner före 1.7.4
CVE-2026-85621LobeChat 2.2.1QQ-adaptern saknade verifiering helt, Feishu-adaptern kunde hoppa över den utan konfigurerad token
CVE-2026-93511WordPress-plugin Premium PackagesObehörig förbikoppling av PayPal-webhook-signatur i versioner före 7.2.1
CVE-2026-73314XenForo, PayPal REST-hanterareVerifiering föll igenom ("fail open") när ett okänt värde skickades i fältet auth_algo
CVE-2026-56360n8n, webhook-integrationVerifierade inte HMAC-SHA256-signaturen i versioner före 1.123.18 och 2.6.2

Tre av de sex fallen (LobeChat, XenForo och delar av Clerk-fallet) är varianter av samma princip som i steg 10: koden föll tillbaka till "tillåt" när den stötte på ett konfigurationsläge den inte hade räknat med. Ingen av dessa produkter saknade HMAC-kunskap i sina team. De saknade en regel om att okänt alltid ska tolkas som ogiltigt, aldrig som giltigt.

Ett annat gemensamt drag är hur länge den här typen av brister hinner ligga obemärkta. En felaktig signaturkontroll syns inte i vanlig funktionstestning, eftersom "lyckade" anrop fortfarande fungerar precis som de ska. Det är bara avvisningen som saknas, och ingen testar rutinmässigt att fel input faktiskt stoppas om inget i releaseprocessen kräver det. Det är anledningen till att negativa testfall, som i checklistan senare i den här guiden, är minst lika viktiga som de positiva testerna. Ett projekt kan ha hundra procent testtäckning på lyckade flöden och fortfarande vara helt öppet för förfalskade anrop.

Varför detta är extra aktuellt för svenska och nordiska bolag

För företag som omfattas av NIS2-regelverket räknas en förfalskad webhook som kan trigga felaktiga betalningar eller kontoändringar som en potentiell säkerhetsincident, inte bara en teknisk bugg. Det ställer webhook-säkerhet i samma kategori som andra kontroller som redan granskas i en NIS2-revision: åtkomsthantering, loggning och förmåga att upptäcka missbruk i tid. En obevakad eller svagt verifierad webhook-endpoint är precis den typ av enkel, lågt hängande sårbarhet som brukar lyftas fram i en sårbarhetsanalys, eftersom den är billig att åtgärda men kostsam att missa.

Det finns också en praktisk sida av saken för nordiska team. Många svenska och nordiska bolag integrerar mot internationella plattformar som Stripe, Shopify och GitHub, samtidigt som de bygger egna interna webhooks mellan mikrotjänster i molnmiljöer hos till exempel AWS eller Azure i europeiska regioner. Varje sådan integration är en ny endpoint att granska, och mönstret i CVE-tabellen ovan visar att det sällan är den första integrationen som brister. Det är den femte eller tionde, byggd av ett annat team, under tidspress, som återanvänder gammal kod utan att tänka igenom fail-closed-principen på nytt.

Testa implementationen: kommandon och exempel-output

Innan du kopplar in en riktig leverantör, testa din endpoint lokalt med en signatur du räknar ut för hand. Det bekräftar att hela kedjan, från rå body till jämförelse, fungerar innan okända variabler som nätverk och leverantörsformat kommer in i bilden.

# Räkna ut en giltig signatur för en testpayload
BODY='{"event":"payment.success","amount":500}'
SECRET="test-hemlighet-32-tecken-lang"
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/incoming \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: $SIGNATURE" \
  -d "$BODY"

Ett korrekt svar ser ut så här:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

received

Ändra sedan ett enda tecken i $BODY utan att räkna om signaturen, och kör samma kommando igen. Servern ska svara med 401 och texten "Ogiltig signatur". Om den i stället svarar 200 har du ett allvarligt fel i jämförelselogiken, troligen att du verifierar mot en nytolkad body i stället för den råa bufferten från steg 2.

HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8

Ogiltig signatur

Vanliga fallgropar att undvika

De flesta implementationsfel upprepas från projekt till projekt, oavsett om koden skrivs i Node.js, Python eller något annat språk. Det är sällan HMAC-matematiken som är fel, det är allt runt omkring den. Här är de fem som dyker upp oftast i kodgranskningar av webhook-mottagare.

  • Verifiering mot omtolkad JSON i stället för rå body. Så fort ett ramverk har parsat och sedan serialiserat om datan matchar den sällan byte för byte med originalet, vilket gör att alla signaturer failar, eller värre, att utvecklaren tar bort kontrollen för att "få det att fungera".
  • Strängjämförelse i stället för konstant-tid-jämförelse. Fungerar korrekt i alla tester, men öppnar för timing-attacker i produktion.
  • Tyst fallback när hemligheten saknas i miljövariablerna. En kod som skriver secret || "fallback" gör hela skyddet förutsägbart för vem som helst som läser källkoden eller en gammal commit.
  • Inget skydd mot repriser. En signatur som en gång var giltig förblir matematiskt giltig för alltid om den fångas upp, om inte en tidsstämpel eller ett event-id stoppar återanvändning.
  • Loggning av den råa hemligheten eller fullständiga signaturer i klartext. Loggar hamnar ofta i tredjepartsverktyg med bredare åtkomst än produktionsservern, och en läckt hemlighet gör hela verifieringen meningslös.

Felsökning: vanliga problem och lösningar

När en implementation inte beter sig som väntat är det nästan alltid ett av följande åtta problem. Gå igenom listan i ordning innan du börjar lägga till loggning eller felsöka på måfå, eftersom de första raderna täcker de absolut vanligaste orsakerna.

SymptomTrolig orsakÅtgärd
Signaturen matchar aldrig, inte ens med korrekt hemlighetBody har redan parsats till JSON innan den rå bufferten sparadesFlytta verify-callbacken till parsningssteget, före all annan middleware
Signaturen matchar lokalt men inte i produktionProxy eller load balancer ändrar teckenkodning eller radbrytningar i bodynKontrollera att proxyn vidarebefordrar bodyn oförändrad, slå av eventuell automatisk komprimering
Verifieringen fungerar men bara iblandRace condition där hemligheten roteras medan gamla requester fortfarande är i köTillåt två giltiga hemligheter samtidigt under en kort övergångsperiod vid rotation
Node.js kastar fel vid timingSafeEqualDe två buffertarna har olika längd, vilket funktionen inte tillåterKontrollera längden separat innan anropet, returnera false direkt vid mismatch
Python-funktionen returnerar alltid FalseHemligheten skickas som sträng till en funktion som förväntar sig bytes, eller tvärtomAnvänd konsekvent .encode("utf-8") på strängar innan de går in i hmac.new()
Leverantörens testwebhook godkänns men riktiga events avvisasTestverktyget skickar en förenklad payload som inte matchar produktionens innehållTesta alltid mot leverantörens egna exempel-payloads, inte en egen förenklad variant
Dubbletter av samma event behandlas två gångerInget dedupliceringslager på event-id, leverantören gör ett ofarligt omförsökLägg in kontrollen från steg 12 med en cache som har en TTL på minst 24 timmar
Endpointen svarar för långsamt och leverantören markerar den som trasigTung bearbetning sker synkront innan svaret skickasSvara 200 direkt efter verifiering, lägg bearbetningen i en kö eller bakgrundsjobb

Avancerade tips för produktion

När grundskyddet är på plats finns det några steg till som skiljer en hobbyimplementation från en som klarar en säkerhetsgranskning. Rotera webhook-hemligheten regelbundet, till exempel var sjätte månad, och bygg in stöd för att verifiera mot två giltiga hemligheter samtidigt under övergången, så att inga events tappas medan den gamla fasas ut. Lägg endpointen bakom samma typ av hastighetsbegränsning som andra publika API:er, eftersom en obegränsad endpoint kan missbrukas för att testa gissade signaturer även om varje enskilt försök avvisas. Komplettera gärna med mTLS mellan din server och leverantörer som stödjer det, vilket lägger till ett transportlager-skydd ovanpå applikationslagrets HMAC-kontroll. Separata signeringshemligheter per leverantör och per miljö (utveckling, test, produktion) gör också att en läcka i en testmiljö aldrig kan användas mot produktion. Slutligen, logga varje avvisad signatur med tidsstämpel och avsändar-IP, utan att logga själva hemligheten eller signaturen i klartext, så att ett mönster av upprepade avslag syns i övervakningen innan det blir ett incidentläge.

Förvara hemligheten i en dedikerad secrets manager snarare än direkt i en miljövariabelfil som checkas in i ett repo. Molnleverantörer erbjuder redan byggstenarna för detta, och de flesta team behöver bara koppla ihop dem med sin deploy-process i stället för att bygga något eget. Oavsett vilken lösning du väljer gäller samma regel som för själva HMAC-verifieringen: om hämtningen av hemligheten misslyckas ska tjänsten vägra starta, inte falla tillbaka på ett tomt eller hårdkodat värde. Sätt också upp ett larm som triggar om antalet avvisade signaturer för en endpoint plötsligt stiger kraftigt under en kort tidsperiod, eftersom det ofta är det tidigaste tecknet på att någon aktivt testar sig förbi skyddet innan ett eventuellt dataintrång sker på allvar.

Komplett exempelprojekt: hela koden samlad

Här är allt från stegen ovan sammansatt i en körbar Express-applikation. Spara filen som server.js, sätt miljövariabeln WEBHOOK_SECRET och kör med node server.js.

const express = require("express");
const crypto = require("node:crypto");

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const FIVE_MINUTES = 5 * 60 * 1000;
const seenEventIds = new Set();

if (!WEBHOOK_SECRET || WEBHOOK_SECRET.length < 16) {
  throw new Error("WEBHOOK_SECRET saknas eller är för kort — startar inte");
}

app.use(
  express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf;
    },
  })
);

function computeSignature(rawBody, secret) {
  return crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
}

function verifySignature(rawBody, receivedHex, secret) {
  if (!secret || !receivedHex) return false;
  const expectedHex = computeSignature(rawBody, secret);
  const receivedBuf = Buffer.from(receivedHex, "hex");
  const expectedBuf = Buffer.from(expectedHex, "hex");
  if (receivedBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(receivedBuf, expectedBuf);
}

function isTimestampFresh(timestampHeader) {
  const sentAt = Number(timestampHeader) * 1000;
  if (!sentAt) return false;
  return Math.abs(Date.now() - sentAt) < FIVE_MINUTES;
}

function isDuplicate(eventId) {
  if (seenEventIds.has(eventId)) return true;
  seenEventIds.add(eventId);
  return false;
}

app.post("/webhooks/incoming", (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  const timestamp = req.headers["x-webhook-timestamp"];
  const eventId = req.headers["x-webhook-event-id"];

  if (!signature || !verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Ogiltig signatur");
  }
  if (!isTimestampFresh(timestamp)) {
    return res.status(401).send("Föråldrad eller saknad tidsstämpel");
  }
  if (!eventId || isDuplicate(eventId)) {
    return res.status(200).send("Redan behandlad");
  }

  // Svara snabbt, lägg tung bearbetning i en kö
  res.status(200).send("received");
});

app.listen(3000, () => console.log("Webhook-mottagare igång på port 3000"));

Den här strukturen, en signaturkontroll, en färskhetskontroll och en dubblettkontroll i den ordningen, täcker de tre vanligaste angreppsvägarna: förfalskning, repris och dubbel bearbetning. OWASP:s Cheat Sheet Series och OWASP API Security-projektet är bra referenser att gå vidare till om du vill bygga ut skyddet ytterligare, till exempel med strikt indatavalidering av själva payloaden efter att signaturen godkänts.

Lägg till ett enkelt testfall som en del av projektet innan du kallar det klart. Testet nedan använder Node.js inbyggda testrunner (tillgänglig sedan Node.js 18) och kräver inget extra testramverk.

Skriv ett automatiskt test

const test = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");

function computeSignature(rawBody, secret) {
  return crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
}

test("giltig signatur accepteras", () => {
  const body = Buffer.from('{"event":"test"}');
  const secret = "hemlighet-för-test-32-tecken";
  const sig = computeSignature(body, secret);
  assert.strictEqual(sig, computeSignature(body, secret));
});

test("ändrad body ger annan signatur", () => {
  const secret = "hemlighet-för-test-32-tecken";
  const sigA = computeSignature(Buffer.from('{"event":"a"}'), secret);
  const sigB = computeSignature(Buffer.from('{"event":"b"}'), secret);
  assert.notStrictEqual(sigA, sigB);
});

Kör testerna med node --test. Lägg sedan projektet i en container så att miljön blir identisk i utveckling, test och produktion.

Paketera med Docker

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]

Bygg och kör containern med din hemlighet injicerad som miljövariabel, aldrig inbakad i imagen.

docker build -t webhook-receiver .
docker run -p 3000:3000 -e WEBHOOK_SECRET="$(openssl rand -hex 32)" webhook-receiver

openssl rand -hex 32 genererar en kryptografiskt slumpmässig 64-tecken lång hemlighet varje gång du kör den, vilket är ett enkelt sätt att säkerställa att hemligheten aldrig är en gissningsbar sträng som "mitt-webhook-lösenord".

Checklista: redo för produktion

Gå igenom listan nedan innan endpointen får ta emot riktiga events. Varje rad motsvarar ett steg längre upp i guiden, så en bock i varje ruta betyder att den specifika attackvägen faktiskt är stängd, inte bara teoretiskt hanterad.

KontrollVarför den behövs
Rå body sparas före JSON-parsningAnnars matchar signaturen aldrig, eller kontrollen tas bort i frustration
Hemlighet minst 32 tecken, hämtad från miljövariabel eller secrets managerKort eller hårdkodad hemlighet går att gissa eller läcker via källkoden
Saknad eller tom hemlighet stoppar serverstartFörhindrar att tjänsten av misstag går live utan skydd, som i CVE-2026-41432
Jämförelse sker med timingSafeEqual eller compare_digestStoppar timing-attacker mot jämförelselogiken
Tidsstämpel valideras mot ett kort fönster (cirka fem minuter)Stoppar repriser av gamla, tidigare giltiga anrop
Event-id dedupliceras mot en cache med TTLStoppar dubbel bearbetning av omförsök från leverantören
Avvisade signaturer loggas utan att logga hemligheten självGer tidig varning om någon aktivt försöker gissa sig förbi skyddet
Negativa testfall (fel signatur, ändrad body, saknad header) finns i testsvitenSäkerställer att skyddet faktiskt avvisar, inte bara godkänner korrekt trafik

Vanliga frågor

Räcker HMAC-SHA256, eller behöver jag ett starkare hashalgoritm?
SHA-256 är fortfarande branschstandard för webhook-signaturer hos GitHub, Stripe och Shopify under 2026. Styrkan i HMAC kommer från nyckellängden och hemlighetens sekretess, inte bara hashfunktionen, så en lång, slumpmässig hemlighet väger tyngre än att byta till SHA-512.

Kan jag använda samma hemlighet för flera leverantörer?
Det går tekniskt, men det är en dålig idé. Om en leverantörs hemlighet läcker påverkar det då alla integrationer samtidigt i stället för bara en. Separata hemligheter per leverantör begränsar skadan om något läcker.

Behöver jag HTTPS om jag redan har HMAC-verifiering?
Ja. HMAC bevisar vem som skickade meddelandet och att det inte ändrats, men skyddar inte mot avlyssning i transit. HTTPS krypterar hela anropet, inklusive hemligheten i vissa flöden, och ska alltid användas tillsammans med signaturverifiering, aldrig i stället för den.

Vad gör jag om leverantören inte skickar någon tidsstämpel alls?
Lägg till din egen mottagningstidsstämpel och kombinera den med ett dedupliceringslager på event-id, som i steg 12. Det ger inte lika starkt replay-skydd som en leverantörssignerad tidsstämpel, men stoppar återanvändning av fångade requester över tid.

Hur testar jag att min endpoint faktiskt avvisar falska signaturer, inte bara godkänner riktiga?
Bygg negativa testfall i din testsvit: skicka en korrekt body med fel signatur, en korrekt signatur med ändrad body, en helt saknad signatur-header och en föråldrad tidsstämpel. Alla fyra ska ge 401. Om något av dem ger 200 har verifieringen ett hål.

Påverkar det här GDPR-hanteringen av webhook-data?
Indirekt, ja. Loggar som innehåller fullständiga payloads kan innehålla personuppgifter, och eftersom en obevakad endpoint kan ta emot förfalskad data är signaturverifiering ett sätt att säkerställa att det som lagras faktiskt kommer från en betrodd källa, vilket är relevant för kraven på dataintegritet i GDPR artikel 5.

Är det värt att använda en tredjepartstjänst som hanterar webhook-mottagning åt mig?
Det kan förenkla drift och ge inbyggd retry-hantering, men du behöver fortfarande själv verifiera signaturen innan datan går vidare till din applikationslogik. Tjänsten tar bort infrastrukturarbetet, inte ansvaret för verifieringen.

Vad händer om jag behöver rotera webhook-hemligheten utan driftstopp?
Konfigurera verifieringsfunktionen att acceptera två giltiga hemligheter samtidigt under en övergångsperiod: den gamla och den nya. Uppdatera din egen tjänst med båda, byt till den nya hos leverantören, och ta bort den gamla hemligheten från din konfiguration först när du är säker på att inga anrop längre signeras med den. En hastig rotation utan överlappning riskerar att events avvisas mitt i bytet.