Bonaventure OgetoBy Bonaventure Ogeto|

Replay Attacks on Paystack Webhooks

Defend against replay attacks by making your webhook handler idempotent. Store every processed transaction reference in your database. Before granting value, check if the reference has already been processed. If it has, return 200 without taking action. This stops both malicious replays and legitimate Paystack retries from causing double processing.

What Is a Webhook Replay Attack

A replay attack is different from a fake webhook attack. In a fake webhook attack, the attacker crafts a new payload and sends it without a valid signature. Signature verification stops this completely.

In a replay attack, the attacker intercepts or captures a real webhook payload that Paystack sent previously. This payload has a valid signature because it was genuinely signed by Paystack. The attacker sends the exact same payload to your server again. Your signature verification passes because the payload has not been modified.

The result: your server processes the same payment event twice. If your webhook handler grants access on charge.success, the attacker gets access twice (or gets a refund processed twice, or gets a subscription extended twice).

How an attacker captures the original webhook:

  • They compromise your server logs and find previously received webhook payloads
  • They intercept network traffic (if your webhook URL uses HTTP instead of HTTPS)
  • They are a malicious insider who received a legitimate payment and captured the webhook
  • They find webhook payloads in your application's debug output or error tracking

Why Signature Verification Alone Is Not Enough

Signature verification answers one question: "Did Paystack send this payload?" For a replayed webhook, the answer is yes. Paystack did send it. The signature is valid. The payload is authentic. It is just being replayed.

You need a second question: "Have I already processed this event?" This is the idempotency check, and it is the defense against replays.

Signature verification and idempotency are complementary defenses:

Attack TypeSignature VerificationIdempotency Check
Fake webhook (forged payload)Blocks itWould also block (unknown reference)
Replay attack (real payload, resent)PassesBlocks it
Paystack retry (real payload, retried)PassesPrevents double processing

You need both. Signature verification without idempotency is vulnerable to replays. Idempotency without signature verification is vulnerable to forged payloads. Together, they cover all attack vectors.

Implementing Idempotent Webhook Processing

The core pattern: use the transaction reference as a unique key. Before processing, check if you have already processed a webhook for that reference. If you have, return 200 immediately without taking action.

var crypto = require('crypto');

// Assuming a database with a 'processed_webhooks' table
// Columns: reference (unique), event_type, processed_at

async function handleWebhook(req, res) {
  // Step 1: Verify signature (see fake webhook attacks guide)
  var secret = process.env.PAYSTACK_SECRET_KEY;
  var signature = req.headers['x-paystack-signature'];
  var hash = crypto
    .createHmac('sha512', secret)
    .update(req.body)
    .digest('hex');

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

  var event = JSON.parse(req.body.toString());
  var reference = event.data.reference;

  // Step 2: Idempotency check
  var existing = await db.query(
    'SELECT reference FROM processed_webhooks WHERE reference = $1',
    [reference]
  );

  if (existing.rows.length > 0) {
    console.log('Webhook already processed for: ' + reference);
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Step 3: Process the event
  if (event.event === 'charge.success') {
    await grantAccess(event.data);
  }

  // Step 4: Record the processed reference
  await db.query(
    'INSERT INTO processed_webhooks (reference, event_type, processed_at) VALUES ($1, $2, NOW())',
    [reference, event.event]
  );

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

Return 200 for duplicates. If you return an error for a duplicate webhook, Paystack may keep retrying a legitimately retried webhook, wasting resources on both sides.

Handling Race Conditions

What if two identical webhooks arrive at the exact same time? Both threads check the database, both find no existing record, and both proceed to process the payment. You have double processing despite the idempotency check.

This is a race condition, and it is a real concern with Paystack retries and replay attacks. Fix it with a database-level uniqueness constraint:

CREATE TABLE processed_webhooks (
  id SERIAL PRIMARY KEY,
  reference VARCHAR(255) UNIQUE NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP DEFAULT NOW()
);

With a UNIQUE constraint on the reference column, the second INSERT will fail with a duplicate key error even if both threads passed the SELECT check. Wrap the insert in a try-catch:

async function processWebhookSafely(event) {
  var reference = event.data.reference;

  try {
    // Try to insert first (atomic operation)
    await db.query(
      'INSERT INTO processed_webhooks (reference, event_type) VALUES ($1, $2)',
      [reference, event.event]
    );
  } catch (err) {
    if (err.code === '23505') {
      // Unique violation: already processed
      console.log('Duplicate webhook caught by constraint: ' + reference);
      return { duplicate: true };
    }
    throw err;
  }

  // Only reaches here if insert succeeded (first processing)
  if (event.event === 'charge.success') {
    await grantAccess(event.data);
  }

  return { duplicate: false };
}

The INSERT-first approach is more reliable than SELECT-then-INSERT because the database enforces atomicity at the constraint level. No race condition can bypass it.

Server-Side Transaction Verification

For high-value transactions, add a third defense layer: verify the transaction through the Paystack API before granting value.

var https = require('https');

function verifyWithPaystack(reference, expectedAmount, 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);

      if (!result.status) {
        return callback(new Error('Verification failed'));
      }

      if (result.data.status !== 'success') {
        return callback(new Error('Transaction not successful'));
      }

      if (result.data.amount !== expectedAmount) {
        return callback(new Error('Amount mismatch'));
      }

      callback(null, result.data);
    });
  });

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

This API call confirms the transaction is real, successful, and for the expected amount. Even if an attacker somehow replays a webhook past your idempotency check, the Paystack API returns the canonical state of the transaction.

Distinguishing Paystack Retries from Replay Attacks

Paystack retries webhooks when your server does not respond with a 2xx status code. This is normal and expected behavior. Your idempotency logic should handle retries gracefully without treating them as attacks.

Differences between retries and attacks:

PropertyPaystack RetryReplay Attack
Source IPPaystack serversAttacker's IP
TimingMinutes to hours after originalAny time
SignatureValidValid (same payload)
PayloadIdentical to originalIdentical to original

Your code should handle both the same way: check if the reference has been processed, and if so, return 200 without re-processing. The distinction matters for logging and alerting, not for code logic.

If you want to detect potential replay attacks (as opposed to Paystack retries), log the source IP of webhook requests and alert if you see webhooks for already-processed references coming from non-Paystack IP addresses.

Cleanup and Maintenance

The processed_webhooks table grows over time. You can safely delete entries older than Paystack's retry window (typically 72 hours), but many teams keep them longer for audit purposes.

-- Delete entries older than 90 days
DELETE FROM processed_webhooks
WHERE processed_at < NOW() - INTERVAL '90 days';

If you keep entries for audit purposes, add an index on the reference column for fast lookups:

CREATE INDEX idx_processed_webhooks_reference
ON processed_webhooks (reference);

Monitor the table size and query performance. For high-volume integrations processing thousands of transactions per day, consider partitioning by date or archiving old records to a separate table.

Key Takeaways

  • A replay attack sends a previously captured legitimate webhook to your server. The signature is valid because the payload is unchanged.
  • Without idempotency checks, a replayed webhook causes double processing: double credits, double access grants, or double product shipments.
  • Store every processed transaction reference in your database. Before processing, check if the reference exists. If it does, skip processing and return 200.
  • Paystack itself retries webhooks when your server does not respond with 200. Your idempotency logic handles both malicious replays and legitimate retries.
  • Signature verification alone does not stop replay attacks. The replayed payload has a valid signature.
  • Server-side transaction verification via the Paystack API provides an additional check: confirm the transaction status and amount before granting value.

Frequently Asked Questions

Can I use an in-memory store (like a Set) instead of a database for idempotency?
Only if you have a single server process that never restarts. An in-memory store loses all data on restart, allowing replays of previously processed webhooks. A database is the reliable choice.
What if I use Redis for idempotency tracking?
Redis works well for this purpose. Use SETNX (set if not exists) for atomic deduplication. Set a TTL of 72 hours or more to cover the Paystack retry window. Be aware that Redis data is lost on restart unless you have persistence configured.
Should I block requests from non-Paystack IP addresses?
IP allowlisting adds a layer of defense but should not be your only protection. Paystack IP addresses can change, and you need to maintain the allowlist. Signature verification plus idempotency is the primary defense. IP filtering is optional hardening.
How many times does Paystack retry a failed webhook?
Paystack retries with exponential backoff over a 72-hour period. The exact number of retries and intervals may vary. Design your webhook handler to be idempotent regardless of how many times a webhook is delivered.
What if I need to reprocess a webhook intentionally?
If you need to reprocess a legitimate webhook (for example, after fixing a bug in your handler), delete the corresponding entry from your processed_webhooks table and trigger a retry from the Paystack dashboard, or call the transaction verify endpoint directly.

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