Skip to content
Terug naar blog
Beveiliging

Webhook signature-verificatie mislukt: oorzaken en oplossingen

Webhook signature-verificatie mislukt? Meestal is het de raw body, de digest-codering of een ontbrekend timestamp-prefix. Debug het met onze gratis HMAC-tool.

15 min leestijd

Webhook signature-verificatie mislukt? Zo vind je de oorzaak

Een foutmelding dat de webhook signature-verificatie is mislukt, betekent precies één ding: de digest die jouw code berekende is niet gelijk aan de digest in de request-header. Dat is de hele boodschap. Er staat niets in over rechten, niets over geldigheidsduur, en het is bijna nooit een bug in de SDK van de provider. Er zit verschil tussen de bytes waarover de provider de hash berekende en de bytes waarover jij dat deed.

Vier inputs bepalen de uitkomst: welke bytes zijn ondertekend, welke sleutelbytes zijn gebruikt, welk hash-algoritme heeft gedraaid, en in welke codering je hebt vergeleken. Zit er één fout, dan ziet de mislukking er precies hetzelfde uit. De melding geeft geen enkele hint over welke van de vier het was, dus je werk is het inputbereik versmallen, niet de melding nóg eens nauwkeuriger lezen.

Kies een startpunt:

Signature komt niet overeen? Drie takken:
├─ Heeft je framework de JSON ingelezen voordat jij hem zag?      → sectie 3
├─ Draagt de headerwaarde een prefix, of ziet die uit als base64?  → sectie 4
└─ Zit er in de header van de provider een timestamp?              → sectie 2

Elke sectie hieronder eindigt met iets dat je op je eigen payload kunt uitvoeren.

1. Wat een signature-mismatch je eigenlijk vertelt

Verificatie is een vergelijking van twee byte-strings. Mislukt die, dan zit de fout in de input, en er zijn vier plekken die je los van elkaar kunt uitsluiten.

Welke bytes zijn ondertekend. De provider berekende een HMAC over een specifieke reeks bytes. Misschien is dat alleen de request-body, misschien een timestamp die vooraan de body geplakt zit. Heeft je framework de JSON al ingelezen en geeft het je een object, dan heb je die bytes niet meer en kun je ze niet betrouwbaar reconstrueren. Dat is sectie 3, en het is met afstand de meest voorkomende oorzaak.

Welke sleutelbytes zijn gebruikt. Dezelfde secret-string kun je lezen als UTF-8-tekst, als hex of als base64, en elke lezing levert een andere sleutel op. Hetzelfde geldt voor een secret met een extra regeleinde dat je configloader heeft laten staan. In deze dimensie zit nog een tweede storing verborgen: het secret is misschien niet een verkeerde lezing van het juiste secret, maar een heel ander secret. Dat is sectie 6.

In welke codering je hebt vergeleken. Een digest is bij SHA-256 32 ruwe bytes. Hex en base64 zijn twee manieren om diezelfde bytes als tekst op te schrijven, en ze lijken nooit op elkaar. Vergelijk je de een met de ander, dan krijg je een permanente hmac signature mismatch terwijl de onderliggende bytes wél overeenkomen.

Welk hash-algoritme heeft gedraaid. De meeste providers gebruiken SHA-256 en zetten dat ook in hun documentatie, dus deze dimensie kost je meestal niets. GitHub is de uitzondering die je moet kennen: elke aflevering draagt X-Hub-Signature (HMAC-SHA1) naast X-Hub-Signature-256 (HMAC-SHA256), en de documentatie van GitHub zegt dat de SHA-1-header er alleen nog om historische redenen in zit, en raadt de 256-variant aan. Lees je de verkeerde, dan verraadt de lengte het al voordat de bytes dat doen. De body uit sectie 2, met hetzelfde secret onder SHA-1 ondertekend, is sha1=ba2954d180839d8170b08b32cd38483775aaae96: 40 hex-tekens tegen de 64 van de SHA-256-digest.

Houd die vier gescheiden terwijl je debugt. De snelste manier om één dimensie te isoleren is de digest buiten je applicatie berekenen, met input die jij zelf bepaalt: plak een body en een secret in de HMAC-generator en kijk wat eruit komt. De generator draait volledig in je browser en het secret verlaat de pagina niet, dus je kunt er zonder risico ook een signing secret uit productie in plakken. HMAC gebruikt dezelfde SHA-256-primitive als een gewone SHA-256-hash, alleen met jouw secret als sleutel. Lukt het je om de waarde van de provider met de hand te reproduceren, dan is er niets mis met de cryptografie en zit de bug in je requestverwerking.

2. Wat de vier grote providers werkelijk ondertekenen

De aanname waar de meeste integraties op stuklopen: elke provider ondertekent de request-body en niets anders. Twee van de vier grootste doen dat niet. Dit is waar elk van hen daadwerkelijk een HMAC over berekent, zoals hun eigen documentatie het beschrijft:

ProviderHeaderOndertekende stringCoderingPrefix in de waardeSecretTimestamp-tolerantie
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…signing secret van het endpoint (prefix whsec_)5 minuten (300 seconden)
GitHubX-Hub-Signature-256rawBody (geen prefix)hexsha256=webhook secret tokengeen (er wordt geen timestamp verstuurd)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=signing secret5 minuten
ShopifyX-Shopify-Hmac-SHA256rawBodybase64geenclient secret van de app (niet een apart webhook secret)geen

Die vier dekken toevallig drie onafhankelijke assen af. De ondertekende string is óf alleen de body, óf een timestamp met de body erachter. Zelfs het scheidingsteken verschilt: Stripe gebruikt ., Slack :. De codering is hex bij drie en base64 bij één. Het secret komt bij drie uit een aparte webhook-credential en bij Shopify uit het client secret van de app. Dat laatste gaat het vaakst mis, omdat er in de beheerinterface een veld met het label “webhook” staat dat níet is wat je nodig hebt. Deze vier zijn trouwens geen volledige lijst: Adyen, zelf een Amsterdams bedrijf en in Nederland veel gebruikt, ondertekent zijn webhooks ook met HMAC. Zoek voor je eigen provider op welke string er precies wordt ondertekend voordat je code schrijft.

Om het verschil concreet te maken: één body, één secret, vier manieren van ondertekenen.

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
VormWaarde
GitHub-stijlsha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Shopify-stijlCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Stripe-stijlt=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Slack-stijlv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Lees de eerste twee rijen samen, want het is dezelfde digest van 32 bytes, twee keer opgeschreven. Vierenzestig hex-tekens, of vierenveertig base64-tekens inclusief padding. Niets aan de twee strings suggereert dat ze gelijk zijn. Vergelijk je over twee coderingen heen, dan krijg je een mismatch die elke “maar het secret is toch goed”-controle overleeft.

De laatste twee rijen laten de andere helft zien. Zelfde body, zelfde secret, zelfde algoritme, en geen van beide digests lijkt op die van GitHub, want de string waarover de HMAC loopt begint nu met een timestamp. De meeste meldingen van een stripe webhook signature verification failed komen neer op deze rij: de code berekende de HMAC over de body alleen en zette de t-waarde met de punt er nooit voor. Reproduceer alle vier in de HMAC-generator door alleen het berichtveld aan te passen en het uitvoerformaat om te zetten.

Eén praktisch gevolg van de timestamp-kolom: een digest van Stripe of Slack is maar een paar minuten geldig, dus je kunt niet vandaag een signature vastleggen en die morgen in een test opnieuw afspelen. Signatures van GitHub en Shopify blijven onbeperkt geldig. Dat maakt ze veel makkelijker te debuggen, en het betekent dat je zelf over replay-bescherming moet nadenken.

3. Het probleem met de raw body

De meeste meldingen van webhook signature verification failed komen bij deze sectie uit, en de oorzaak zit dan niet in je HMAC-code maar in wat er vóór je handler met de bytes gebeurt.

Je framework heeft de bytes al vernield

Webframeworks zijn gebouwd om je het inlezen uit handen te nemen. Precies dat gemak breekt de signature-verificatie, want op het moment dat je handler draait, zijn de originele bytes weg.

express.json() leest de request-stream, verwerkt die en vervangt req.body door een JavaScript-object. De stream is opgebruikt; een tweede lezing levert niets meer op. In FastAPI betekent een Pydantic-model of een dict-bodyparameter dat het framework al leest en verwerkt voordat je functie begint. Rails vult params uit de JSON-body via een middleware die vóór je controller-action draait. De Jackson-converter van Spring zet de body om naar je DTO-klasse, en standaard kun je de onderliggende HttpServletRequest-inputstream maar één keer lezen.

Niets hiervan is een bug. Ze doen allemaal precies waarvoor ze bedoeld zijn. Het probleem is dat een signature over bytes gaat, dat een object geen bytes is, en dat het object terugzetten naar bytes een andere bewerking is dan die de provider uitvoerde.

Opnieuw serialiseren werkt soms, en dat is de valkuil

Het standaardadvies luidt dat opnieuw serialiseren de bytes verandert. Dat is maar de helft van het verhaal, en juist de ontbrekende helft maakt deze mislukking zo moeilijk te diagnosticeren. Soms verandert er namelijk helemaal niets.

Dit is JSON.stringify(JSON.parse(body)) === body, gemeten over verschillende soorten payloads:

Vorm van de payloadBytes na de round-tripVerandering
{"id":42,"event":"user.created"}identiekgeen, en daarom slagen je lokale tests
{"amount":1.0}veranderd{"amount":1}
{"n":1e3}veranderd{"n":1000}
{"id":12345678901234567890}veranderd{"id":12345678901234567000} (precisie verloren)
{"name":"caf\u00e9"}veranderd{"name":"café"} (6 bytes worden 2)
{"a":1}\nveranderdafsluitend regeleinde ingeslikt
{ "a" : 1 }veranderdwitruimte binnenin ingeslikt
{"v":-0.0}veranderd{"v":0}
{"p":0.1000000000000000055511151231257827}veranderd{"p":0.1}

Kijk naar de eerste rij. Een plat object met een integer en een korte ASCII-string komt byte voor byte hetzelfde terug, dus een verifier die eerst inleest en daarna opnieuw serialiseert, slaagt in elke test die je tegen zo’n fixture hebt geschreven. Dan zet je het live, en de eerste payload met een geldbedrag van 1.0, een ID boven 2^53 of een klantnaam met een accent mislukt. Niet allemaal. Alleen die.

Dat is het mechanisme achter “lokaal werkt het, in productie af en toe een 401”, en dat is aanzienlijk erger dan een verifier die het altijd fout doet. Een verifier die altijd faalt, is binnen een uur gerepareerd. Faalt hij op 3% van de events, dan krijgt de provider de schuld en sleept het weken door: opnieuw proberen, escaleren, ermee leven. Ligt je faalpercentage strikt tussen nul en honderd procent, begin dan bij deze tabel.

De volgorde van de keys is de oorzaak die iedereen verwacht en in de praktijk de minst waarschijnlijke, want JSON.parse behoudt de invoegvolgorde voor string-keys. Getallen en witruimte zijn de echte daders.

De raw body ophalen per framework

Express, met de route-specifieke parser geregistreerd vóór de globale JSON-parser:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Deze route moet geregistreerd worden VÓÓR app.use(express.json()).
// body-parser markeert de request als verwerkt, dus een latere raw() geeft stil {} terug.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // een Buffer, geen object
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // bereken de HMAC direct over de Buffer, geen toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // alle andere routes krijgen nog steeds verwerkte JSON
app.listen(3000);

Kun je de middleware niet herordenen, bewaar dan tijdens het inlezen een kopie:

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

FastAPI. Starlette cachet de body, dus await request.body() geeft de originele bytes terug, zelfs in een handler die óók een ingelezen model binnenkrijgt:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # bytes, exact zoals ontvangen
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, waar request.raw_post je de niet-verwerkte body als string geeft:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, waar je de body zelf leest en moet onthouden dat die daarna leeg is:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Unmarshal vanuit raw, nooit vanuit r.Body — daar zitten geen bytes meer in.
	w.WriteHeader(http.StatusOK)
}

