All posts
25 June 202618 min read

Webhook Security Best Practices for Nigerian Payment Integrations: What Most Developers Get Wrong

The majority of Nigerian payment integrations have at least one critical webhook security vulnerability — and most developers do not know it. This guide covers HMAC verification, replay attack prevention, idempotency, and the specific mistakes that leave your Paystack, Flutterwave, and Monnify integrations exposed.

Webhook Security Best Practices for Nigerian Payment Integrations: What Most Developers Get Wrong

A webhook endpoint that processes Paystack payment events is, functionally, a publicly accessible URL that — when called correctly — triggers your application to record revenue, fulfil an order, upgrade a subscription, or release digital goods.

Read that again. A public URL that triggers financial operations.

The security implication is obvious once you say it out loud. Yet most Nigerian payment integrations are implemented with one or more vulnerabilities that would allow an attacker to trigger those operations without ever making a real payment. The attacker does not need your Paystack secret key. They just need to know your webhook URL and understand your application logic well enough to craft a convincing fake payload.

This guide covers every layer of webhook security — from signature verification to idempotency to infrastructure hardening — with concrete implementation examples for Paystack, Flutterwave, and Monnify. If you have a payment integration in production, read this before you do anything else today.

The Threat Model

Before getting into implementation, it is worth being precise about what you are defending against.

Attack 1: Fake webhook injection An attacker sends a crafted POST request to your webhook URL that looks like a legitimate payment event. If you process it, you fulfil an order without receiving payment.

Attack 2: Replay attacks An attacker captures a legitimate webhook event — perhaps by intercepting it in transit or obtaining a previous event payload — and resends it. If you process it twice, you fulfil an order twice, credit an account twice, or unlock access twice.

Attack 3: Payload tampering An attacker intercepts a legitimate webhook and modifies the amount, customer email, or order reference before it reaches your server. If you do not verify the signature, you process a modified event.

Attack 4: Race condition exploitation An attacker sends multiple concurrent requests for the same event, hoping that your application processes it before your duplicate detection kicks in.

All four attacks are prevented by a combination of signature verification, timestamp validation, idempotency, and proper concurrency handling. None of them require sophisticated tooling on the attacker's side — they require only a basic understanding of HTTP.

Layer 1 — Signature Verification

Every major Nigerian payment gateway signs its webhook payloads. Verifying the signature is the foundational security measure — everything else builds on top of it.

Paystack — HMAC-SHA512

Paystack signs the raw request body using HMAC-SHA512 with your secret key. The signature arrives in the x-paystack-signature header.

code
import crypto from "crypto";
 
function verifyPaystackSignature(
  rawBody: string,
  signature: string,
  secretKey: string,
): boolean {
  // Validate signature format before running crypto
  if (!signature || !/^[0-9a-f]+$/i.test(signature)) {
    return false;
  }
 
  const expected = crypto
    .createHmac("sha512", secretKey)
    .update(rawBody)      // ← raw string, NOT JSON.parse(rawBody)
    .digest("hex");
 
  // Timing-safe comparison prevents timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signature, "hex"),
    );
  } catch {
    // Buffers of different lengths throw — signature is invalid
    return false;
  }
}

Two critical details in this implementation:

Raw body, not parsed JSON. If you parse the body with JSON.parse and then stringify it again before computing the hash, you will get a different hash than Paystack computed. JSON serialisation is not deterministic — key ordering, whitespace, and number formatting can all differ between implementations. Read the body as a raw string with req.text() and pass that string directly to the HMAC function.

Timing-safe comparison. A naive string comparison (expected === signature) is vulnerable to timing attacks where an attacker can infer how many characters of their guess are correct by measuring response time differences. crypto.timingSafeEqual takes constant time regardless of where the strings diverge.

Flutterwave — Secret Hash Comparison

Flutterwave uses a different approach. Instead of HMAC, they send your webhook secret verbatim in the verif-hash header. You compare this header value against your configured secret.

code
function verifyFlutterwaveSignature(
  secretHash: string | null,
  expectedSecret: string,
): boolean {
  if (!secretHash) return false;
 
  // Still use timingSafeEqual even for plain comparison
  try {
    return crypto.timingSafeEqual(
      Buffer.from(secretHash.trim()),
      Buffer.from(expectedSecret.trim()),
    );
  } catch {
    return false;
  }
}

Flutterwave's approach is simpler but weaker — it does not cryptographically bind the signature to the payload content. An attacker who knows your verif-hash value (perhaps from a source code leak or a compromised environment variable) can craft any payload and it will pass verification. With Paystack's HMAC approach, knowing the signature for one payload tells you nothing about the signature for a different payload.

This means Flutterwave integrations need to be especially careful about protecting the secret hash value and verifying payload content independently.

Monnify — HMAC-SHA512 with Different Header

Monnify uses HMAC-SHA512 like Paystack but sends the signature in a different header (monnify-signature) and may use a different field from your API credentials as the signing key.

code
function verifyMonnifySignature(
  rawBody: string,
  signature: string | null,
  apiKey: string,
): boolean {
  if (!signature || !/^[0-9a-f]+$/i.test(signature)) {
    return false;
  }
 
  const expected = crypto
    .createHmac("sha512", apiKey)
    .update(rawBody)
    .digest("hex");
 
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signature, "hex"),
    );
  } catch {
    return false;
  }
}

Verify which credential Monnify uses as the signing key in their current documentation — this has changed between API versions.

What to Return on Signature Failure

This is where many implementations make a mistake. When signature verification fails, they return 401 Unauthorized or 403 Forbidden. This is the wrong response for two reasons:

First, it tells an attacker that their payload structure was correct but their signature was wrong — useful information for refining an attack.

Second, it causes the payment gateway to retry the event. When Paystack receives a non-200 response, it assumes delivery failed and will retry with increasing delays. An attacker who sends enough fake events can trigger retry storms that consume your server capacity.

The correct response to a failed signature is 200 OK with a body indicating the event was received but not processed:

code
if (!isValid) {
  // Do not reveal WHY we rejected — just acknowledge receipt
  return res.status(200).json({ received: false });
}

Return 503 Service Unavailable only for genuine infrastructure failures — database down, queue unavailable — where you actually want the gateway to retry.

Layer 2 — Read the Body Before Anything Else

A webhook handler should read the raw request body as its very first action, before any middleware touches it.

The common mistake is letting an Express or Next.js JSON body parser run before your webhook handler. Body parsers parse JSON and discard the original string. By the time your handler runs, the raw body is gone — you cannot compute the HMAC because you no longer have the exact bytes that were signed.

code
// WRONG — JSON middleware runs first, raw body is gone
app.use(express.json());
app.post("/webhook/paystack", (req, res) => {
  const rawBody = ???; // too late — it's been parsed
});
 
// CORRECT — read raw body in the handler
app.post(
  "/webhook/paystack",
  express.raw({ type: "application/json" }),  // ← raw, not json
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const signature = req.headers["x-paystack-signature"] as string;
    // Now you can verify
  },
);

In Next.js App Router, req.text() gives you the raw body directly:

code
export async function POST(req: NextRequest) {
  const rawBody = await req.text();  // ← always call this first
  const signature = req.headers.get("x-paystack-signature") ?? "";
  // Verify before parsing
  const body = JSON.parse(rawBody);
}

Also validate the body size before reading it. A webhook payload is never legitimately 50MB. Set an upper bound and reject oversized requests before you read them:

code
const rawBody = await req.text();
 
if (rawBody.length > 2 * 1024 * 1024) { // 2MB limit
  return NextResponse.json({ error: "Payload too large" }, { status: 413 });
}

Layer 3 — Replay Attack Prevention

Signature verification alone does not prevent replay attacks. A legitimate webhook event has a valid signature — if an attacker captures it and resends it, the signature is still valid.

Timestamp Validation

Paystack and some gateways include a timestamp in the payload. Reject events with timestamps more than five minutes in the past:

code
const eventTimestamp = new Date(body.data?.paid_at ?? body.data?.created_at);
const now = new Date();
const ageMs = now.getTime() - eventTimestamp.getTime();
 
const FIVE_MINUTES_MS = 5 * 60 * 1000;
 
if (ageMs > FIVE_MINUTES_MS) {
  console.warn(`[webhook] Stale event rejected: ${ageMs}ms old`);
  return NextResponse.json({ received: true, status: "ignored" }, { status: 200 });
}

This window needs to account for legitimate delays — a webhook from Paystack may take 30–60 seconds to arrive during high traffic. Five minutes is a reasonable window that blocks replay attacks while allowing legitimate delayed deliveries.

Idempotency — The More Robust Defence

Timestamp validation has a gap: it does not prevent an attacker from replaying an event within the five-minute window. The more robust defence is idempotency — ensuring that processing the same event twice produces the same result as processing it once.

For payment webhooks, idempotency means storing the transaction reference and rejecting any duplicate:

code
// Check before processing
const existing = await transactionsRepository.findByReference(
  bridgeId,
  body.data.reference,
);
 
if (existing) {
  console.log(`[webhook] Duplicate reference ${body.data.reference} — ignored`);
  return NextResponse.json(
    { received: true, status: "ignored" },
    { status: 200 },
  );
}
 
// Process and store atomically
const transaction = await transactionsRepository.create({
  reference: body.data.reference,
  // ...
});

The reference field is your idempotency key. Every payment event from every Nigerian gateway includes a unique reference. If you have already processed a reference, you do not process it again — full stop.

We use a partial unique index in PostgreSQL to enforce this at the database level as a backstop:

code
CREATE UNIQUE INDEX unq_bridge_reference_success
ON transactions (bridge_id, reference)
WHERE status = 'success';

Even if your application-level duplicate check has a race condition, the database constraint will reject the second insert with a conflict error rather than creating a duplicate record.

Layer 4 — Handling Race Conditions

Race conditions on webhook endpoints are more common than developers expect. Paystack sometimes sends the same event multiple times in rapid succession — particularly if your initial response was slow and their timeout fired before you returned 200. The result is two near-simultaneous requests for the same event reaching your server.

With only an application-level duplicate check, both requests can pass the check (before either has written to the database) and both attempt to create the transaction record. One succeeds. The other either fails silently or creates a duplicate.

The correct defence is a combination of the database-level unique constraint above plus careful error handling:

code
try {
  const transaction = await transactionsRepository.create({
    reference: body.data.reference,
    // ...
  });
 
  if (!transaction) {
    // onConflictDoNothing returned null — duplicate was blocked at DB level
    return NextResponse.json(
      { received: true, status: "ignored" },
      { status: 200 },
    );
  }
 
  // Continue processing
} catch (err: any) {
  // Unique constraint violation — another request beat us to it
  if (err?.code === "23505") { // PostgreSQL unique violation
    return NextResponse.json(
      { received: true, status: "ignored" },
      { status: 200 },
    );
  }
  throw err; // Re-throw genuine errors
}

The 23505 error code is PostgreSQL's unique constraint violation. Catching it explicitly means a race condition produces an ignored duplicate rather than an unhandled error.

Layer 5 — Rate Limiting

A public webhook URL without rate limiting is an invitation for denial-of-service attacks. An attacker who discovers your webhook URL can send thousands of requests per second, consuming server resources even if every request fails signature verification.

Rate limiting per webhook slug (or per IP) prevents this:

code
import { checkRateLimit } from "@/lib/rate-limiter";
 
// Before reading the body or verifying the signature
const rateResult = await checkRateLimit(
  `webhook:${slug}`,
  10_000,          // 10k events per day per bridge
  60 * 60 * 24,    // 24-hour window
);
 
if (!rateResult.allowed) {
  return NextResponse.json(
    { received: false, error: "Rate limit exceeded" },
    {
      status: 429,
      headers: {
        "Retry-After": String(
          Math.ceil((rateResult.resetAt.getTime() - Date.now()) / 1000),
        ),
      },
    },
  );
}

10,000 events per day is generous for any legitimate bridge — most merchants see fewer than 1,000 webhook events per day even at significant volume. If you are regularly approaching this limit, increase it; the default should be high enough to never affect legitimate traffic.

Rate limit by slug rather than by IP because payment gateways often use multiple IP addresses for webhook delivery and those IPs change over time. Rate limiting by gateway IP would require maintaining an allowlist that goes stale.

Layer 6 — Only Accept Expected Event Types

Your webhook handler should explicitly check that it recognises the event type before doing anything with the payload:

code
const HANDLED_PAYSTACK_EVENTS = new Set([
  "charge.success",
  "charge.dispute.create",
  "transfer.reversed",
  "subscription.create",
  "subscription.charge.success",
  "subscription.not_renew",
  "subscription.disable",
]);
 
const eventType = body.event as string;
 
if (!HANDLED_PAYSTACK_EVENTS.has(eventType)) {
  // Acknowledge but do not process unknown event types
  return NextResponse.json(
    { received: true, status: "ignored", reason: "Unhandled event type" },
    { status: 200 },
  );
}

Returning 200 for unrecognised events prevents Paystack from retrying them indefinitely. Explicitly checking the allowlist rather than using a switch-default pattern means a new event type Paystack adds in the future does not accidentally fall through to some processing logic.

Layer 7 — Sanitise What You Store

Webhook payloads can contain sensitive data that you should not store verbatim. Paystack's charge.success payload includes:

  • authorization.authorization_code — can be used for future charges on the card
  • authorization.last4 — partial card number
  • authorization.bin — card BIN, useful for fraud analysis but also sensitive
  • customer.ip_address — customer's IP at time of payment
  • log — a full array of the customer's actions during the checkout session

Storing raw webhook payloads in your database is a PCI-DSS concern. If your database is compromised, attackers have access to authorization codes that can be used to charge your customers' cards again.

Sanitise the payload before storing it in your webhook logs:

code
function sanitizeWebhookPayload(payload: Record<string, any>): Record<string, any> {
  const sanitized = { ...payload };
 
  if (sanitized.data?.authorization) {
    // Remove the reusable authorization code
    delete sanitized.data.authorization.authorization_code;
    // Keep last4 and bin for reference but mask them if extra caution needed
  }
 
  if (sanitized.data?.customer) {
    // Remove IP address — NDPA PII
    delete sanitized.data.customer.ip_address;
  }
 
  // Remove the full action log — large and unnecessary
  if (sanitized.data?.log) {
    sanitized.data.log = `[${sanitized.data.log.length} entries — redacted]`;
  }
 
  return sanitized;
}

Store the sanitised version in your webhook_logs table. The original payload should never touch your database.

Layer 8 — HTTPS Everywhere, Always

Your webhook endpoint must be served over HTTPS. HTTP webhook endpoints are vulnerable to man-in-the-middle attacks where an attacker intercepts the payload in transit, modifies it, and forwards it to your server. If you do not also verify the signature, you will process the tampered payload.

Vercel, Railway, Render, and all modern deployment platforms provide HTTPS by default on their subdomains. If you use a custom domain, confirm that your TLS certificate is valid and that HTTP requests are redirected to HTTPS (not just available on HTTPS).

Do not accept webhook events on HTTP endpoints, even in development. Use a tunnelling tool like ngrok or cloudflared to expose your local development server over HTTPS:

code
# ngrok — creates a temporary HTTPS URL pointing to your local server
ngrok http 3000
 
# cloudflared — similar, from Cloudflare
cloudflare tunnel --url http://localhost:3000

Register the HTTPS URL ngrok or cloudflared provides in your Paystack test dashboard. This gives you a realistic test environment where signature verification actually works the same way it will in production.

Putting It Together — A Complete Webhook Handler

Here is a complete, production-grade webhook handler for Paystack that implements every layer discussed above:

code
// app/api/webhook/[slug]/route.ts
import { type NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { checkRateLimit }        from "@/lib/rate-limiter";
import { transactionService }    from "@repo/core";
import { bridgesRepository }     from "@repo/db";
 
export const runtime = "nodejs"; // crypto.createHmac requires Node.js
 
const HANDLED_EVENTS = new Set([
  "charge.success",
  "charge.dispute.create",
  "transfer.reversed",
]);
 
export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
 
  // Layer 5 — Rate limiting (before reading body)
  const rateResult = await checkRateLimit(`webhook:${slug}`, 10_000, 86400);
  if (!rateResult.allowed) {
    return NextResponse.json(
      { received: false },
      { status: 429, headers: { "Retry-After": "3600" } },
    );
  }
 
  // Layer 2 — Read raw body first, before any parsing
  const rawBody = await req.text();
 
  // Size guard
  if (rawBody.length > 2 * 1024 * 1024) {
    return NextResponse.json({ error: "Payload too large" }, { status: 413 });
  }
 
  // Extract only known signature headers
  const signature = req.headers.get("x-paystack-signature") ?? "";
 
  // Layer 1 — Signature verification
  const bridge = await bridgesRepository.findBySlug(slug);
  if (!bridge) {
    // Return 200 — do not reveal that the slug does not exist
    return NextResponse.json({ received: false }, { status: 200 });
  }
 
  const secretKey = decryptProviderSecret(bridge.providerSecretEncrypted);
  const isValid   = verifyPaystackSignature(rawBody, signature, secretKey);
 
  if (!isValid) {
    // Layer 1 — Return 200 on invalid signature to prevent retry storms
    console.warn(`[webhook] Invalid signature for slug: ${slug}`);
    return NextResponse.json({ received: false }, { status: 200 });
  }
 
  // Parse only after verification
  const body = JSON.parse(rawBody) as Record<string, any>;
 
  // Layer 6 — Event type allowlist
  if (!HANDLED_EVENTS.has(body.event)) {
    return NextResponse.json(
      { received: true, status: "ignored" },
      { status: 200 },
    );
  }
 
  // Return 200 immediately — processing happens asynchronously
  // This prevents gateway timeouts from causing retries
  const response = NextResponse.json(
    { received: true, status: "queued" },
    { status: 200 },
  );
 
  // Layer 3 & 4 — Idempotency and race condition handling
  // are enforced inside transactionService.handleIncomingWebhook
  // via the database-level unique constraint
  try {
    await transactionService.handleIncomingWebhook(slug, rawBody, {
      "x-paystack-signature": signature,
    });
  } catch (err) {
    // Log but do not change the response — we already returned 200
    console.error(`[webhook] Processing error for slug ${slug}:`, err);
  }
 
  return response;
}
 
function verifyPaystackSignature(
  rawBody: string,
  signature: string,
  secretKey: string,
): boolean {
  if (!signature || !/^[0-9a-f]+$/i.test(signature)) return false;
 
  const expected = crypto
    .createHmac("sha512", secretKey)
    .update(rawBody)
    .digest("hex");
 
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signature, "hex"),
    );
  } catch {
    return false;
  }
}

Common Vulnerabilities in Nigerian Payment Integrations

To close, here is a list of the specific vulnerabilities we most commonly see when reviewing Nigerian payment integrations. Check your own implementation against each one.

Verifying the signature on a parsed body. As described above — JSON.stringify(JSON.parse(rawBody)) does not reproduce the original bytes. The HMAC will not match even for legitimate events, causing you to either reject all events or (worse) skip verification entirely when you see it failing.

Returning 401 or 403 on signature failure. Causes the gateway to retry. An attacker who triggers enough of these can exhaust your server with retry traffic.

Checking only the event type, not the status field. charge.success events can have a status field of failed or abandoned inside the payload. Always check data.status === "success" in addition to event === "charge.success".

Trusting the amount in the webhook payload without verification. For critical operations — unlocking high-value digital goods, topping up balances — verify the amount against what you expected from the specific order. Do not fulfil a ₦100,000 order because a webhook claims a ₦100 payment was made with event: "charge.success".

Storing raw payloads including authorization codes. As described in Layer 7 — sanitise before storing.

Running verification in a middleware that only fires on some routes. If your signature verification middleware has exceptions or can be bypassed, those exceptions are your vulnerabilities.

Using a test secret key in production. Test and live keys are different. A test key will successfully verify test-mode webhooks but not live-mode webhooks — and vice versa. Confirm your environment variables contain the correct key for each environment.

No monitoring on webhook delivery rate. If your Paystack dashboard shows 95% of webhooks failing, you have a problem — but you will only know if you are monitoring. Set up alerts on your webhook endpoint's error rate.

The security posture of your webhook endpoint is a direct reflection of the security posture of your business finances. An insecure endpoint is not a theoretical risk — it is an open door to anyone who wants to trigger your order fulfilment system without paying. Closing that door takes an afternoon. Dealing with the consequences of leaving it open can take much longer.


KoboSync implements all eight security layers described in this guide on every webhook endpoint it creates — HMAC verification, timing-safe comparison, idempotency via database constraints, rate limiting, payload sanitisation, and more. Every bridge you connect is secure by default. See how it works →