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.

4 min Niveau: Avanceret ✓ Kode testet

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:

  1. 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.
  2. Sammenlign i konstant tid. Brug hmac.compare_digest, crypto.timingSafeEqual eller hash_equals. En almindelig == stopper ved første forskel og kan lække signaturen tegn for tegn.
  3. Afvis med 401. Mangler headeren, eller passer den ikke, så svar 401 og 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:

Python#selftest-python
"""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")
Text#selftest-output
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516
alle tjek bestået

Og det samme i Node:

JavaScript#selftest-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:

Python#flask
# 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:

Python#fastapi
# 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:

JavaScript#express
// 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#php
<?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:

Shell#send-test
#!/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"
Text#send-test-output
gyldig signatur: HTTP 204
forkert signatur: HTTP 401

Fejlfinding

SymptomÅrsag
Signaturen passer aldrigBody'en er parset og serialiseret igen, eller du bruger API-nøglen i stedet for webhookens hemmelighed
Passer lokalt, men ikke i produktionEn proxy eller et framework-middleware ændrer body'en (komprimering, tegnsæt, JSON-parser) før din kode
timingSafeEqual kaster en fejlSignaturerne har forskellig længde. Tjek længden først, som i eksemplerne
Hemmeligheden er vækDen vises kun ved oprettelsen. Slet webhooken og opret en ny

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →