Webhook-Sicherheit: HMAC-Signaturen, Replay-Schutz und Verifizierung in Symfony
Webhooks sind die Integrationsfläche, die Teams zuletzt absichern, wenn überhaupt. Ein Endpoint nimmt POST-Requests aus dem öffentlichen Internet an, parst den Body und schreibt in die Datenbank. Wenn jeder ihn aufrufen kann, kann jeder Bestellungen anlegen, Subscription-Zustände umschalten oder Rechnungen als bezahlt markieren. Webhook-Signatur-Verifizierung ist der Mechanismus, der das verhindert, und in den meisten Codebasen, die wir auditieren, fehlt sie, ist halb umgesetzt oder auf eine Weise implementiert, die leise bricht.
Dieser Beitrag deckt die ganze Kette ab: wie man HMAC-Signaturen auf der Senderseite korrekt erzeugt, wie man sie in PHP verifiziert, ohne einen Timing-Seitenkanal zu öffnen, wie man Replay-Angriffe stoppt und wie man einen Symfony-Consumer baut, der Retries übersteht und mehrere Provider gleichzeitig bedient.
Warum fehlende Webhook-Signatur-Verifizierung eine offene Tür ist
Ein Webhook-Endpoint ohne Verifizierung vertraut allem, was mit der richtigen JSON-Form ankommt. Ein Angreifer, der die URL findet (sie leakt über Logs, Browser-Verläufe, Provider-Dashboards und falsch konfigurierte Error-Tracker öfter, als du denkst), kann ein payment_intent.succeeded-Event fälschen und deine Anwendung dazu bringen, Waren zu versenden, die nie bezahlt wurden.
Die Lösung ist ein Shared Secret. Der Provider signiert jede Zustellung mit HMAC-SHA256 über den Request-Body und legt das Ergebnis in einen Header. Dein Endpoint berechnet die Signatur aus dem empfangenen Body neu und vergleicht. Ein Angreifer ohne das Secret kann keine gültige Signatur erzeugen, gefälschte Events scheitern also an der Tür, bevor irgendeine Business-Logik läuft.
Das ist die Theorie. Die Implementierung ist der Punkt, an dem Teams sich verletzen, und die Fehler häufen sich an drei Stellen: was signiert wird, wie der Vergleich läuft und was passiert, wenn derselbe gültige Request zweimal ankommt.
Signiere den Raw Body, nie ein re-serialisiertes Objekt
Der mit Abstand häufigste Bug in der Webhook-Signatur-Verifizierung, auf beiden Seiten der Verbindung, ist das Signieren oder Verifizieren einer serialisierten Repräsentation des Payloads statt der rohen Bytes.
Wenn der Sender hash_hmac('sha256', json_encode($event), $secret) macht und der Empfänger hash_hmac('sha256', json_encode(json_decode($body)), $secret), schlägt die Verifizierung sporadisch fehl. json_encode in PHP escapt standardmäßig Forward Slashes, ordnet Keys in Einfügereihenfolge und rendert 1.0 als 1. Node auf der anderen Seite trifft andere Entscheidungen. Zwei Encoder, die semantisch identisches JSON produzieren, produzieren fast nie byte-identisches JSON, und HMAC arbeitet auf Bytes.
Die Regel für die Senderseite: einmal serialisieren, exakt diese Bytes signieren, exakt diese Bytes senden.
$payload = json_encode($event, JSON_THROW_ON_ERROR);
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// send $payload as the body, never re-encode it
Die Regel für die Empfängerseite: gegen den rohen Request-Body verifizieren, bevor irgendetwas ihn parst.
$rawBody = $request->getContent();
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
In Symfony liefert dir $request->getContent() die unberührten Bytes. Mach die Verifizierung dort, in einem Listener oder Subscriber, der vor Deserialisierung, Body-Convertern oder allem anderen läuft, was den Payload normalisieren könnte.
Beachte den Timestamp, der in den signierten String konkateniert wird. Das ist keine Dekoration. Er ist der Anker für den Replay-Schutz, zu dem wir gleich kommen.
Timing-sicherer Vergleich in PHP
Signaturen mit === zu vergleichen leakt Informationen. String-Vergleiche in PHP kehren zurück, sobald ein abweichendes Byte gefunden wird. Eine Signatur, die in den ersten vier Zeichen übereinstimmt, braucht also messbar länger bis zur Ablehnung als eine, die sofort scheitert. Über genügend Requests kann ein Angreifer diese Timing-Differenz nutzen, um eine gültige Signatur Byte für Byte zu rekonstruieren. Das ist kein theoretischer Angriff; er wurde gegen echte APIs über das Netzwerk demonstriert.
PHP hat die Antwort seit 5.6 eingebaut:
if (!hash_equals($expected, $providedSignature)) {
throw new UnauthorizedHttpException('Webhook', 'Invalid signature.');
}
hash_equals vergleicht in konstanter Zeit relativ zur Länge des bekannten Strings. Zwei Details sind wichtig. Der bekannte, selbst berechnete Wert gehört in das erste Argument. Und beide Eingaben sollten die Hex-Digest-Strings sein, verglichen wie sie sind; den vom Angreifer gelieferten Wert vorher nicht lowercasen, trimmen oder anderweitig massieren, denn diese Operationen führen wieder Timing-Varianz ein.
Wenn du eine Sache aus diesem Beitrag mitnimmst: Jedes === oder ==, das eine Signatur vergleicht, ist ein Finding in einem Security-Review. Wir markieren es in fast jedem Code-Audit, das einen Webhook-Consumer umfasst.
Replay-Schutz: Timestamps plus Nonce-Cache
Eine gültige Signatur beweist, dass der Request von jemandem mit dem Secret kam. Sie beweist nicht, dass der Request frisch ist. Ein Angreifer, der eine legitime Zustellung abfängt (aus einem Log-Aggregator, einem Proxy, einer kompromittierten Staging-Maschine), kann sie unverändert erneut senden, Signatur intakt, so oft er will. Wenn das Event „schreibe diesem Account etwas gut" lautet, ist Replay ein direkter Weg zum Betrug.
Zwei Ebenen schließen dieses Loch.
Die erste ist der Timestamp, den du oben im signierten String gesehen hast. Der Sender legt die Sendezeit in einen Header und in den HMAC-Input. Der Empfänger lehnt alles ab, was älter als ein kleines Fenster ist:
$timestamp = (int) $request->headers->get('X-Webhook-Timestamp');
if (abs(time() - $timestamp) > 300) {
throw new UnauthorizedHttpException('Webhook', 'Timestamp outside tolerance.');
}
Weil der Timestamp Teil des signierten Payloads ist, kann ein Angreifer einen abgefangenen Request nicht durch Austauschen des Headers auffrischen; die Signatur würde nicht mehr passen. Fünf Minuten Toleranz fangen Clock Skew und Retry-Verzögerungen des Providers ab. Stripe nutzt dasselbe Fenster als Default.
Die zweite Ebene deckt Replays innerhalb des Fensters ab. Jedes Event trägt eine eindeutige ID, und der Empfänger führt eine kurzlebige Liste der IDs, die er bereits akzeptiert hat. Redis passt hier natürlich, weil der Eintrag von selbst ablaufen kann:
$key = 'webhook:seen:' . $provider . ':' . $eventId;
if (!$redis->set($key, '1', ['nx', 'ex' => 600])) {
// NX failed: this ID was already processed within the last 10 minutes
return new Response(null, 200);
}
Das NX-Flag macht Check-and-Set atomar, zwei gleichzeitige Zustellungen desselben Events können also nicht beide durchkommen. Für ein wiederholtes Event 200 zurückzugeben ist Absicht. Der Provider betrachtet die Zustellung als erfolgreich und hört auf zu retrien, genau das, was du bei einem Duplikat willst.
Idempotenz: der Empfänger, der Retries übersteht
Replay-Schutz und Idempotenz sehen ähnlich aus, lösen aber unterschiedliche Probleme. Replay-Schutz hindert Angreifer daran, abgefangene Requests wiederzuverwenden. Idempotenz hält deine eigenen Daten korrekt, wenn der Provider dasselbe Event legitim zweimal zustellt, was jeder ernsthafte Provider tun wird, denn sie retrien bei Timeouts, und ihre Zustellgarantie ist at-least-once.
Der Redis-Nonce oben dedupliziert innerhalb eines Zehn-Minuten-Fensters. Das ist zu kurz für Zustell-Retries, die nach einem Ausfall auf deiner Seite Stunden später ankommen können. Dafür willst du dauerhafte Speicherung. Extrahiere die Event-ID, schreibe sie in PostgreSQL in derselben Transaktion, die die fachliche Änderung anwendet, und lass einen Unique Constraint entscheiden:
$this->em->wrapInTransaction(function () use ($event) {
$this->em->persist(new ProcessedWebhook($event->provider, $event->id));
$this->handler->apply($event); // the actual business logic
});
Wurde das Event bereits verarbeitet, verletzt der Insert den Unique Constraint auf (provider, event_id), die Transaktion rollt zurück, und die fachliche Änderung wird nicht ein zweites Mal angewendet. Fang die UniqueConstraintViolationException, logge sie als Duplikat und gib 200 zurück. Den Dedup-Datensatz und die Zustandsänderung in eine Transaktion zu binden ist der Punkt: Scheitert eines, scheitern beide, ein Crash mitten im Handler kann das Event also nicht als erledigt markieren, ohne es angewendet zu haben.
Ein Cron, der processed_webhooks-Zeilen älter als 30 Tage löscht, hält die Tabelle im Zaum.
Ein Multi-Provider-Consumer in Symfony
Echte Anwendungen empfangen Webhooks von mehreren Providern gleichzeitig, und jeder Provider signiert anders. Stripe sendet Stripe-Signature mit eingebettetem Timestamp und Schema-Präfix. GitHub sendet X-Hub-Signature-256 als sha256=<hex> ohne Timestamp. Clerk nutzt das Svix-Format: Base64-Signaturen, eine Message-ID und ein separater Timestamp-Header.
Einen Verifizierungspfad pro Controller hartzukodieren endet in Copy-Paste-Drift. Eine sauberere Form ist ein Verifier-Interface und ein Request-Listener, der die Implementierung anhand der Route auswählt:
interface WebhookVerifier
{
public function verify(Request $request, string $secret): void;
}
final class StripeVerifier implements WebhookVerifier { /* t=,v1= parsing */ }
final class GitHubVerifier implements WebhookVerifier { /* sha256= prefix */ }
final class ClerkVerifier implements WebhookVerifier { /* svix headers */ }
#[AsEventListener(event: KernelEvents::REQUEST, priority: 24)]
final class WebhookVerificationListener
{
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
$provider = $request->attributes->get('_webhook_provider');
if (null === $provider) {
return;
}
$this->verifiers->get($provider)->verify(
$request,
$this->secrets->forTenant($request, $provider),
);
}
}
Der Listener läuft früh, auf dem Raw Body, bevor irgendein Controller oder Body-Mapper den Request anfasst. Routen opten über einen _webhook_provider-Default ein. In einem Multi-Tenant-Setup löst der Secret-Lookup pro Tenant auf, denn jeder Tenant verbindet seinen eigenen Stripe- oder GitHub-Account und hat daher sein eigenes Signing Secret. Halte diese Secrets in deinem Secrets-Manager, nicht in der Tenant-Zeile neben dem Anzeigenamen.
Fehlschläge sollten 401 mit leerem Body zurückgeben. Gib nicht die erwartete Signatur, den berechneten Digest oder den gescheiterten Check aus. Gesprächige Fehlerantworten haben kaputte Webhook-Endpoints mehr als einmal in Signatur-Orakel verwandelt.
Diese Art Struktur ist Alltag in den SaaS-Plattformen, die wir bauen; wenn du eine Integrationsschicht von Grund auf entwirfst, beschreibt unsere Seite zur individuellen Softwareentwicklung, wie wir das angehen.
Kaufen statt bauen: was du von einem Provider verlangen solltest
Wenn du auf der konsumierenden Seite bist und einen Anbieter bewertest, von dessen Webhooks du abhängen wirst, verrät dir dessen Sicherheitsdesign viel über den Rest seines Engineerings. Prüfe vor der Integration die Dokumentation auf HMAC-SHA256-Signaturen (oder besser) über den Raw Body, einen Timestamp, der in die Signatur eingebunden ist statt als loser Header mitzukommen, dokumentierte Retry-Semantik mit Backoff, Secret-Rotation mit Überlappungsphase, damit du Secrets ohne Downtime rollen kannst, und eindeutige Event-IDs, die sich zur Deduplizierung eignen.
Ein Anbieter, der mit MD5 signiert, keinen Timestamp hat oder dir sagt „verifiziere über die Source-IP", zeigt dir, wie er mit den Teilen umgeht, die du nicht sehen kannst. IP-Allowlisting ist keine Authentifizierung; Provider wechseln Ranges, und geteilte Egress-IPs bedeuten, dass andere Kunden derselben Plattform deinen Endpoint von einer erlaubten Adresse aus treffen können.
Monitoring: Verifizierungsfehler sind ein Signal
Sobald die Verifizierung steht, wird ihre Fehlerrate zur Sicherheitsmetrik. Logge jede Ablehnung mit der Fehlerklasse (falsche Signatur, veralteter Timestamp, wiederholter Nonce) und der Source-IP, dann beobachte zwei Muster.
Ein plötzlicher Anstieg von Signaturfehlern von einer einzelnen IP ist jemand, der deinen Endpoint sondiert. Das ist einen Alert wert, aber meist harmlos, denn die Verifizierungsschicht tut ihren Job.
Ein stetiges Rinnsal von Fehlern von den echten IPs des Providers ist schlimmer: Es bedeutet meist, dass eine Secret-Rotation schiefging oder ein Deploy die Body-Verarbeitung verändert hat (ein Proxy, der re-encodet, eine Middleware, die Whitespace trimmt), und du verwirfst jetzt still legitime Events. Kombiniere die Ablehnungsmetrik mit einer Delivery-Lag-Metrik, dem Alter des neuesten erfolgreich verarbeiteten Events pro Provider. Wenn Stripe-Events normalerweise in Sekunden ankommen und der Lag über Minuten steigt, ist etwas oberhalb deiner Business-Logik kaputt, egal ob jemand deinen Code angefasst hat.
Beide Metriken sind billig: zwei Counter und ein Gauge in dem, was du ohnehin für Anwendungsmetriken nutzt.
Wie es weitergeht
Die komplette Checkliste passt in einen Absatz. Signiere und verifiziere den Raw Body. Vergleiche mit hash_equals. Binde einen Timestamp in die Signatur ein und lehne veraltete Zustellungen ab. Dedupliziere mit einem atomaren Redis-Check für das kurze Fenster und einem Unique Constraint in PostgreSQL für das lange. Verifiziere vor dem Parsen, pro Provider, pro Tenant. Gib stille 401er zurück. Beobachte die Fehlerrate.
Nichts davon ist exotisch, und alles zusammen passt in einen Nachmittag, sobald du die Fehlermodi kennst. Der teure Teil ist herauszufinden, welche dieser Schritte dein aktueller Consumer auslässt, denn ein Webhook-Endpoint scheitert still: Im Happy Path funktioniert alles, und die Lücke zeigt sich erst, wenn jemand Feindseliges sie findet.
Wenn du ein zweites Paar Augen auf deinen Webhook-Consumern willst, oder generell auf der Integrationsfläche deiner Anwendung, schreib an hello@wolf-tech.io oder schau dich auf wolf-tech.io um. Ein fokussierter Review der Endpoints, die geldbewegende Events annehmen, ist einer der schnellsten Security-Gewinne, die wir kennen.

