Bonaventure OgetoBy Bonaventure Ogeto|

Fake Webhook Attacks and How Signature Checks Stop Them

A fake webhook attack sends a crafted JSON payload to your webhook URL with event type charge.success and a fabricated transaction reference. If your handler trusts the payload without checking the x-paystack-signature header, it grants value for a payment that never happened. Defend by computing the HMAC-SHA512 hash of the raw request body using your secret key and comparing it to the signature header. Reject any request where they do not match.

How a Fake Webhook Attack Works

Your webhook URL is an HTTP endpoint that receives POST requests from Paystack when events happen (payment completed, transfer successful, subscription renewed). The body of the request is a JSON object describing the event.

An attacker discovers or guesses your webhook URL. Then they send a POST request that looks exactly like a legitimate Paystack webhook:

// What the attacker sends to your webhook URL
{
  "event": "charge.success",
  "data": {
    "id": 999999999,
    "reference": "fake_ref_abc123",
    "amount": 500000,
    "currency": "NGN",
    "status": "success",
    "customer": {
      "email": "attacker@example.com"
    }
  }
}

If your webhook handler blindly trusts this payload, it marks the order as paid, grants access, ships the product, or activates the subscription. The attacker gets value without paying.

The attack is trivial to execute. Any tool that can send HTTP requests (curl, Postman, a simple script) can do it. The attacker does not need your API keys, database access, or any special skill. They just need your webhook URL and a correctly shaped JSON body.

How HMAC-SHA512 Signatures Work

Every legitimate webhook from Paystack includes an x-paystack-signature header. This header contains the HMAC-SHA512 hash of the request body, computed using your Paystack secret key.

HMAC (Hash-based Message Authentication Code) works like this:

  1. Paystack takes the raw JSON body of the webhook request
  2. Paystack computes the SHA512 hash of that body using your secret key as the HMAC key
  3. Paystack sends the resulting hex string in the x-paystack-signature header

On your server:

  1. You take the raw body of the incoming request
  2. You compute the SHA512 hash using your secret key
  3. You compare your computed hash with the value in the x-paystack-signature header
  4. If they match, the request is authentic. If they do not match, the request is forged.

The security relies on the fact that only Paystack and your server know the secret key. An attacker who does not have the secret key cannot compute the correct hash, so they cannot produce a valid signature.

Implementing Signature Verification in Node.js

Here is the complete implementation for an Express.js webhook handler:

var crypto = require('crypto');
var express = require('express');
var app = express();

// CRITICAL: Use raw body, not parsed JSON
app.post('/webhook/paystack',
  express.raw({ type: 'application/json' }),
  function(req, res) {
    var secret = process.env.PAYSTACK_SECRET_KEY;
    var signature = req.headers['x-paystack-signature'];

    if (!signature) {
      console.log('Webhook rejected: no signature header');
      return res.status(400).json({ error: 'No signature' });
    }

    var hash = crypto
      .createHmac('sha512', secret)
      .update(req.body)
      .digest('hex');

    if (hash !== signature) {
      console.log('Webhook rejected: invalid signature');
      return res.status(400).json({ error: 'Invalid signature' });
    }

    // Signature is valid. Parse the body and process the event.
    var event = JSON.parse(req.body.toString());
    console.log('Webhook verified: ' + event.event);

    // Process the event
    if (event.event === 'charge.success') {
      handleChargeSuccess(event.data);
    }

    res.status(200).json({ received: true });
  }
);

Key details:

  • express.raw() gives you the raw request body as a Buffer. This is essential. If you use express.json(), Express parses the JSON and you lose the exact byte sequence.
  • The .update(req.body) call takes the raw Buffer. Do not convert to string or parse to JSON before hashing.
  • Compare the full hex string. Do not compare only a prefix or use a case-insensitive comparison.

Common Signature Verification Mistakes

Developers make these mistakes repeatedly, each of which breaks the signature check and leaves the door open to fake webhooks:

Mistake 1: Using Parsed JSON Instead of Raw Body

// WRONG: parsing first, then re-stringifying
app.post('/webhook', express.json(), function(req, res) {
  var hash = crypto
    .createHmac('sha512', secret)
    .update(JSON.stringify(req.body)) // re-serialized, different bytes
    .digest('hex');
});

JSON.stringify may change whitespace, key order, or Unicode escaping compared to the original body. The hash will not match even for legitimate webhooks. Always hash the raw body.

Mistake 2: Skipping Verification "Temporarily"

// WRONG: "will add verification later"
app.post('/webhook', function(req, res) {
  // TODO: verify signature
  handlePayment(req.body.data);
  res.status(200).send('ok');
});

"Temporarily" becomes permanently in many codebases. Never ship a webhook handler without signature verification, not even to staging.

Mistake 3: Timing-Vulnerable Comparison

// VULNERABLE: standard string comparison leaks timing information
if (hash === signature) { ... }

In theory, standard string comparison can leak information about how many characters match through timing differences. For webhook verification with a 128-character hex string, the practical risk is very low, but you can use crypto.timingSafeEqual for defense in depth:

var hashBuffer = Buffer.from(hash, 'hex');
var sigBuffer = Buffer.from(signature, 'hex');
var isValid = hashBuffer.length === sigBuffer.length
  && crypto.timingSafeEqual(hashBuffer, sigBuffer);

Mistake 4: Not Checking for Missing Header

If the x-paystack-signature header is missing, your hash comparison will compare against undefined, which may pass in some languages. Always check that the header exists before comparing.

Defense in Depth: Verify the Transaction Too

