Request-Signing mit HMAC für Webhook-Empfänger praktisch umsetzen
AI generated
{ }
GET
REST · Webhooks · Security
HMAC-Request-Signing für Webhooks
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.

15 Min. Lesezeit HMAC-SHA256 Webhook-Sicherheit

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.

11. FAQ: HMAC-Webhook-Signing: Das Wichtigste auf einen Blick

1Warum reicht eine geheime URL für einen Webhook-Endpunkt nicht als Schutz?
Eine URL kann durch Logs, Proxys, Browser-Historie oder einfaches Erraten bekannt werden. Ohne kryptographische Signaturprüfung könnte jeder, der die URL kennt, gefälschte Ereignisse einschleusen.
2Warum wird die Signatur über den rohen Payload statt über das geparste JSON berechnet?
Weil bereits unterschiedliche Feldreihenfolgen oder Leerzeichen im JSON zu einer abweichenden Byte-Sequenz führen würden. Nur der exakte, rohe Byte-Strom garantiert eine reproduzierbare Signatur.
3Warum darf ich Signaturen nicht mit == oder === vergleichen?
Diese Operatoren brechen beim ersten abweichenden Zeichen ab, wodurch die Vergleichszeit minimal von der Anzahl übereinstimmender Zeichen abhängt. Das ermöglicht theoretisch einen Timing-Angriff auf die korrekte Signatur.
4Was macht hash_equals() konkret anders?
hash_equals() vergleicht beide Strings immer vollständig in konstanter Zeit, unabhängig davon, an welcher Stelle sie voneinander abweichen, und verhindert dadurch messbare Zeitunterschiede.
5Wie groß sollte das Toleranzfenster für den Timestamp sein?
In der Praxis haben sich fünf bis zehn Minuten bewährt. Kürzere Fenster führen bei normaler Netzwerklatenz zu falschen Ablehnungen, deutlich längere Fenster vergrößern unnötig das Zeitfenster für Replay-Angriffe.
6Reicht ein Timestamp allein als Replay-Schutz aus?
Der Timestamp begrenzt das Zeitfenster, verhindert aber kein exaktes Duplikat innerhalb dieses Fensters. Für zusätzliche Sicherheit kann die Kombination aus Signatur und Timestamp zusätzlich in einem Cache wie Redis gespeichert werden.
7Wo sollte das Secret für die Signaturberechnung gespeichert werden?
Ausschließlich serverseitig in Umgebungsvariablen oder einem Secret-Manager, niemals im Frontend-Code, in Client-Konfigurationsdateien oder in Versionskontrolle.
8Muss die Signaturprüfung vor dem Controller stattfinden?
Ja, idealerweise auf Kernel- oder Middleware-Ebene, damit kein Controller versehentlich ungeprüften Payload verarbeitet, weil die Prüfung bereits vor dem Routing abgeschlossen ist.
9Kann ich den Request-Body in Symfony nach dem Signatur-Check noch normal auslesen?
Ja, $request->getContent() liefert den rohen Body weiterhin, solange kein vorheriger Schritt ihn bereits konsumiert oder verändert hat. Deshalb sollte die Signaturprüfung früh im Request-Lebenszyklus stattfinden.
10Welcher Hash-Algorithmus sollte für HMAC verwendet werden?
SHA-256 ist der aktuelle Praxisstandard für Webhook-Signaturen. Schwächere Algorithmen wie MD5 oder SHA-1 gelten als kryptographisch geschwächt und sollten für neue Implementierungen nicht mehr verwendet werden.