Verificér webhook-signaturen i Python, Node og PHP
Tjek X-Danadresse-Signature med HMAC-SHA256 over den rå body og en konstant-tids-sammenligning, og test din kode med en selvsigneret levering.
Din webhook-URL er offentlig, så alle kan sende et POST til den. Signaturen beviser, at leveringen kommer fra Danadresse og ikke er ændret undervejs. Tjek den på hver levering, før du gør noget med indholdet.
Sådan er signaturen lavet
Hver levering har headeren X-Danadresse-Signature. Værdien er HMAC-SHA256 af hele request-body'en med webhookens hemmelighed som nøgle, skrevet som hex med små bogstaver (64 tegn). Hemmeligheden fik du, da du oprettede webhooken, se Webhooks: opsætning.
Tre ting skal være på plads:
- Brug de rå bytes. Beregn HMAC over body'en præcis, som den kom over nettet. Parser du JSON først og serialiserer igen, ændrer mellemrum og rækkefølge sig, og signaturen passer ikke.
- Sammenlign i konstant tid. Brug
hmac.compare_digest,crypto.timingSafeEqualellerhash_equals. En almindelig==stopper ved første forskel og kan lække signaturen tegn for tegn. - Afvis med 401. Mangler headeren, eller passer den ikke, så svar
401og gør intet andet.
Signaturen dækker ikke et separat tidsstempel i en header. Feltet timestamp ligger i body'en og er derfor med i signaturen. Vil du afvise gamle leveringer, der bliver sendt igen af en tredjepart, så tjek timestamp og afvis dem, der er ældre end fx en time. Gem også (entity, sekvensnummer) og spring over, hvis du har set det før. Se Webhooks: idempotens og fejl.
Test din verificering uden en rigtig levering
Der er ingen knap til at sende en testlevering. Det er heller ikke nødvendigt: du kan signere en eksempel-body selv med samme algoritme. Her er en selvtest i Python, der ikke kræver netværk:
"""selvtest.py: signér en eksempel-body og tjek, at verify() accepterer den og afviser ændringer.""" import hashlib import hmac import json def sign(secret: str, raw_body: bytes) -> str: """Præcis som Danadresse: hex HMAC-SHA256 af de rå bytes.""" return hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() def verify(secret: str, raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = sign(secret, raw_body) return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) secret = "9c4e" * 16 # 64 hex-tegn, samme form som den rigtige hemmelighed body = json.dumps({ "entity": "adresse", "sekvensnummer": 207922555, "count": 1, "changes": ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], "timestamp": "2026-10-15T13:08:11.512345+00:00", }).encode() sig = sign(secret, body) print("signatur:", sig) assert verify(secret, body, sig), "gyldig signatur blev afvist" assert not verify(secret, body.replace(b"207922555", b"207922556"), sig), "ændret body blev accepteret" assert not verify("forkert" + secret[7:], body, sig), "forkert hemmelighed blev accepteret" assert not verify(secret, body, None), "manglende header blev accepteret" # Samme data, men JSON'en er formateret om: signaturen passer ikke længere. # Derfor skal du verificere de rå bytes, ikke en re-serialiseret dict. assert not verify(secret, json.dumps(json.loads(body), indent=2).encode(), sig) print("alle tjek bestået")
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516 alle tjek bestået
Og det samme i Node:
// selvtest.mjs: signér en eksempel-body og tjek verify() (Node 18+) import assert from "node:assert/strict"; import crypto from "node:crypto"; export function sign(secret, rawBody) { return crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); } export function verify(secret, rawBody, signature) { if (typeof signature !== "string") return false; const expected = Buffer.from(sign(secret, rawBody), "utf8"); const given = Buffer.from(signature.trim().toLowerCase(), "utf8"); // timingSafeEqual kaster en fejl ved forskellig længde, så tjek længden først return expected.length === given.length && crypto.timingSafeEqual(expected, given); } const secret = "9c4e".repeat(16); const body = Buffer.from(JSON.stringify({ entity: "adresse", sekvensnummer: 207922555, count: 1, changes: ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], timestamp: "2026-10-15T13:08:11.512345+00:00", })); // JSON.stringify skriver uden mellemrum, så signaturen bliver en anden end i // Python-eksemplet. Det er pointen: signaturen gælder de præcise bytes. const sig = sign(secret, body); console.log("signatur:", sig); assert.ok(verify(secret, body, sig)); assert.ok(!verify(secret, Buffer.from(body.toString().replace("207922555", "207922556")), sig)); assert.ok(!verify("forkert", body, sig)); assert.ok(!verify(secret, body, undefined)); assert.ok(!verify(secret, body, "abc")); console.log("alle tjek bestået");
Python: Flask
request.get_data() giver de rå bytes. Læs dem, før du bruger request.json:
# app.py (Flask) import hashlib import hmac import json import os from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"] def verify(raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) @app.post("/hooks/danadresse") def danadresse_hook(): raw = request.get_data() # de rå bytes, før Flask parser JSON if not verify(raw, request.headers.get("X-Danadresse-Signature")): abort(401) payload = json.loads(raw) # Læg arbejdet i en kø og svar hurtigt (leveringen afbrydes efter 6 sekunder) enqueue(payload["entity"], payload["sekvensnummer"], payload["changes"]) return "", 204 def enqueue(entity: str, sekvensnummer: int, changes: list[str]) -> None: ... # fx Celery, RQ eller en tabel, som et baggrundsjob læser
Python: FastAPI
Læs await request.body() i stedet for at erklære en Pydantic-model som parameter. Modellen ville kun give dig de parsede data:
# main.py (FastAPI) import hashlib import hmac import json import os from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, Response app = FastAPI() SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"] def verify(raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) @app.post("/hooks/danadresse", status_code=204) async def danadresse_hook( request: Request, background: BackgroundTasks, x_danadresse_signature: str | None = Header(default=None), ) -> Response: raw = await request.body() # læs de rå bytes, ikke en Pydantic-model if not verify(raw, x_danadresse_signature): raise HTTPException(status_code=401, detail="bad signature") payload = json.loads(raw) background.add_task(behandl, payload) # kører efter svaret er sendt return Response(status_code=204) def behandl(payload: dict) -> None: ... # hent ændringerne med /replikering/haendelser?entitet={entity} fra din cursor
Node: Express
Den hyppigste fejl i Express er, at express.json() allerede har parset body'en. Brug express.raw() på webhook-ruten:
// server.mjs (Express) import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.DANADRESSE_WEBHOOK_SECRET; const app = express(); function verify(rawBody, signature) { if (typeof signature !== "string") return false; const expected = Buffer.from(crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex"), "utf8"); const given = Buffer.from(signature.trim().toLowerCase(), "utf8"); return expected.length === given.length && crypto.timingSafeEqual(expected, given); } // express.raw giver req.body som en Buffer med de præcise bytes. // Brug den KUN på denne rute: express.json() ville parse body'en først, // og så kan signaturen ikke længere verificeres. app.post("/hooks/danadresse", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => { if (!verify(req.body, req.get("X-Danadresse-Signature"))) { return res.sendStatus(401); } const payload = JSON.parse(req.body.toString("utf8")); res.sendStatus(204); // svar først ... setImmediate(() => enqueue(payload)); // ... og arbejd bagefter }); function enqueue(payload) { // fx BullMQ, en databasetabel eller en anden kø console.log("ændret:", payload.entity, payload.sekvensnummer, payload.count); } app.use(express.json()); // dine andre ruter app.listen(3000);
PHP
php://input giver de rå bytes, og hash_equals sammenligner i konstant tid:
<?php // hooks/danadresse.php (ren PHP; i Laravel: $request->getContent() og $request->header(...)) declare(strict_types=1); $secret = getenv('DANADRESSE_WEBHOOK_SECRET'); if (!is_string($secret) || $secret === '') { http_response_code(500); // konfigurationsfejl hos dig exit; } $raw = file_get_contents('php://input'); // de rå bytes $signature = $_SERVER['HTTP_X_DANADRESSE_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $raw, $secret); // hex, små bogstaver if (!hash_equals($expected, strtolower(trim($signature)))) { http_response_code(401); exit; } $payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); // Gem leveringen og svar med det samme; et cron-job eller en kø gør resten $pdo = new PDO(getenv('DATABASE_DSN')); $stmt = $pdo->prepare('INSERT INTO webhook_inbox (entity, sekvensnummer, body) VALUES (?, ?, ?)'); $stmt->execute([$payload['entity'], $payload['sekvensnummer'], $raw]); http_response_code(204);
Send en signeret test-levering til dit endpoint
Når din handler kører lokalt, kan du sende en signeret levering med openssl og curl. Du skal få 2xx for den gyldige og 401 for den forkerte:
#!/usr/bin/env bash # send-test.sh: send en signeret test-levering til dit eget endpoint set -euo pipefail URL="${1:-http://localhost:3000/hooks/danadresse}" : "${DANADRESSE_WEBHOOK_SECRET:?Sæt DANADRESSE_WEBHOOK_SECRET}" BODY='{"entity": "adresse", "sekvensnummer": 207922555, "count": 1, "changes": ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], "timestamp": "2026-10-15T13:08:11.512345+00:00"}' SIG="$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$DANADRESSE_WEBHOOK_SECRET" -hex | sed 's/^.*= //')" curl -sS -o /dev/null -w "gyldig signatur: HTTP %{http_code}\n" "$URL" \ -H "Content-Type: application/json" \ -H "X-Danadresse-Entity: adresse" \ -H "X-Danadresse-Signature: $SIG" \ --data-binary "$BODY" curl -sS -o /dev/null -w "forkert signatur: HTTP %{http_code}\n" "$URL" \ -H "Content-Type: application/json" \ -H "X-Danadresse-Signature: 0000" \ --data-binary "$BODY"
gyldig signatur: HTTP 204 forkert signatur: HTTP 401
Fejlfinding
| Symptom | Årsag |
|---|---|
| Signaturen passer aldrig | Body'en er parset og serialiseret igen, eller du bruger API-nøglen i stedet for webhookens hemmelighed |
| Passer lokalt, men ikke i produktion | En proxy eller et framework-middleware ændrer body'en (komprimering, tegnsæt, JSON-parser) før din kode |
timingSafeEqual kaster en fejl | Signaturerne har forskellig længde. Tjek længden først, som i eksemplerne |
| Hemmeligheden er væk | Den vises kun ved oprettelsen. Slet webhooken og opret en ny |
Relaterede guides
Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)