Spring, waar je met byte[] Jackson volledig overslaat:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper is het alternatief wanneer een filter de controle moet doen en je de signatuur van de controller niet kunt wijzigen. Die heeft zijn eigen valkuil: getContentAsByteArray() geeft pas bytes terug nadat iets verderop de stream heeft gelezen, dus aanroepen vóór chain.doFilter(...) levert je een lege array op.

4. Coderingen die niet overeenkomen: hex, base64 en de sleutel zelf

Tussen jouw digest en de waarde in de header zitten drie losse beslissingen over codering, en elke daarvan kan de vergelijking op zichzelf al breken.

De codering van de digest. De uitvoer van HMAC-SHA256 is 32 bytes. Als kleine letters in hex zijn dat 64 tekens; als standaard base64 zijn het 44 tekens, inclusief de = als padding. Dezelfde twee rijen uit sectie 2, naast elkaar gezet:

CoderingTekensDezelfde 32 bytes, opgeschreven als
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Een snelle vuistregel als je naar een onbekende header staart: bestaat de waarde uit 64 tekens uit 0-9a-f, dan is het hex. Is het 44 tekens die op = eindigen, of zitten er +, / of hoofdletters in, dan is het base64. Wil je het zeker weten in plaats van gokken, haal de base64-waarde dan door de Base64-decoder en controleer of er 32 bytes uitkomen. Is dat zo, dan beschrijven beide strings dezelfde digest en vergeleek je tekstformaten, geen signatures.

De prefix in de waarde. GitHub zet sha256= vóór de hex. Slack stuurt v0=. Stripe verpakt alles in een door komma’s gescheiden lijst van key=value-paren. Geen van die tekens hoort bij de digest, dus haal óf de prefix van de header af, óf zet hem voor je eigen waarde. Geen van beide doen is een van de vaakst voorkomende redenen dat een verder correcte implementatie een hmac signature mismatch meldt. In Node meldt het zelfs geen mismatch, zoals sectie 7 uitlegt.

De codering van de sleutel. Het secret is óók bytes, en dezelfde string gelezen als UTF-8, hex of base64 geeft drie verschillende sleutels. Providers die je een teksttoken als whsec_... geven, willen UTF-8, maar genoeg interne systemen verspreiden secrets in base64 of hex die je moet decoderen voordat je ondertekent. Dit faalt op precies dezelfde manier als de JWT-variant van het probleem, en dat staat uitgebreid beschreven in JWT invalid signature: alle oorzaken en hoe je ze oplost, inclusief hoe je vaststelt of een secret base64 of platte tekst is.

5. Timestamp, tolerantie en replay-vensters

Je kunt een digest berekenen die perfect klopt en tóch worden afgewezen. Providers die een timestamp meesturen verwachten dat je die controleert, en een verouderde timestamp is een geldige signature die je alsnog moet weigeren.

ProviderWaar de timestamp staatVenster
Stripet= binnen Stripe-Signature5 minuten (300 seconden)
Slackde header X-Slack-Request-Timestamp5 minuten
GitHubwordt niet verstuurdniet van toepassing
Shopifywordt niet verstuurdniet van toepassing

Het venster kan in twee richtingen fout staan, en beide doen pijn. Te ruim, en een opgevangen request blijft net zo lang herhaalbaar als jij toestaat, waardoor het controleren van de timestamp grotendeels zinloos wordt. Te krap, en gewone klokafwijking begint echte afleveringen te weigeren. Vijf minuten is wat beide providers hebben gekozen, en dat overnemen is een verstandige standaard.

Voordat je een tolerantie oprekt: controleer de klok. Container images draaien geen NTP, en een VM die uit een snapshot wordt hervat kan minuten achterlopen op de werkelijke tijd zonder dat er iets in de logs staat. Een host die gestaag wegloopt, veroorzaakt fouten die beginnen als incidenteel en eindigen als totaal. Dat leest als een regressie in de code, maar dat is het niet.

