Master webhook security by implementing HMAC payload signature verification and replay attack prevention in Node.js and PHP to keep your endpoints safe.
We once spent three hours debugging a "ghost" payment event that triggered twice in our production environment. It turned out we were accepting raw payloads without verifying the sender, leaving us wide open to both data tampering and replay attacks.
If you aren't validating the signature of every incoming request, you aren't really running an API; you're running an open door. Here is how you lock it down.
At its core, webhook security relies on two pillars: authenticity and freshness. You need to prove the request came from your provider (like Stripe, GitHub, or Twilio) and ensure that a captured request cannot be resubmitted to trigger the same action again.
We first tried simple IP whitelisting, but that fell apart the moment our provider shifted their infrastructure to dynamic cloud IPs. We then moved to HMAC validation, which is the industry standard for a reason. By using a shared secret, the provider hashes the request body, and you verify that hash against the provided signature.
When you receive a webhook, the provider sends a signature header—usually X-Hub-Signature-256 or similar. You must compute the HMAC of the raw request body using your secret key and compare it to that header.
In Node.js (using Express), you need the raw body buffer. If you use express.json() middleware, it parses the body into an object, which ruins the hash. Use the verify option instead:
JAVASCRIPTconst express = require(CE9178">'express'); const crypto = require(CE9178">'crypto'); const app = express(); app.post(CE9178">'/webhook', express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }), (req, res) => { const signature = req.headers[CE9178">'x-signature']; const hmac = crypto.createHmac(CE9178">'sha256', process.env.WEBHOOK_SECRET); const digest = hmac.update(req.rawBody).digest(CE9178">'hex'); if (digest !== signature) { return res.status(401).send(CE9178">'Invalid signature'); } // Proceed with processing });
In PHP, use file_get_contents('php://input') to grab the raw stream. Never use $_POST for signature verification.
PHP$payload = file_get_contents('php:#6A9955">//input'); $signature = $_SERVER['HTTP_X_SIGNATURE']; $secret = getenv('WEBHOOK_SECRET'); $computed = hash_hmac('sha256', $payload, $secret); if (!hash_equals($computed, $signature)) { http_response_code(401); die('Invalid signature'); }
Using hash_equals is critical in PHP to prevent timing attacks. It ensures the comparison takes a constant amount of time regardless of how many characters match.
Even with a valid signature, a malicious actor could capture a legitimate, signed request and send it to your server repeatedly. This is a classic replay attack prevention failure.
To stop this, you need a timestamp. Most providers include a timestamp header (e.g., X-Timestamp). Your logic should look like this:
Flow diagram: Receive Request → Valid Signature?; B -- No → Reject 401; B -- Yes → Timestamp Fresh?; D -- No → Reject 401; D -- Yes → ID seen before?; E -- Yes → Reject 401; E -- No → Process Webhook
Just as you use JSON Schema Validation: Preventing Injection and DoS Attacks to protect your internal endpoints, you must treat webhooks as untrusted input. If you ignore this, you're effectively letting external actors execute logic within your system.
While I haven't seen a massive breach caused by this personally, the noise generated by replay attacks is enough to degrade database performance significantly. We once saw a 20% spike in CPU usage during a targeted replay attack before we implemented the timestamp check.
== instead of hash_equals: In PHP, standard string comparison is vulnerable to timing attacks.HMAC ensures data integrity, but you should always use HTTPS. HMAC validates that the payload hasn't changed, but HTTPS prevents the payload from being intercepted in plain text in the first place.
It depends on your provider. Most services recommend a window between 3 and 5 minutes to account for network latency. If your infrastructure is highly performant, you can tighten this to 60 seconds.
No. Use a TTL (Time-To-Live) cache like Redis. If you store the ID for 10 minutes, you effectively prevent replay attacks without bloating your database storage.
I’m still experimenting with rotating secrets automatically. It adds a layer of complexity to the deployment pipeline, but it’s likely the next step for our most sensitive endpoints. Security is rarely a "done" task; it's a moving target.
Preventing session hijacking requires more than just HTTPS. Learn to secure your Node.js and PHP apps with proper cookie attributes and request fingerprinting.
Read moreHTTP header injection can lead to dangerous response splitting attacks. Learn how to secure your Node.js and PHP apps by implementing strict input validation.
Read moreTOCTOU race conditions leave your file system operations vulnerable. Learn how to secure your Node.js and PHP code against these concurrency flaws today.
Read more