Home / Articles / Web Development
Web Development

Secure Webhooks in PHP: Signature Validation, Preventing Replay, and Avoiding Duplicate Processing

Webhooks allow two applications to automatically notify each other, but open endpoints can easily be spoofed or called repeatedly. Here’s a practical pattern for receiving webhooks in PHP...

Webhook Aman di PHP: Validasi Signature, Cegah Replay, dan Hindari Proses Ganda

Webhooks often serve as an unseen part of a website. When a payment is successful, an order status changes, or there’s a new commit in a repository, other services send an HTTP POST to our application’s URL. It’s convenient, but there’s one fundamental problem: webhook endpoints are essentially public doors waiting for requests from the internet.

If every request is trusted blindly, anyone can send a fake payload as if it were from a service provider. The consequences can be serious: order statuses change without payment, accounts gain incorrect access rights, or notifications are sent repeatedly.

Therefore, webhooks should be treated like a specialized API: they must verify the authenticity of requests, limit data formats, and ensure that the same event is not processed twice.

Webhooks are not just a receiving URL

In a simple flow, the service provider sends a JSON payload to an endpoint like https://example.com/webhook/payment. The server then reads the data and performs a specific action.

The problem is that the URL alone does not prove who the sender is. HTTPS protects data in transit, but the application still needs to verify that the request was indeed made by a trusted service. GitHub, for example, provides a webhook secret and an HMAC signature in the X-Hub-Signature-256 header to help the receiver validate the payload. GitHub documentation also emphasizes that the signature should be verified before processing the payload further.

A safer sequence of checks

Do not immediately call json_decode() and then update the database. Use the following sequence:

  1. Ensure the request method is POST.
  2. Read the raw request body before converting it to a PHP array.
  3. Retrieve the signature from the header.
  4. Calculate the expected signature using the secret on the server.
  5. Compare the signature using a constant-time comparison function.
  6. Validate the size and structure of the payload.
  7. Check if the event has already been processed.
  8. Store or queue the event before returning a success response.

The raw body is important because the signature is calculated from the original request content. If the JSON is decoded and then restructured, changes in whitespace, property order, or character format can result in a different hash output.

Example of HMAC signature validation in PHP

The following example follows GitHub's signature pattern, which is prefixed with sha256= followed by the HMAC-SHA256 result of the raw body. The header format for each provider may differ, so do not assume all webhooks use the same header names or schemes.

<?php

$secret = $_ENV['WEBHOOK_SECRET'] ?? '';
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Method not allowed');
}

if ($secret === '' || $rawBody === '' || $signature === '') {
    http_response_code(401);
    exit('Unauthorized');
}

$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Unauthorized');
}

$data = json_decode($rawBody, true);

if (!is_array($data)) {
    http_response_code(400);
    exit('Invalid payload');
}

// Continue with event validation and processing.
http_response_code(200);
echo 'OK';

The hash_equals() function is used to ensure that the comparison does not use the regular == operator. GitHub recommends constant-time comparison to reduce the risk of information leakage through response time differences. The secret should also be stored in an environment variable or secret manager, not hardcoded in the repository.

Signature does not resolve replay attacks

A valid signature does not necessarily mean the request is new. If an attacker successfully records a legitimate request, that request can be resent. This is known as a replay attack.

Protection depends on the capabilities of the webhook provider. If the payload or header includes a timestamp and event ID that are also signed, the server can reject requests that are too old and log the IDs that have already been received. OWASP recommends a combination of short-tolerant timestamps and deduplication based on event IDs if the provider's protocol supports it.

For services that only provide a signature over the raw body, like GitHub's basic pattern, do not invent your own timestamp mechanism and assume it is automatically safe. Use the provided delivery ID to detect resends and keep the business process idempotent.

Prevent duplicate payments or actions with idempotency

Idempotency means the same request can be received multiple times without duplicating the final effect. This is important because the webhook sender may try to resend when the previous response is delayed or fails to be received.

For example, before processing a payment event, store the event ID in a dedicated table:

CREATE TABLE webhook_events (
    event_id VARCHAR(191) PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    received_at DATETIME NOT NULL,
    processed_at DATETIME NULL
);

The flow is:

  1. Verify the signature.
  2. Retrieve the event_id from the payload or header.
  3. Try to insert that ID into the table.
  4. If it hits a duplicate key, assume the event has already been processed.
  5. If successful, execute the business action in the appropriate transaction.

Do not rely solely on a SELECT check followed by a separate INSERT. Two requests arriving almost simultaneously can both pass the checks. A primary key or unique index in the database provides stronger protection.

Do not process everything within the request

Webhooks should respond quickly. If the handler immediately sends emails, updates multiple tables, or calls several other APIs, the request may timeout. The sender may then assume the delivery failed and try again, increasing the likelihood of duplication.

A more stable pattern is to verify the request, store the event in the database or queue, and then return a success response after the data is truly saved. A separate worker can handle heavy processing in the background. GitHub's guide on webhooks also recommends background processing to prevent delivery reception from being hindered by lengthy tasks.

Checklist before using webhooks in production

  • Use HTTPS and valid certificates.
  • Store secrets in environment variables, not in source code.
  • Verify signatures from the raw body.
  • Use hash_equals() or an equivalent constant-time comparison function.
  • Reject methods other than POST.
  • Limit the size of the request body.
  • Validate event type, ID, and important fields before touching the database.
  • Store event IDs to prevent duplicate processing.
  • Do not log secrets, signatures, or entire sensitive payloads.
  • Set up alarms for spikes in failed signatures, 4xx responses, and accumulating queues.

What does this mean for us?

Secure webhooks are not determined by a single line of HMAC. Security arises from multiple layers: signatures prove the authenticity and integrity of the payload, timestamps or deduplication help combat replay, databases prevent duplicate effects, while queues keep the endpoint responsive.

Start with the most impactful webhooks—such as payments, access rights changes, and item shipments. After that, apply the same pattern to other integrations. A little discipline at the beginning is far cheaper than fixing fake orders or duplicate transactions after the application is already running.

Sources & further reading

– Rio Yotto @rioyotto