# 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.
>
> https://danadresse.dk/guides/webhooks-signatur · verified 2026-11-02
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](/guides/webhooks-opsaetning).

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](/guides/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 id=selftest-python run=syntax
"""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 id=selftest-output run=no
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516
alle tjek bestået
```

Og det samme i Node:

```js id=selftest-node run=syntax
// 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 id=flask run=syntax
# 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 id=fastapi run=syntax
# 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:

```js id=express run=syntax
// 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 id=php run=syntax
<?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:

```bash id=send-test run=syntax
#!/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 id=send-test-output run=no
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 |