De andere klokbug is een verkeerde eenheid. Elke provider in de tabel stuurt epoch-seconden. Vergelijk zo’n waarde met een millisecondewaarde als Date.now() in JavaScript en het verschil is ongeveer duizend keer de echte leeftijd, waardoor elk event buiten elk denkbaar venster valt. Het symptoom is een tolerantiecontrole die honderd procent van de afleveringen weigert terwijl de digest zelf wél klopt. Weet je niet zeker welke eenheid je in handen hebt, dan verraadt de lengte het; epoch-seconden versus milliseconden behandelt de omrekening en de valkuilen rond tijdzones.

Gebruik de ruwe timestamp-string uit de header als je de ondertekende string opbouwt, niet een ingelezen en opnieuw geformatteerd getal. 1700000000 inlezen als float en weer uitschrijven kan 1700000000.0 opleveren, en dat is een andere reeks bytes.

6. Verkeerd secret, en secrets die roteren

Voordat je verder de coderingen in duikt, sluit eerst de simpelste oorzaak uit: misschien is het secret niet het juiste secret. De documentatie van Stripe zegt het onomwonden, namelijk dat Stripe voor elk endpoint een eigen secret key aanmaakt, en dat als je dezelfde URL zowel op test- als op live-sleutels richt, het secret voor elk daarvan anders is. Daaruit volgen drie varianten van dezelfde fout.

Testmodus en livemodus hebben elk hun eigen secret, dus een waarde die je kopieerde terwijl het dashboard in testmodus stond, mislukt bij elke live-aflevering. Daarnaast heeft elk endpoint er zelf een, en de documentatie voegt toe dat je bij meerdere endpoints voor elk endpoint waarvan je de signatures wilt verifiëren een eigen secret moet ophalen: richt twee endpoints op één handler met één secret in de omgeving en de helft van je verkeer mislukt. En stripe listen drukt een signing secret af voor het lokaal doorsturen door de CLI. Dat is een ander endpoint dan wat er in het dashboard staat, dus de twee zijn niet uitwisselbaar.

Van buitenaf ziet geen van deze drie eruit als een coderingsfout. De digest is goed gevormd, de vergelijking is correct en de waarde in je omgeving is een echt Stripe-secret. Alleen niet het secret dat deze aflevering heeft ondertekend.

Rotatie is diezelfde dimensie die onder je voeten beweegt. Het is de storing die het minst op een coderingsprobleem lijkt en die het vaakst voor een bug in de code wordt aangezien. Er is niets in je code veranderd, gisteren werkte de verificatie nog, en nu mislukt een deel van de events.

Dat overlapvenster zit er met opzet in. Stripe houdt het oude endpoint-secret na een rotatie tot 24 uur geldig, en in die periode draagt de Stripe-Signature-header één v1-signature per actief secret. Shopify doet het omgekeerd: na een rotatie kan het tot een uur duren voordat Shopify digests met het nieuwe secret berekent, dus in de tussentijd heb je het oude nodig.

Het gedrag van Stripe breekt code, omdat de header eruitziet alsof er één signature in zit. Splitsen op , en de eerste v1 pakken die je vindt werkt prima tot er twee zijn. Vanaf dat moment klopt het ongeveer de helft van de keren, afhankelijk van welk secret welk event ondertekende. Loop ze allemaal langs:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // houd de originele string vast
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Twee details in die code zijn ook los van de loop belangrijk. De timestamp gaat als de string waarin hij aankwam in de ondertekende payload, en de body wordt als bytes samengevoegd in plaats van via template-interpolatie, want dat zou hem eerst als UTF-8 decoderen.

Dezelfde vorm geldt als je aan jouw kant roteert: accepteer zowel het oude als het nieuwe secret zolang de overlap duurt en laat daarna het oude vallen. Het secret waar je naartoe roteert heeft volledige entropie nodig, dus genereer het in plaats van het te typen, bijvoorbeeld met de generator voor signing secrets voor een willekeurige waarde van 256 bits.

7. Signatures vergelijken zonder timing te lekken

Zodra je twee digests hebt, is de manier waarop je ze vergelijkt een beveiligingsbeslissing. Een string-vergelijking stopt zodra hij een afwijkende byte vindt, dus de tijd die het kost verraadt hoeveel bytes vooraan goed waren. Een aanvaller die veel requests kan versturen, haalt daarmee byte voor byte een geldige signature terug. Over het internet is dat traag en lawaaierig; op een lokaal netwerk volledig haalbaar.

Elke runtime heeft een vergelijking met vaste looptijd ingebouwd:

TaalVergelijking met constante tijdBij verschillende lengtes
Nodecrypto.timingSafeEqual(a, b)gooit een fout
Pythonhmac.compare_digest(a, b)geeft False terug
Gohmac.Equal(a, b)geeft false terug
PHPhash_equals($known, $user)geeft false terug
RubyOpenSSL.secure_compare(a, b)geeft false terug

Die laatste kolom is de bron van een hele categorie verwarrende incidenten. Node is de uitzondering, en die faalt niet netjes:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Reken uit wanneer dat afgaat. Een SHA-256-digest in hex is 64 tekens. De waarde in X-Hub-Signature-256 is er 71, want sha256= is zeven tekens. Vergeet je de prefix eraf te halen, dan hebben de twee buffers verschillende lengtes en gooit timingSafeEqual een fout in plaats van false terug te geven. Vang je die niet op, dan borrelt de exception uit je handler omhoog en maakt Express er een 500 van.

Bedenk nu wat je te zien krijgt. Je verwacht een webhook 401 unauthorized en je krijgt een serverfout, dus ga je je handler nalezen, je databaseaanroep, je event-dispatcher. De echte bug staat één regel boven de vergelijking. Een hex-digest van 64 tekens vergelijken met een base64-waarde van 44 tekens gooit om dezelfde reden een fout, dus ook een verkeerde codering komt in Node als een 500 naar boven in plaats van als een nette afwijzing.

De oplossing: controleer de lengte zelf en geef false terug.

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // controleer vóór de aanroep
  return crypto.timingSafeEqual(a, b);
}

De lengte lekken is onschadelijk: de lengte van een digest staat vast door het algoritme en is openbaar. Wat je niet mag lekken, is welke prefix overeenkwam. Het tabblad Verify van de HMAC-generator verwerkt het lengteverschil in dezelfde accumulator met constante tijd in plaats van vroegtijdig terug te keren, zodat een afwijkende lengte gewoon false oplevert in plaats van een exception, en je een headerwaarde tegen je berekende digest kunt controleren zonder wegwerpcode te schrijven.

8. Wanneer de transportlaag je bytes heeft veranderd

Je hebt de ondertekende string, de raw body, de coderingen, de klok en rotatie uitgesloten. Wat overblijft, is de mogelijkheid dat de bytes die bij jouw proces aankomen niet de bytes zijn die de provider verlieten.

Compressie. Een provider of proxy kan de body gzipped versturen met Content-Encoding: gzip. De signature dekt de ongecomprimeerde payload, dus je moet de hash ná het decomprimeren berekenen. Sommige frameworks decomprimeren onzichtbaar, andere geven je de gecomprimeerde bytes; een body die er in je log uitziet als binaire rommel verraadt het.

Chunked transfer. Bij Transfer-Encoding: chunked is er geen Content-Length, en code die op die header vertrouwt om een leesbuffer te dimensioneren kapt de body af. De digest van een afgekapte body is geldige onzin: hij zal nooit overeenkomen, en er lijkt niets mis.

Proxy’s en WAF’s. Elke laag die de body leest en herschrijft, kan die veranderen. AWS API Gateway kan de body in base64 encoderen voordat die bij een Lambda aankomt, dus moet je eerst decoderen en dan de hash berekenen. Application load balancers, service meshes en web application firewalls normaliseren payloads of encoderen ze opnieuw. Test het door de bytelengte die je handler ziet te vergelijken met de Content-Length die de provider verstuurde.

Tekencodering en BOM. Payloads kunnen tekens buiten ASCII bevatten, en de documentatie van GitHub is expliciet: de payload moet als UTF-8 worden behandeld. De body naar een string decoderen in de verkeerde tekenset en hem weer encoderen vernielt elk teken van meerdere bytes. Een UTF-8 byte order mark, EF BB BF, ervoor gezet door een goedbedoelende editor of serializer, voegt drie bytes toe die nooit zijn ondertekend.

Regeleindes en losse witruimte. Een body die in tekstmodus door een bestand is gegaan, kan aankomen met LF herschreven naar CRLF. Lees ook de spec van de provider na voor de exacte ondertekende string: sommige zetten er zelf een teken achter, en Typeform is een gedocumenteerd geval waarin een afsluitend regeleinde deel uitmaakt van wat er in de hash gaat. Noemt de documentatie van een provider een extra teken, neem dat dan letterlijk.

9. Een herhaalbare debug-workflow

Loop deze op volgorde langs. Elke stap vindt de bug of schrapt een tak, en vroeg kunnen stoppen is precies de bedoeling.

  1. Log de ruwe bytes voordat er middleware draait. Schrijf de body naar een bestand, of log de bytelengte plus de SHA-256 ervan, vanaf het vroegste punt in de levensloop van de request dat je kunt bereiken. Alleen de lengte lost verrassend veel gevallen op: één meer dan verwacht is een afsluitend regeleinde, drie meer is een BOM.
  2. Bereken de digest met de hand. Plak exact die bytes en je secret in de HMAC-generator, kies SHA-256 en zet het uitvoerformaat gelijk aan dat van de header. Dit is de stap met de meeste opbrengst, want hij splitst het probleem netjes in twee.
  3. Vergelijk de met de hand berekende waarde met de header. Gelijk betekent dat zowel de bytes als het secret goed zijn en dat de bug ergens in jouw codepad zit, dus lees je vergelijking na. Niet gelijk betekent dat een van de inputs fout is; ga verder.
  4. Controleer de ondertekende string tegen de tabel in sectie 2. Zet deze provider een timestamp vooraan? Met welk scheidingsteken? Voeg de prefix toe in de tool en bereken opnieuw.
  5. Wissel de codering van de digest. Bereken opnieuw als hex en als base64 en vergelijk beide met de header. Een headerwaarde van 44 tekens met een = op het eind is base64, wat je code ook aannam.
  6. Wissel de codering van de sleutel. Probeer het secret als tekst, dan als hex, dan als base64. Meestal levert één van de drie een match op, en dat vertelt je wat de provider verwacht.
  7. Controleer de klok en de rotatiestatus. Vergelijk de tijd van je server met een betrouwbare bron, bevestig dat je epoch-seconden verwerkt, en kijk in het dashboard van de provider of er in de laatste 24 uur is geroteerd.

Twee gewoontes maken deze lus veel sneller. Ten eerste: leg één mislukkende payload vast en werk daar offline mee verder in plaats van op de volgende aflevering te wachten. Ten tweede: speel die vastgelegde body opnieuw af tegen je endpoint met een vaste signature, zodat de input tussen pogingen nooit verschilt. De generator voor cURL-commando’s stelt de request samen met exact de juiste headers en een body die uit een bestand wordt gelezen, zodat de bytes over meerdere runs stabiel blijven. Kun je de fout op commando reproduceren, dan wordt een sporadische melding van webhook signature verification failed een reparatie van vijf minuten.

Moet je alsnog een supportticket aanmaken, vermeld dan de bytelengte van de body waarover je de hash berekende, de headerwaarde letterlijk, de manier waarop je de ondertekende string hebt opgebouwd, en de codering van de digest. Zet er nooit het secret zelf bij.

FAQ

Waarom werkt mijn webhook signature lokaal wel en in productie niet?

Je testpayload overleeft een JSON-round-trip vermoedelijk ongewijzigd, dus opnieuw serialiseren is onschadelijk. Echte payloads bevatten floats, grote integers, Unicode-escapes of extra witruimte, en die veranderen de bytes wél. Onderteken de raw body in plaats van een opnieuw geserialiseerde kopie; de tabel in sectie 3 laat zien welke vormen stuklopen.

Moet ik de prefix sha256= meenemen bij het vergelijken van signatures?

Haal hem eraf, of zet hem voor je eigen waarde zodat beide strings exact gelijk zijn. Je berekende hex-digest is 64 tekens en de headerwaarde is er 71 met de prefix. Sommige vergelijkingsfuncties geven false terug bij een verschil in lengte, en timingSafeEqual in Node gooit een fout in plaats van false terug te geven.

Kan ik de signature verifiëren nadat mijn framework de JSON heeft ingelezen?

Niet betrouwbaar. Opnieuw serialiseren reproduceert de originele bytes alleen bij payloads zonder floats, zonder integers boven 2^53, zonder Unicode-escapes en zonder extra witruimte. Zodra er één opduikt verandert de digest, waardoor de verificatie in tests slaagt en op een deel van de productie-events mislukt.

Waarom leveren Stripe en GitHub verschillende signatures op voor dezelfde payload?

Omdat ze de hash over verschillende strings berekenen. GitHub ondertekent alleen de raw body. Stripe ondertekent de timestamp, een letterlijke . en daarna de body, dus één payload die op twee momenten wordt afgeleverd geeft twee verschillende digests. Slack zet v0: en zijn eigen timestamp ervoor. Zelfde algoritme, andere input.

Hoe lang moet de timestamp-tolerantie zijn?

Vijf minuten is wat Stripe en Slack gebruiken, en dat overnemen is een redelijke standaard. Kortere vensters weigeren legitieme afleveringen zodra de klok van je server wegloopt. Langere vensters vergroten de periode waarin een aanvaller een opgevangen request opnieuw kan afspelen. Synchroniseer je klokken met NTP voordat je de tolerantie oprekt.

Geeft timingSafeEqual false terug als de lengtes verschillen?

Nee. Node gooit RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Vang je die niet op, dan wordt het een 500 in plaats van een 401, waardoor je je handler gaat debuggen in plaats van de regel boven de vergelijking. Vergelijk eerst de lengtes en geef zelf false terug.

Mijn provider heeft het secret geroteerd, waarom mislukken sommige webhooks dan nog?

Rotatievensters overlappen. Stripe houdt het oude secret tot 24 uur geldig en stuurt één v1-signature per actief secret, dus code die alleen de eerste v1 leest faalt op ongeveer de helft van de events. Shopify heeft na een rotatie tot een uur nodig voordat het nieuwe secret in gebruik is.

Conclusie

Verificatie is een vergelijking van bytes, dus webhook signature verification failed komt altijd neer op onenigheid over bytes en nooit op iets cryptografisch. Houd de dimensies gescheiden terwijl je debugt:

  • Welke bytes zijn ondertekend. Leg de raw body vast voordat er een parser aan komt. Bereken nooit een HMAC over een opnieuw geserialiseerd object: dat komt vaak genoeg overeen om je tests te doorstaan en te weinig om te werken.
  • Welke sleutelbytes zijn gebruikt. Eén secret gelezen als tekst, als hex en als base64 geeft drie verschillende sleutels.
  • In welke codering je hebt vergeleken. Hex is 64 tekens, base64 is 44, en beide beschrijven dezelfde 32 bytes.
  • Al het overige. De timestamp-prefix, de prefix in de waarde, het tolerantievenster, de overlap bij rotatie en de transportlaag, ruwweg in die volgorde van waarschijnlijkheid.
  • Hoe je hebt vergeleken. Controleer eerst de lengte, gebruik daarna de functie met constante tijd van je runtime.

Wil je een betrouwbare waarde om tegen te vergelijken, bereken die dan buiten je applicatie: plak de body en het secret in de HMAC-generator en kijk aan welke kant het misgaat.

Tags: webhook hmac api-security debugging authentication

Gerelateerde artikelen

Alle artikelen bekijken