Signature verification proves the webhook came from Paystack. But for critical operations (granting access, shipping products, activating subscriptions), add a second verification step: call the Paystack API to confirm the transaction.

var https = require('https');

function verifyTransaction(reference, callback) {
  var options = {
    hostname: 'api.paystack.co',
    path: '/transaction/verify/' + reference,
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
    },
  };

  var req = https.request(options, function(res) {
    var data = '';
    res.on('data', function(chunk) { data += chunk; });
    res.on('end', function() {
      var result = JSON.parse(data);
      callback(null, result);
    });
  });

  req.on('error', function(err) { callback(err); });
  req.end();
}

function handleChargeSuccess(webhookData) {
  verifyTransaction(webhookData.reference, function(err, result) {
    if (err) {
      console.error('Verification API call failed:', err.message);
      return;
    }

    if (!result.status || result.data.status !== 'success') {
      console.error('Transaction verification failed for: ' + webhookData.reference);
      return;
    }

    // Both checks passed. Safe to grant value.
    grantAccess(result.data);
  });
}

This second check catches scenarios that signature verification alone does not cover:

  • A webhook that was legitimately sent but for a transaction that was later reversed
  • An edge case where the webhook data is stale or incomplete
  • Amount verification: confirm the amount in the API response matches what you expected

Protecting Your Webhook URL

Signature verification is your primary defense, but you should also minimize exposure of your webhook URL:

  • Use a non-guessable path. Instead of /webhook or /paystack-webhook, use something like /hooks/ps-v2-a7f3e9. This does not provide security (that comes from signatures), but it reduces noise from scanners.
  • Restrict HTTP methods. Your webhook endpoint should only accept POST requests. Return 405 Method Not Allowed for GET, PUT, DELETE, etc.
  • IP allowlisting (optional). Paystack publishes the IP addresses their webhooks originate from. You can allowlist these IPs at the firewall or load balancer level for an additional layer of filtering. However, IP addresses can change, so maintain this carefully.
  • Rate limiting. Apply rate limiting to your webhook endpoint to prevent denial-of-service through webhook flooding. A legitimate Paystack integration receives at most a few webhooks per second.

Testing Your Signature Verification

Write tests that confirm your webhook handler rejects invalid signatures. Here is a Jest test:

var crypto = require('crypto');
var request = require('supertest');
var app = require('../app');

var SECRET = 'sk_test_xxxxxxxxxxxxxxxxxxxx';

function sign(body) {
  return crypto
    .createHmac('sha512', SECRET)
    .update(body)
    .digest('hex');
}

describe('Webhook signature verification', function() {
  it('accepts valid signatures', function(done) {
    var body = JSON.stringify({
      event: 'charge.success',
      data: { reference: 'test_ref_123', status: 'success', amount: 50000 }
    });

    request(app)
      .post('/webhook/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', sign(body))
      .send(body)
      .expect(200, done);
  });

  it('rejects missing signatures', function(done) {
    request(app)
      .post('/webhook/paystack')
      .set('Content-Type', 'application/json')
      .send('{"event":"charge.success"}')
      .expect(400, done);
  });

  it('rejects invalid signatures', function(done) {
    request(app)
      .post('/webhook/paystack')
      .set('Content-Type', 'application/json')
      .set('x-paystack-signature', 'invalid_signature_value')
      .send('{"event":"charge.success"}')
      .expect(400, done);
  });
});

These tests should be part of your CI pipeline. See contract testing your Paystack webhook handler for comprehensive webhook testing strategies.

Key Takeaways

  • Fake webhook attacks are one of the most common attacks on Paystack integrations. The attacker sends a forged charge.success payload to trick your server into granting value.
  • The x-paystack-signature header contains an HMAC-SHA512 hash of the request body, computed using your secret key. Only Paystack and your server know the secret key, so only Paystack can produce a valid signature.
  • Always verify the signature before processing any webhook event. Compare the hash you compute against the header value. Reject mismatches with a 400 response.
  • Even with valid signatures, always verify the transaction server-side by calling the Paystack /transaction/verify endpoint. This catches edge cases that signature verification alone cannot.
  • Never log or expose your webhook URL publicly. While signature verification protects you, an exposed URL invites unnecessary traffic.
  • Use the raw request body for signature computation, not a parsed and re-serialized JSON object. Parsing and re-serializing can change the byte sequence and break the hash.

Frequently Asked Questions

What happens if I do not verify webhook signatures?
Anyone who knows your webhook URL can send fake payloads and trick your server into granting value for payments that never happened. This is one of the most exploited vulnerabilities in payment integrations.
Can an attacker figure out my secret key from the signature?
No. HMAC-SHA512 is a one-way function. Even with the signature and the original message, computing the key is computationally infeasible. Your secret key remains safe as long as you do not expose it through other means (git commits, logs, client-side code).
Does Paystack retry webhooks if my server returns an error?
Yes. Paystack retries failed webhooks with exponential backoff. If your signature verification rejects a forged webhook with a 400 response, Paystack does not retry it because it was not a real webhook. Only genuine Paystack webhooks get retried.
Should I verify the signature in middleware or in the handler?
Either works. Middleware is cleaner because it separates verification from business logic and applies to all webhook routes automatically. The important thing is that verification happens before any business logic processes the event.
My signature verification fails for legitimate webhooks. What is wrong?
The most common cause is using a parsed and re-serialized body instead of the raw request body. Make sure you hash the exact bytes Paystack sent, not a JSON.stringify of the parsed object. Also verify you are using the correct secret key (test key for test webhooks, live key for live webhooks).

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon