guides

How to Secure Your M-Pesa Callback Endpoint Against Spoofed Requests

How to Secure Your M-Pesa Callback Endpoint Against Spoofed Requests
Illustration by TechInKenya

In Part 1 of this series, we built a working STK Push integration from scratch. In Part 2 , we handled how to send money from your M-Pesa Business Account to any phone using the Daraja B2C API. we handled In Part 3, we tackled the hard part: handling timeouts, disconnections, and keeping transaction state intact even when Safaricom's callback never arrives. If you have read all of them, you now have a resilient payment flow that can survive real-world network failures.

There is one more piece that most local developers do not think about until something goes wrong: what happens when someone deliberately sends a fake callback to your endpoint?

This is not a theoretical problem. The Daraja callback URL is a public HTTPS endpoint that accepts POST requests. The payload structure it expects is fully documented in Safaricom's public API docs. Anyone who knows your callback URL can craft a request body that looks exactly like a legitimate M-Pesa success notification and POST it to your server. If your handler does not verify where the request came from, it will process that fake callback as a real payment, fulfil the order, and your business takes the loss.

That attack vector is simple enough that it does not require technical sophistication to exploit. A junior developer who has read the Daraja docs once knows enough to construct a valid-looking callback body. On a subscription platform or a digital goods store where fulfilment is automated, a few spoofed requests can cause real financial damage before anyone notices.

This article covers everything you need to lock down your callback endpoint properly: understanding the attack surface, setting up IP allowlisting using Safaricom's documented IP ranges, implementing a secret token in the URL itself, adding payload structure validation, and combining all three into a layered middleware approach that handles each concern cleanly.

All examples continue from the Node.js project in the previous articles.

Understanding the Attack Surface

Before writing any defence code, it helps to think clearly about what an attacker is actually doing.

Your callback endpoint at something like https://yourapp.com/api/mpesa/callback receives a POST request from Safaricom whenever a transaction resolves. The body looks like this for a successful payment:

json
{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 500 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20241101102115 },
          { "Name": "PhoneNumber", "Value": 254708920430 }
        ]
      }
    }
  }
}

This structure is publicly documented. Anyone can copy it, swap in any CheckoutRequestID they want, set ResultCode to 0, and POST it to your server. If your callback handler simply reads ResultCode, finds it is 0, and marks the corresponding order as paid, you have a serious vulnerability.

The second problem is that your callback URL path may not stay secret for long. It could leak through browser developer tools, server logs shared with a third party, a disgruntled employee, or simply by being predictable. A URL like /api/mpesa/callback is something an attacker would try on the first guess.

The third problem is that even if your URL is obscure, if you are only checking the URL and not the payload or the source IP, an attacker who discovers it can replay the same attack indefinitely.

The defence strategy uses three independent layers that each catch a different vector. No single layer is sufficient on its own, but together they make your callback endpoint significantly harder to abuse.

Layer 1: IP Allowlisting

The most fundamental check is verifying that the request originated from Safaricom's servers. Safaricom documents a specific list of IP addresses from which all Daraja callbacks are sent. Any request coming from an IP not on that list should be rejected before your handler does any processing.

The IP addresses provided by Safaricom in the M-Pesa documentation are:

  • 196.201.214.200

  • 196.201.214.206

  • 196.201.213.114

  • 196.201.214.207

  • 196.201.214.208

  • 196.201.213.44

  • 196.201.212.127

  • 196.201.212.138

  • 196.201.212.129

  • 196.201.212.136

  • 196.201.212.74

  • 196.201.212.69

Here is how to implement this as Express middleware:

javascript
// middleware/ipAllowlist.js

const SAFARICOM_IPS = [
  "196.201.214.200",
  "196.201.214.206",
  "196.201.213.114",
  "196.201.214.207",
  "196.201.214.208",
  "196.201.213.44",
  "196.201.212.127",
  "196.201.212.138",
  "196.201.212.129",
  "196.201.212.136",
  "196.201.212.74",
  "196.201.212.69",
];

const ipAllowlist = (req, res, next) => {
  // x-forwarded-for is set by proxies and load balancers.
  // If you are behind a reverse proxy (nginx, Cloudflare, etc.),
  // the actual client IP will be in this header, not in req.ip.
  const forwardedFor = req.headers["x-forwarded-for"];
  const clientIp = forwardedFor
    ? forwardedFor.split(",")[0].trim()
    : req.socket.remoteAddress;

  if (!SAFARICOM_IPS.includes(clientIp)) {
    console.warn(`Callback rejected: IP ${clientIp} not in Safaricom allowlist`);
    // Return 200 anyway. Returning 4xx could alert an attacker that
    // the IP check exists, and could also cause Safaricom to retry
    // legitimate callbacks during temporary routing anomalies.
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  next();
};

module.exports = ipAllowlist;

Two important implementation details here. First, when your server sits behind a reverse proxy, a CDN like Cloudflare, or an AWS load balancer, req.socket.remoteAddress will show the proxy's IP, not Safaricom's. You need to read from x-forwarded-for to get the real originating IP. However, x-forwarded-for can itself be spoofed if your proxy does not strip or overwrite it. On platforms like Heroku, Railway, and Render, the platform manages this header and it is trustworthy. On a bare VPS with nginx, configure nginx to set X-Real-IP from $remote_addr and read that header instead.

Second, note that we return HTTP 200 even for rejected requests rather than a 403. Returning a clear error code on failed IP checks reveals information about your defences to an attacker probing your endpoint, and it could also cause unnecessary retries from Safaricom if their outgoing IPs ever change temporarily during routing updates. Returning 200 silently and logging the attempt gives you visibility without exposing anything.

One important caveat with IP allowlisting: Safaricom could introduce a new IP for processing requests without immediate notice, meaning you could accidentally reject legitimate callbacks from a new Safaricom IP that you had not previously whitelisted. The mitigation for this is to monitor your logs for 200 responses that came from the IP allowlist middleware but had no matching transaction record in your database. That pattern indicates a real callback was rejected. Keep an eye on the Safaricom developer forum and changelog for IP range updates.

Layer 2: Secret Token in the Callback URL

IP allowlisting verifies the source of the request. A secret URL token verifies that the request is targeting your specific endpoint and not a guessed URL path. The idea is simple: you embed an unpredictable secret in your callback URL, register that URL with Daraja, and your handler rejects any request that does not include the correct token.

This works because Safaricom will only ever call the exact URL you registered. An attacker who does not know your specific URL cannot spoof requests to it.

Step 1: Generate a strong token and add it to your environment variables.

bash
# Generate a cryptographically strong token.
# In your terminal:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Output example: a3f2b1c94d8e7a6b5f0e1d2c3b4a5968...

Add it to your .env file:

code
MPESA_CALLBACK_SECRET=a3f2b1c94d8e7a6b5f0e1d2c3b4a5968your_generated_value_here

Step 2: Use the token in your callback URL.

Update your CALLBACK_URL environment variable and the URL you register with Daraja:

code
CALLBACK_URL=https://yourapp.com/api/mpesa/callback/a3f2b1c94d8e7a6b5f0e1d2c3b4a5968

This URL is what you pass in the CallBackURL field of every STK Push request.

Step 3: Validate the token in middleware.

javascript
// middleware/validateCallbackToken.js

const validateCallbackToken = (req, res, next) => {
  const { token } = req.params;
  const expectedToken = process.env.MPESA_CALLBACK_SECRET;

  if (!token || token !== expectedToken) {
    console.warn(`Callback rejected: invalid or missing token in URL`);
    // Same reasoning as with IP check - return 200 silently
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  next();
};

module.exports = validateCallbackToken;

Step 4: Update your route definition to use the dynamic segment.

javascript
// In server.js or routes/callback.js
const validateCallbackToken = require("./middleware/validateCallbackToken");
const ipAllowlist = require("./middleware/ipAllowlist");

app.post(
  "/api/mpesa/callback/:token",
  ipAllowlist,
  validateCallbackToken,
  callbackHandler
);

The comparison token !== expectedToken uses a regular string equality check here. For even stronger security, use Node's crypto.timingSafeEqual() instead, which prevents timing attacks where an attacker could infer the correct token length by measuring how long the comparison takes:

javascript
const crypto = require("crypto");

const validateCallbackToken = (req, res, next) => {
  const { token } = req.params;
  const expectedToken = process.env.MPESA_CALLBACK_SECRET;

  try {
    const tokenBuffer = Buffer.from(token || "");
    const expectedBuffer = Buffer.from(expectedToken);

    // Buffers must be the same length for timingSafeEqual
    if (
      tokenBuffer.length !== expectedBuffer.length ||
      !crypto.timingSafeEqual(tokenBuffer, expectedBuffer)
    ) {
      console.warn("Callback rejected: token mismatch");
      return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
    }
  } catch {
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  next();
};

A useful side effect of this approach: if you ever suspect your callback URL has been compromised, you can rotate the secret by generating a new token, updating your .env, and re-registering the new URL with Daraja. The old URL immediately stops working.

Layer 3: Payload Structure Validation

IP allowlisting and a secret URL token together are strong. But there is a third layer worth adding: validating that the request body matches the structure you expect before doing anything with it.

This catches a different set of problems. It defends against malformed requests that could cause your handler to crash with an unhandled exception, and it adds an extra obstacle for any attacker who has somehow learned your callback URL.

javascript
// middleware/validateCallbackPayload.js

const validateCallbackPayload = (req, res, next) => {
  const body = req.body;

  // Check the top-level structure
  if (!body || typeof body !== "object") {
    console.warn("Callback rejected: body is not a JSON object");
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  const callback = body?.Body?.stkCallback;

  if (!callback) {
    console.warn("Callback rejected: missing Body.stkCallback");
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  const { MerchantRequestID, CheckoutRequestID, ResultCode } = callback;

  if (!MerchantRequestID || !CheckoutRequestID || ResultCode === undefined) {
    console.warn("Callback rejected: missing required fields in stkCallback");
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  // ResultCode must be a number
  if (typeof ResultCode !== "number") {
    console.warn("Callback rejected: ResultCode is not a number");
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  // For ResultCode 0 (success), CallbackMetadata must be present
  if (ResultCode === 0) {
    const items = callback?.CallbackMetadata?.Item;
    if (!Array.isArray(items) || items.length === 0) {
      console.warn("Callback rejected: missing CallbackMetadata for ResultCode 0");
      return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
    }

    // Verify expected metadata fields are present
    const itemNames = items.map((i) => i.Name);
    const requiredFields = ["Amount", "MpesaReceiptNumber", "TransactionDate", "PhoneNumber"];
    const missingFields = requiredFields.filter((f) => !itemNames.includes(f));

    if (missingFields.length > 0) {
      console.warn(`Callback rejected: missing metadata fields: ${missingFields.join(", ")}`);
      return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
    }
  }

  next();
};

module.exports = validateCallbackPayload;

This middleware does two things. It validates the structural contract of the Daraja callback payload, so your actual handler code can safely access nested fields without defensive null-checking everywhere. And it silently rejects requests that do not match the expected shape, which would never come from Safaricom's real servers.

Layer 4: Cross-Reference Against Your Own Database

This is the final line of defence and arguably the most important one. Even if an attacker bypasses all three layers above (by somehow knowing your URL, spoofing the IP headers through a compromised proxy, and correctly constructing the payload), they still need to provide a CheckoutRequestID that exists in your database as a transaction you actually initiated.

Your callback handler from Part 3 already does this lookup. The key is to make sure it is treated as a hard requirement, not a soft check:

javascript
// Inside your callback handler, after the middleware chain

const transaction = await db.mpesaTransactions.findOne({
  where: { checkoutRequestId: CheckoutRequestID },
});

if (!transaction) {
  // This CheckoutRequestID was never initiated by your system.
  // This is either a spoofed request or a test callback. Either way, ignore it.
  console.warn(`Callback rejected: unknown CheckoutRequestID ${CheckoutRequestID}`);
  return; // We already responded with 200 at the top of the handler
}

For a spoofed request to succeed past this point, an attacker would need a CheckoutRequestID from a transaction your own server initiated. That requires either compromising your database or somehow intercepting the STK Push initiation response from Daraja. Both of those are much harder problems for an attacker than simply crafting a fake callback body.

Putting It All Together

With all four layers in place, here is what the complete middleware chain looks like on your callback route:

javascript
// server.js

const ipAllowlist = require("./middleware/ipAllowlist");
const validateCallbackToken = require("./middleware/validateCallbackToken");
const validateCallbackPayload = require("./middleware/validateCallbackPayload");
const callbackHandler = require("./handlers/callback");

app.post(
  "/api/mpesa/callback/:token",
  ipAllowlist,               // Layer 1: Must come from Safaricom IPs
  validateCallbackToken,     // Layer 2: URL must contain the correct secret
  validateCallbackPayload,   // Layer 3: Body must match expected structure
  callbackHandler            // Layer 4: CheckoutRequestID must exist in our DB
);

Every incoming request passes through each middleware in order. If it fails any check, it gets a silent 200 and exits. Only a request that passes all four reaches your actual business logic. Here is a summary of what each layer catches:

Layer

What it blocks

IP allowlist

Requests from any server that is not Safaricom

Secret URL token

Requests to a guessed or leaked generic callback path

Payload validation

Malformed bodies and structurally incorrect requests

DB cross-reference

Callbacks for transactions you never initiated

Each layer is independent and tested separately. If Safaricom's IP ranges ever change, you update the allowlist in ipAllowlist.js. If your callback token is compromised, you rotate it in .env and re-register the URL. Neither change affects any other layer.

The real value of this layered approach is not any single check in isolation. It is that an attacker would need to simultaneously know your secret URL token, originate their request from a Safaricom IP range, construct a perfectly valid payload structure, and have a real CheckoutRequestID from your database. That combination is not practically achievable without also having already compromised your server, at which point the callback endpoint is not your biggest problem.

A Word on Environments

One common gotcha when implementing IP allowlisting: it will break your local development setup immediately. In development, callbacks from ngrok come through ngrok's servers, not Safaricom's IP ranges, so they will all be rejected by the IP check.

The cleanest solution is to conditionally skip the IP allowlist in development:

javascript
// middleware/ipAllowlist.js

const ipAllowlist = (req, res, next) => {
  // Skip IP check in development. ngrok IPs will never match Safaricom's ranges.
  if (process.env.NODE_ENV === "development") {
    return next();
  }

  const forwardedFor = req.headers["x-forwarded-for"];
  const clientIp = forwardedFor
    ? forwardedFor.split(",")[0].trim()
    : req.socket.remoteAddress;

  if (!SAFARICOM_IPS.includes(clientIp)) {
    console.warn(`Callback rejected: IP ${clientIp} not in allowlist`);
    return res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  }

  next();
};

Keep a separate .env.development file where NODE_ENV=development, and make sure production deployments set NODE_ENV=production. The IP check will be enforced in staging and production, and bypassed locally.

What About Rate Limiting?

One more thing worth adding is basic rate limiting on the callback endpoint. Safaricom sends at most one callback per transaction, with occasional retries. If you are receiving dozens of requests per minute to your callback URL from varied IP addresses, something suspicious is happening.

A simple in-memory rate limiter using the express-rate-limit package is sufficient for this:

bash
npm install express-rate-limit
javascript
// middleware/callbackRateLimit.js
const rateLimit = require("express-rate-limit");

const callbackRateLimit = rateLimit({
  windowMs: 60 * 1000,  // 1 minute window
  max: 60,              // max 60 requests per minute per IP
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    console.warn(`Rate limit hit on callback endpoint from ${req.ip}`);
    res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });
  },
});

module.exports = callbackRateLimit;

Add it to the front of your middleware chain, before the IP allowlist. Legitimate Safaricom callbacks from a single shortcode will never come close to 60 per minute.

Next in the series: Going Live on Daraja Production: What Safaricom Actually Reviews and How to Avoid the Most Common Delays. Questions or feedback? Drop a comment below or reach us at [email protected].

Sandra Safari
ABOUT THE AUTHOR

Sandra Safari

Software Staff Writer,Sandra Safari serves a unique dual role at TechInKenya as both a Software Engineer and a Tech Journalist. Operating at the intersection of infrastructure engineering and media, s...see full bio

Comments

to join the discussion.