Webhooks often seem simple: one service sends an HTTP request, and then our website receives and processes it. The problem is, a webhook endpoint is essentially a door that can be called from the internet. If anyone can send a request in the correct format, the application may trust data that was actually created by an attacker.
The risks are not just fake data entering the database. Weak webhooks can trigger email deliveries, order status changes, account creations, access grants, and even other internal processes that should only be executed by trusted services.
Webhooks are not automatically secure just because their URL is hard to guess
Many early implementations rely on secret URLs like /webhook/order-7f3a9c. While this is better than easily guessable endpoints, it is still not authentication. URLs can leak through server logs, deployment histories, screenshots, analytics plugins, or third-party configurations.
Think of a webhook URL as a home address, not a door key. We still need a mechanism to ensure who is coming and whether the message has not been altered.
First layer: verify the digital signature
A common pattern used is HMAC, which is a hash-based authentication code. The sending and receiving services share the same secret. The sender calculates a signature from the request content and includes it in the header. The receiver recalculates the signature and compares the results.
Example of header concept:
X-Webhook-Signature: sha256=...The important thing: the signature must be calculated from the raw request body, not from data that has been converted into an array or new JSON. Small changes in spacing, character order, or number format can produce a different signature.
In PHP, signature comparison should use hash_equals(), not the == operator. This function is designed to reduce the risk of timing attacks that can be exploited through processing time differences.
<?php
$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = $_ENV['WEBHOOK_SECRET'];
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($rawBody, true);
This snippet is just a basic example. In a real application, add JSON validation, body size limits, incident logging, and error handling that does not leak secrets or internal details.
Don't forget to prevent old requests from being replayed
A valid signature does not necessarily mean the request is still valid for processing. If an attacker successfully records a legitimate request, they can resend the same request multiple times. This attack is called a replay attack.
To mitigate this, ask the sender to include a timestamp and a unique ID for each event. The signature is then calculated from the combination of the timestamp and the body, for example:
timestamp.bodyThe receiver can reject requests that are too old, for example, older than five minutes, with a reasonable time tolerance for server clock differences. The event ID also needs to be temporarily stored in a database or cache. If the same ID has already been processed, the next request is considered a duplicate.
This step is especially important for events like payments, refunds, subscription status changes, or credit grants. One event should not produce side effects multiple times.
Validate the message content, not just the sender
Requests from trusted services can still have issues due to bugs, format changes, or misconfigurations. After the signature passes, validate the payload strictly.
- Ensure the HTTP method is correct, usually
POST. - Check the
Content-Typeand request size. - Ensure required fields are present and have the correct types.
- Use an allowlist for values like status, currency, or event types.
- Do not trust prices, access rights, or user identities solely from the payload without additional checks.
For example, if a webhook states that an order has been paid, the application should not immediately grant access just because the status field contains paid. Match the transaction ID, amount, currency, and status with the stored data or re-verify through the payment provider's API if the impact is significant.
Limit the impact if the endpoint is misused
The webhook endpoint should have the narrowest access rights possible. Do not execute admin functions directly from public requests. Separate the receiving process from the business process.
A safer pattern is to receive the request, validate it, store the event, and then process it through a worker or queue. This way, internet requests do not have direct control over sensitive processes. The system can also more easily retry failed events without requiring the sender to blindly resend.
If the webhook is used to trigger heavy jobs, do not let the process run indefinitely. Use timeouts, request limits, and rate limits. Rate limiting means restricting how many requests can come in a certain period.
What about IP allowlisting?
IP allowlisting can be an additional layer, especially if the webhook provider has a stable list of official IPs. However, do not make it the only protection. Cloud infrastructure, proxies, and network changes can cause IP addresses to change. Additionally, misconfigured proxies can make the application read the attacker's IP address instead of the actual sender's address.
Use IP allowlisting alongside signatures, not as a replacement for signatures. Also, ensure that checks are performed at the network layer or reverse proxy if possible.
Log notes should be helpful, not revealing secrets
When a webhook fails, logs are the primary tool for diagnosing the cause. Record the time, event type, request ID, validation results, and processing duration. Avoid logging secrets, access tokens, card numbers, cookies, or entire payloads if the payload contains personal data.
Use correlation IDs so that one event can be traced from the incoming request to the completed process. Separate logs for rejected requests, duplicate requests, and processing failures. These three conditions have different meanings and require different actions.
Quick checklist before using webhooks in production
- The endpoint only accepts the necessary methods and formats.
- The signature is verified using the raw body and
hash_equals(). - The request has a timestamp and a unique event ID.
- Old events and duplicate events can be rejected.
- The payload is validated with an allowlist and clear data types.
- Sensitive processes are not executed directly without additional checks.
- Body size, timeouts, and rate limits are configured.
- The secret is stored in the environment or secret manager, not in the source code.
- Logs are informative enough without storing sensitive data.
- There is a procedure to replace the secret in case of a leak.
What does this mean for us?
Secure webhooks are not just about adding one header and calling it done. They require multiple layers: message authentication, replay protection, data validation, impact limitation, and good observability.
Start with the most at-risk endpoints. Ask: if this request were forged, what would be the worst-case scenario? If the answer involves money, user access, or important data, do not treat the webhook as a regular notification. Treat it as an integration pathway with limited access rights, that must be monitored, and can be quickly halted when something unusual occurs.
– Rio Yotto @rioyotto
