Wie ein Empfänger zuverlässig prüft, dass ein eingehender Webhook-Request echt und unverändert ist
Ein Webhook-Endpunkt ist technisch nichts anderes als eine öffentlich erreichbare URL, die POST-Requests entgegennimmt. Jeder, der diese URL kennt, kann theoretisch gefälschte Ereignisse einschleusen, wenn der Empfänger die Authentizität eingehender Requests nicht prüft. HMAC-SHA256-Signaturen über den Payload, kombiniert mit einer timing-sicheren Vergleichsfunktion und einem Replay-Schutz per Timestamp, schließen diese Lücke zuverlässig. Dieser Artikel zeigt die praktische Umsetzung Schritt für Schritt, inklusive eines vollständigen Symfony-Middleware-Beispiels.
Inhaltsverzeichnis
- 1. Warum Webhook-Empfänger die Authentizität eingehender Requests prüfen müssen
- 2. HMAC-SHA256: Signatur über Payload und Secret
- 3. Die Signatur im Header übertragen: X-Signature
- 4. Verifikation auf Empfängerseite: Schritt für Schritt
- 5. Timing-sichere Vergleichsfunktion: hash_equals in PHP
- 6. Replay-Schutz durch Timestamp und Toleranzfenster
- 7. Symfony-Middleware-Beispiel für die Signaturprüfung
- 8. Häufige Fehler bei der praktischen Umsetzung
- 9. Checkliste und Überblick für die Implementierung
- 10. Zusammenfassung
- 11. FAQ
1. Warum Webhook-Empfänger die Authentizität eingehender Requests prüfen müssen
Ein Webhook-Endpunkt ist aus Netzwerksicht nichts anderes als eine öffentlich erreichbare URL, die POST-Requests mit einem JSON-Payload entgegennimmt. Genau darin liegt das Sicherheitsproblem: Jeder, der die URL kennt oder errät, kann technisch beliebige Requests an diesen Endpunkt senden, die inhaltlich wie ein legitimes Ereignis aussehen, etwa eine gefälschte 'Zahlung erfolgreich'-Benachrichtigung, die im Empfängersystem eine Bestellung fälschlich als bezahlt markiert.
Ohne eine Prüfung der Authentizität muss der Empfänger jedem eingehenden Request blind vertrauen, sobald er syntaktisch gültig ist. Das ist ein fundamentaler Unterschied zu einer normalen REST-API, bei der ein Client sich meist über ein Token oder eine Session authentifiziert, denn bei Webhooks initiiert der Sender die Verbindung, und der Empfänger hat keine vorherige Session, gegen die er den Request abgleichen könnte. Request-Signing mit HMAC schließt genau diese Lücke, indem es kryptographisch beweisbar macht, dass ein Request tatsächlich vom erwarteten Sender stammt und unterwegs nicht verändert wurde.
2. HMAC-SHA256: Signatur über Payload und Secret
HMAC (Hash-based Message Authentication Code) kombiniert eine Hashfunktion, üblicherweise SHA-256, mit einem geheimen Schlüssel, den Sender und Empfänger im Vorfeld über einen sicheren Kanal austauschen. Der Sender berechnet vor dem Versand eine Signatur über den vollständigen Payload (den rohen Request-Body als String, nicht das geparste JSON-Objekt) unter Verwendung dieses Secrets, und hängt die berechnete Signatur als eigenen Header an den Request an.
Der entscheidende Sicherheitsvorteil gegenüber einer einfachen Prüfsumme ist, dass ein Angreifer ohne Kenntnis des Secrets keine gültige Signatur für einen manipulierten oder frei erfundenen Payload erzeugen kann, selbst wenn er den Algorithmus (SHA-256) und den generellen Aufbau des Requests genau kennt. Solange das Secret geheim bleibt, ist die Signatur praktisch fälschungssicher, und jede Änderung am Payload, und sei es nur ein einzelnes Byte, führt zu einer komplett anderen, ungültigen Signatur.
3. Die Signatur im Header übertragen: X-Signature
Die Signatur wird im Rahmen des Webhook-Versands als zusätzlicher HTTP-Header übertragen, üblicherweise unter dem Namen X-Signature oder einem sprechenderen Namen wie X-Webhook-Signature. Der Wert dieses Headers ist die als Hex-String kodierte HMAC-SHA256-Signatur über den exakten Request-Body, damit der Empfänger sie mit derselben Berechnung nachvollziehen kann.
Wichtig ist, dass die Signatur immer über die rohen, unveränderten Bytes des Payloads berechnet wird, und nicht über eine zwischenzeitlich neu serialisierte JSON-Repräsentation, da bereits unterschiedliche Feldreihenfolgen oder Leerzeichen im JSON zu einer abweichenden Byte-Sequenz und damit zu einer scheinbar ungültigen Signatur führen würden. Auf Sender-Seite sieht die Berechnung in PHP so aus:
<?php
declare(strict_types=1);
namespace App\Webhook;
final class WebhookSigner
{
public function __construct(
private readonly string $secret,
) {
}
/**
* Berechnet die HMAC SHA256 Signatur über den rohen Payload
* und gibt die Header zurück, die mitgesendet werden müssen.
*
* @return array<string, string>
*/
public function buildHeaders(string $rawPayload): array
{
$timestamp = (string) time();
$signedPayload = $timestamp . '.' . $rawPayload;
$signature = hash_hmac('sha256', $signedPayload, $this->secret);
return [
'X-Signature' => $signature,
'X-Signature-Timestamp' => $timestamp,
];
}
}
4. Verifikation auf Empfängerseite: Schritt für Schritt
Auf Empfängerseite läuft die Prüfung in einer festen Reihenfolge ab, die konsequent eingehalten werden muss, damit die Verifikation nicht durch eine falsche Implementierungsreihenfolge wirkungslos wird. Zunächst wird der rohe Request-Body unverändert eingelesen, bevor irgendeine Framework-Middleware ihn parst oder normalisiert, denn genau dieser rohe Byte-Strom war die Grundlage für die Signaturberechnung auf Sender-Seite.
Anschließend berechnet der Empfänger mit demselben Secret und demselben Algorithmus die erwartete Signatur über denselben rohen Payload (kombiniert mit dem übertragenen Timestamp) und vergleicht sie mit der im X-Signature-Header übermittelten Signatur. Stimmen beide Werte exakt überein, gilt der Request als authentisch, in jedem anderen Fall muss der Request mit einem 401-Statuscode abgelehnt werden, bevor irgendeine Geschäftslogik ausgeführt wird.
5. Timing-sichere Vergleichsfunktion: hash_equals in PHP
Ein häufig übersehener Fehler bei der Implementierung ist der Vergleich der beiden Signaturen mit dem einfachen ==-Operator oder der strict_types-Variante ===. Beide vergleichen Strings zeichenweise und brechen bei der ersten Abweichung sofort ab, was die Laufzeit des Vergleichs minimal, aber messbar von der Anzahl der übereinstimmenden Anfangszeichen abhängig macht.
Ein Angreifer, der Millionen von Requests mit leicht unterschiedlichen Signaturen sendet und die Antwortzeiten präzise misst, kann aus diesen winzigen Zeitunterschieden theoretisch Byte für Byte die korrekte Signatur rekonstruieren, ein klassischer Timing-Angriff. PHP bietet dagegen mit hash_equals() eine Vergleichsfunktion, die unabhängig von der Position der ersten Abweichung immer konstant lange braucht, weil sie beide Strings vollständig durchläuft, statt beim ersten Unterschied abzubrechen. Für jeden Signaturvergleich in Produktionscode muss deshalb ausschließlich hash_equals() verwendet werden, niemals ==, ===, strcmp() oder in_array().
6. Replay-Schutz durch Timestamp und Toleranzfenster
Eine gültige Signatur allein schützt nicht vor einem Replay-Angriff: Wenn ein Angreifer einen einmal legitim gesendeten, korrekt signierten Request abfängt und später identisch erneut abschickt, ist die Signatur weiterhin gültig, weil sich der Payload nicht geändert hat. Ohne zusätzlichen Schutz würde der Empfänger dasselbe Ereignis, etwa eine Zahlungsbestätigung, ein zweites Mal verarbeiten.
Die übliche Lösung kombiniert einen Timestamp, der Teil der signierten Nutzlast ist (wie im obigen Beispiel über signedPayload = timestamp + '.' + rawPayload), mit einem Toleranzfenster auf Empfängerseite, üblicherweise fünf bis zehn Minuten. Liegt der übermittelte Timestamp außerhalb dieses Fensters, wird der Request unabhängig von einer gültigen Signatur abgelehnt, weil er entweder ein wiederholter alter Request ist oder eine gefälschte, zu weit in der Zukunft liegende Angabe enthält. Für zusätzliche Sicherheit lässt sich ergänzend die Kombination aus Signatur und Timestamp für die Dauer des Toleranzfensters in einem Cache wie Redis speichern, um exakte Duplikate innerhalb des Fensters ebenfalls zu erkennen und abzulehnen.
7. Symfony-Middleware-Beispiel für die Signaturprüfung
In einer Symfony-Anwendung lässt sich die vollständige Prüfung, roher Payload einlesen, Timestamp-Toleranz prüfen, Signatur mit hash_equals vergleichen, sauber als Event-Subscriber auf das kernel.request-Event kapseln, der vor dem eigentlichen Controller ausgeführt wird und bei einer ungültigen Signatur den Request sofort mit einer 401-Antwort beendet.
Dieser Ansatz hält die Verifikationslogik zentral an einer Stelle, statt sie in jedem einzelnen Webhook-Controller zu wiederholen, und stellt sicher, dass kein Controller versehentlich ungeprüfte Requests verarbeitet, weil die Prüfung bereits auf Kernel-Ebene stattfindet, bevor das Routing überhaupt den passenden Controller auflöst.
<?php
declare(strict_types=1);
namespace App\Webhook\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Prüft die HMAC Signatur eingehender Webhook Requests,
* bevor der eigentliche Controller aufgerufen wird.
*/
final class WebhookSignatureSubscriber implements EventSubscriberInterface
{
private const int TOLERANCE_SECONDS = 300;
public function __construct(
private readonly string $webhookSecret,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 20],
];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/webhooks/')) {
return;
}
$signature = $request->headers->get('X-Signature');
$timestamp = $request->headers->get('X-Signature-Timestamp');
if ($signature === null || $timestamp === null) {
$event->setResponse(new JsonResponse(['error' => 'Missing signature headers'], 401));
return;
}
if (abs(time() - (int) $timestamp) > self::TOLERANCE_SECONDS) {
$event->setResponse(new JsonResponse(['error' => 'Timestamp outside tolerance window'], 401));
return;
}
$rawPayload = $request->getContent();
$signedPayload = $timestamp . '.' . $rawPayload;
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->webhookSecret);
if (!hash_equals($expectedSignature, $signature)) {
$event->setResponse(new JsonResponse(['error' => 'Invalid signature'], 401));
return;
}
}
}
8. Häufige Fehler bei der praktischen Umsetzung
Der häufigste Fehler in der Praxis ist, dass Frameworks den Request-Body standardmäßig bereits geparst haben, bevor die Signaturprüfung stattfindet, wodurch der für die Signatur relevante rohe Byte-String nicht mehr identisch verfügbar ist. In Symfony ist $request->getContent() zuverlässig, solange die Middleware früh genug im Request-Lebenszyklus ansetzt, in anderen Frameworks oder bei aktivierter Body-Parsing-Middleware kann dieser Schritt subtil scheitern, wenn der Rohkörper nicht mehr zugänglich ist.
Ein zweiter verbreiteter Fehler ist, das Secret client-seitig im Frontend-Code oder in einer öffentlich zugänglichen Konfigurationsdatei abzulegen, wodurch der gesamte Schutz wirkungslos wird, weil jeder mit Zugriff auf diesen Code eigene, gültige Signaturen erzeugen kann. Das Secret gehört ausschließlich in serverseitige Umgebungsvariablen oder einen Secret-Manager, niemals in Versionskontrolle oder client-seitigen Code. Ebenso häufig ist ein zu kurzes oder zu großzügiges Toleranzfenster: Weniger als eine Minute führt bei normaler Netzwerklatenz zu falschen Ablehnungen, mehr als fünfzehn Minuten vergrößert das Zeitfenster für Replay-Angriffe unnötig.
9. Checkliste und Überblick für die Implementierung
Die folgende Tabelle fasst die zentralen Bausteine einer korrekten HMAC-Implementierung für Webhook-Empfänger zusammen, als Checkliste für die eigene Umsetzung.
| Baustein | Zweck | Häufiger Fehler |
|---|---|---|
| HMAC-SHA256 über rohen Payload | Beweist Authentizität und Unveränderheit | Signatur über neu serialisiertes JSON statt Rohdaten berechnet |
| Signatur im X-Signature-Header | Überträgt den Signaturwert zum Empfänger | Header-Name zwischen Sender und Empfänger nicht abgestimmt |
| hash_equals() beim Vergleich | Schützt vor Timing-Angriffen | Vergleich mit == oder === statt konstanter Zeit |
| Timestamp mit Toleranzfenster | Schützt vor Replay-Angriffen | Kein Timestamp oder zu großzügiges Fenster |
| Secret nur serverseitig | Verhindert Fälschung durch Dritte | Secret im Frontend-Code oder in Versionskontrolle |
Mironsoft
OpenAPI-Design, Symfony-APIs und API-Sicherheit
APIs, die externe Teams ohne Rückfragen integrieren können?
Wir prüfen bestehende REST-APIs auf inkonsistente Fehlerformate, fehlende OpenAPI-Dokumentation und Sicherheitslücken und bauen daraus eine API, die klar dokumentiert, versioniert und gegen Missbrauch abgesichert ist.
API-Review
OpenAPI-Spezifikation, Fehlerformate und Statuscodes auf Konsistenz prüfen.
Symfony-Umsetzung
DTOs, Serializer und Validator für saubere, typsichere Request/Response-Modelle einsetzen.
Security-Audit
Rate-Limiting, Auth-Schemes und Input-Validierung gegen echte Angriffsflächen absichern.
10. Zusammenfassung
HMAC-Webhook-Signing: Das Wichtigste auf einen Blick
Kernprinzip
HMAC-SHA256 über den rohen Payload beweist, dass ein Webhook-Request tatsächlich vom erwarteten Sender stammt und unverändert ist.
Sichere Vergleichsfunktion
Signaturen werden ausschließlich mit hash_equals() verglichen, niemals mit == oder ===, um Timing-Angriffe auszuschließen.
Replay-Schutz
Ein signierter Timestamp mit Toleranzfenster von fünf bis zehn Minuten verhindert das wiederholte Abspielen abgefangener Requests.
Kritischster Fehler
Das Secret muss ausschließlich serverseitig gespeichert werden, niemals im Frontend-Code oder in Versionskontrolle.