Stateless verification in NIP transfers

To execute an inter-bank transfer in Nigeria, a fintech must integrate with the NIBSS Instant Payment (NIP) protocol. Whether routing traffic directly to the Nigeria Inter-Bank Settlement System (NIBSS) or through aggregators like Paystack, Flutterwave, Mono, or Dojah, the workflow consists of two decoupled steps.

The first step is Name Enquiry. When a customer inputs a destination account number and selects a bank, the client application triggers a name enquiry. This is typically a GET request from the frontend to the backend (for example, GET /api/v1/name-enquiry?account=0012345678&bank=058). The backend handles this by querying NIBSS or the aggregator, which returns the registered account name. The client application then displays this name to the user.

The customer looks at the name, verifies it matches their intention, and taps the confirmation button. This triggers the second step: a POST request (for example, to /api/v1/transfers) containing the transfer parameters.

The security gap lies in the fact that these two requests are entirely stateless relative to each other. The backend API handles the name enquiry and the transfer as isolated events. The frontend implements the validation sequence, but the backend lacks the logic to verify that the target account number in the second step matches the one that was validated in the first step.

The attack path: Payload interception and manipulation

Because the backend treats the transfer execution request as a standalone event, it assumes the client-submitted data remains unaltered. Attackers exploit this logic disconnect.

An attacker begins by initiating a transfer within a compromised or attacker-owned fintech account. They enter a valid account number of a known user to trigger a successful name enquiry, displaying that user's name on screen.

Before tapping "Confirm," the attacker routes their device traffic through an intercepting proxy such as Burp Suite or OWASP ZAP. When the attacker clicks the confirmation button, the proxy captures the outgoing POST request to the transfer API endpoint.

In the proxy, the attacker alters the destination account number parameter in the JSON payload to point to an attacker-controlled account (such as a burner account at OPay, PalmPay, or Kuda Bank) while keeping the validated bank code or other metadata unchanged. The attacker then releases the request.

The backend API receives this modified request. It validates the user's JWT authentication token, verifies the source wallet balance, and formats the NIP transfer instruction. It does not check if the account number in the payload matches the one that was resolved during the name enquiry step. It accepts the client-provided destination account number parameter blindly.

Manipulating the NIP transaction payloads

Let's examine how the JSON payload looks during this manipulation. First, we have the original client-side request captured by the proxy:

{
  "sourceWalletId": "w_abc",
  "destinationAccount": "0012345678",
  "destinationBank": "058",
  "amount": 50000
}

After interception in Burp Suite, the attacker replaces the recipient details with an alternate target account, retaining the valid source wallet token:

{
  "sourceWalletId": "w_abc",
  "destinationAccount": "0098765432",
  "destinationBank": "058",
  "amount": 50000
}

If the backend does not enforce server-side binding between the resolved name enquiry reference and the final payment payload, it executes the payment to the modified target account.

Business consequences: Lost funds and regulatory exposure

The financial impact is direct. Under the NIP settlement framework, funds move within seconds. The ₦50,000 settles in the destination account at Guaranty Trust Bank (code 058) immediately. If the recipient is an attacker, they will quickly move the money through agent banking networks, cash out at an ATM, or split it across multiple mule wallets, making recovery impossible.

Reversing a settled NIP transfer is extremely difficult. It requires the originating fintech and the destination bank to cooperate, initiate a formal dispute recall, and place a temporary restriction on the recipient account. This administrative process usually takes 3 to 5 business days, and if the funds are gone, the originating fintech must absorb the loss to keep the sender whole.

Furthermore, regulatory authorities like the Central Bank of Nigeria (CBN) and the Nigeria Data Protection Commission (NDPC) enforce strict compliance rules. Under the NDPA, failures that lead to financial fraud can result in administrative fines, mandatory audits, or the suspension of processing licenses.

Finally, customer confidence is degraded. If merchants and customers realize that a verified payee name can be silently diverted, they will migrate to other platforms.

The solution: Cryptographic transaction binding

To address this flaw, the backend must cryptographically bind the name enquiry action to the transfer request.

When a name enquiry queries NIBSS and resolves successfully, the backend must generate a short-lived, signed token (using an HMAC or a temporary JWT). This token must contain:

The server signs this token using a secure server-side secret key and returns it to the client.

When the client submits the transfer request, it must pass the token along with the transaction details. The backend verifies the cryptographic signature of the token, confirms it has not expired, and matches the submitted account number and bank code against the values sealed within the token. If they do not match, the transaction is rejected immediately.

Additionally, the backend must track the reference ID from NIBSS and mark it as spent to prevent replay attacks.

Implementing secure transaction validation in Node.js

Here is the code pattern in Node.js using standard crypto libraries to implement cryptographic binding.

import crypto from 'node:crypto';
import { Request, Response } from 'express';

const SERVER_SECRET = process.env.TRANSACTION_SIGNING_SECRET || 'secure-random-key-here';
const usedReferenceIds = new Set<string>();

interface TokenPayload {
  destinationAccount: string;
  destinationBank: string;
  referenceId: string;
  expiresAt: number;
}

export function generateVerificationToken(
  destinationAccount: string,
  destinationBank: string,
  referenceId: string
): string {
  const expiresAt = Date.now() + 3 * 60 * 1000; // 3 minutes
  const payload: TokenPayload = {
    destinationAccount,
    destinationBank,
    referenceId,
    expiresAt,
  };

  const payloadStr = JSON.stringify(payload);
  const signature = crypto
    .createHmac('sha256', SERVER_SECRET)
    .update(payloadStr)
    .digest('hex');

  return Buffer.from(payloadStr).toString('base64') + '.' + signature;
}

export function verifyTransactionToken(
  token: string,
  submittedAccount: string,
  submittedBank: string
): { isValid: boolean; referenceId?: string } {
  try {
    const parts = token.split('.');
    if (parts.length !== 2) return { isValid: false };

    const [payloadBase64, signature] = parts;
    const payloadStr = Buffer.from(payloadBase64, 'base64').toString('utf8');

    const expectedSignature = crypto
      .createHmac('sha256', SERVER_SECRET)
      .update(payloadStr)
      .digest('hex');

    if (signature !== expectedSignature) {
      return { isValid: false };
    }

    const payload: TokenPayload = JSON.parse(payloadStr);

    if (Date.now() > payload.expiresAt) {
      return { isValid: false };
    }

    if (
      payload.destinationAccount !== submittedAccount ||
      payload.destinationBank !== submittedBank
    ) {
      return { isValid: false };
    }

    return { isValid: true, referenceId: payload.referenceId };
  } catch {
    return { isValid: false };
  }
}

export async function handleTransfer(req: Request, res: Response) {
  const { amount, accountNumber, bankCode, transferToken, sourceWalletId } = req.body;

  if (!amount || !accountNumber || !bankCode || !transferToken || !sourceWalletId) {
    return res.status(400).json({ error: 'Missing transaction parameters' });
  }

  const verification = verifyTransactionToken(transferToken, accountNumber, bankCode);
  if (!verification.isValid || !verification.referenceId) {
    return res.status(400).json({ error: 'Invalid or expired transaction token' });
  }

  // Prevent replay attacks
  if (usedReferenceIds.has(verification.referenceId)) {
    return res.status(400).json({ error: 'Token already used or replayed' });
  }
  usedReferenceIds.add(verification.referenceId);

  // Execute the payment instruction to the NIP switch using verified parameters
  const result = await executeNipPayment({
    sourceWalletId,
    accountNumber,
    bankCode,
    amount,
    referenceId: verification.referenceId,
  });

  return res.json({ success: true, transactionId: result.id });
}

async function executeNipPayment(params: any) {
  // Production integration to NIBSS or Payment Gateway goes here
  return { id: 'txn_' + crypto.randomBytes(8).toString('hex') };
}
Example finding

Client-controlled name matching parameters

During one engagement, we found that a fintech's transfer confirmation page sent the verified account name back to the server in the POST body. The backend compared the submitted name to its own records—not to a NIBSS re-verification—meaning an attacker who knew any legitimate account holder's name string could route funds to any account number. Fix priority: critical.

Does your backend check if the destination account number matches the name enquiry payload, or are you accepting user inputs blindly?

Get a security review

Frequently asked questions

Does NIBSS provide a session token that links name enquiry to transfer?

No. NIBSS NIP treats each API call independently. The binding responsibility is entirely on the fintech's own backend.

How common is this vulnerability?

We find it in roughly half of the Nigerian fintech transfer implementations we review. The frontend often has UX that implies binding, but the backend has no enforcement.

Can this be caught with automated scanning?

No. Automated scanners test endpoints in isolation. This vulnerability only appears when you trace the full multi-step transfer flow as a practitioner would.

Related reading

Blog: Webhook Security for Payment Platforms · Rate Limiting and Anti-Fraud Patterns

Services: API security testing · Payment gateway security