What happens when NIBSS times out

When a transfer request hits the NIP gateway and NIBSS does not respond within the configured timeout window, the fintech's backend receives one of a handful of signals: a hard timeout, a connection reset, or an ambiguous error code from the gateway middleware. What it does not receive is a definitive answer on whether the instruction reached NIBSS and was queued, or whether it was dropped entirely.

This is the dual-outcome problem. The transaction is in a Schrödinger state — settled or not settled, and you cannot know until you query. Most NIP integrations offer a status-check endpoint for exactly this purpose, but the response to that uncertainty is where engineering decisions diverge dramatically — and where security posture diverges with them.

The two options are:

Nearly every consumer fintech chooses the second path. The business pressure to avoid customer-facing errors during a NIBSS degradation window is real. The security implication of that choice is almost never modelled.

The pending state vulnerability

Here is the pattern that appears repeatedly across Nigerian wallet products: a user initiates a transfer. NIBSS times out. The backend marks the transaction pending and — critically — does not debit the source wallet yet. The UI shows "transfer in progress." The reconciliation job runs on a cron, typically hourly or nightly. When it runs, it queries NIBSS for the final status, then debits or reverses accordingly.

The exploitable window is the period between transaction initiation and reconciliation execution. During that window, the wallet balance is unchanged. If the application allows a second transfer to be initiated against that unchanged balance, you have a double-spend condition.

The gap can be as short as a few minutes if the cron runs frequently, or as long as 12 hours if reconciliation is nightly. NIBSS publishes maintenance schedules. Attackers read them.

The exploit, step by step

This is not theoretical. The mechanics are straightforward:

  1. Attacker identifies a NIBSS degradation window — either from the published maintenance schedule or by monitoring transfer response times in the app. During degradation, response times spike and timeout rates increase.
  2. Attacker initiates Transfer A: ₦100,000 from their wallet to an external account they control. NIBSS times out. The fintech backend marks Transfer A as pending. Source wallet balance: unchanged at ₦100,000.
  3. Attacker immediately initiates Transfer B: ₦100,000 to a second external account. The application checks the wallet balance — sees ₦100,000, sees no debit has been applied — and accepts the transfer. NIBSS again times out or is still degraded. Transfer B also enters pending state.
  4. Reconciliation job runs. It discovers Transfer A settled successfully at NIBSS. It debits ₦100,000 from the wallet. It also discovers Transfer B settled (or it retries and it settles on the retry). Another ₦100,000 is debited. Balance is now -₦100,000 — or if the wallet has no overdraft protection, the second debit is attempted against insufficient funds after the first debit already cleared.
  5. Outcome: Attacker's two external accounts received ₦100,000 each. Attacker paid ₦100,000 (the original balance). Net gain: ₦100,000. If the wallet system rejects the second debit as insufficient and issues a reversal, the net gain is the full ₦200,000.

The attack scales with the size of the wallet balance and the number of pending transfers the system will accept. An attacker with ₦500,000 and a 30-minute NIBSS window can fan out five simultaneous transfers and walk away with multiples of their initial balance — assuming the reconciliation logic does not enforce a debit-before-release constraint.

The state machine flaw

The underlying problem is a broken transaction state machine. A correct model looks like this:


INITIATED → BALANCE_RESERVED → SUBMITTED → CONFIRMED → SETTLED
                                         ↘ FAILED → COMPENSATED (balance released)
    

The BALANCE_RESERVED state is non-negotiable. It must happen synchronously, before any external call to NIBSS. The moment a transfer is initiated, the amount must be locked against the wallet — unavailable for any other operation — regardless of what the upstream gateway returns.

What most vulnerable implementations do instead:


INITIATED → SUBMITTED → [NIBSS TIMEOUT] → PENDING
                                              ↓
                                    (reconciliation later)
                                              ↓
                                    DEBIT applied (or reversed)
    

There is no reservation step. The balance is live and spendable while the transaction is in the PENDING state. Every transfer the attacker initiates during that window consumes nothing from the wallet until reconciliation catches up.

The fix is optimistic balance reservation, not deferred debit. Reserve on initiation, release on failure, consume on success. Here is what a correct reservation looks like at the database layer:


-- PostgreSQL: reserve balance before any external call
BEGIN;

SELECT balance, locked_balance
FROM wallets
WHERE id = $wallet_id
FOR UPDATE; -- pessimistic row lock during this transaction

-- Validate: available = balance - locked_balance >= transfer_amount
-- If not, raise an error and ROLLBACK immediately

UPDATE wallets
SET locked_balance = locked_balance + $transfer_amount
WHERE id = $wallet_id;

INSERT INTO transfers (id, wallet_id, amount, status, idempotency_key)
VALUES ($transfer_id, $wallet_id, $transfer_amount, 'RESERVED', $idempotency_key);

COMMIT;

-- Only after COMMIT: make the external NIP call
-- If NIP times out → status stays 'RESERVED', reconciliation resolves it
-- If NIP succeeds → UPDATE transfers SET status = 'SETTLED';
--                   UPDATE wallets SET balance = balance - $transfer_amount,
--                                      locked_balance = locked_balance - $transfer_amount
-- If NIP fails   → UPDATE transfers SET status = 'FAILED';
--                   UPDATE wallets SET locked_balance = locked_balance - $transfer_amount
    

The FOR UPDATE lock prevents concurrent reservation of the same funds. The locked_balance column tracks reserved-but-not-yet-debited amounts. Available balance is always balance - locked_balance. Any transfer initiation that would take available balance below zero is rejected before the external call is ever made.

Why NIBSS maintenance windows are a targeting signal

NIBSS publishes maintenance windows via the CBN circular system and through gateway provider notifications. The windows are public. Any operator reading NIP integration documentation knows when planned outages occur. Attackers who have mapped a target fintech's pending-state behavior use these windows deliberately — they provide a near-guaranteed timeout response rather than a hard failure, which means the exploit window is predictable and repeatable.

Outside of planned maintenance, NIBSS degrades under load. End-of-month salary periods, public holiday disbursements, and Black Friday promotional campaigns all produce load spikes that increase timeout rates. These are also predictable windows — not published, but inferable from calendar and business cycle data.

A fintech that has not modelled what happens to its state machine under NIBSS timeout conditions is exposed every time those conditions occur — which is not rare.

The complete fix

Four changes are required. Each one independently reduces risk. Together, they close the attack surface.

1. Reserve balance immediately on initiation

As shown above — lock the transfer amount in the wallet row before making any call to NIBSS. Use FOR UPDATE or equivalent pessimistic locking on the wallet row to prevent concurrent reservation races. The check-then-act must be atomic.

2. Implement the Saga pattern with compensating transactions

Model NIP transfers as a Saga: a sequence of steps where each step has a defined compensating action. The sequence for an outbound transfer is:

  1. Initiate — validate request, create transfer record
  2. Reserve balance — lock transfer_amount in wallet (compensating: release lock)
  3. Submit to NIP — send instruction to NIBSS gateway (compensating: send reversal if NIP settled)
  4. Await confirmation — poll NIP status endpoint or receive webhook callback
  5. Settle or compensate — on success: debit balance and release lock; on failure: release lock only

A reconciliation job that runs against step 4 is fine — but it must operate on already-reserved balances. It cannot be the mechanism that decides whether a debit was valid. That decision was made at step 2.

3. Idempotency keys on all transfer requests

Every transfer initiation endpoint must require a client-generated idempotency key. The server must store this key and reject any request with a duplicate key that arrives while the first request is in any non-terminal state (RESERVED, SUBMITTED, PENDING). Terminal states are SETTLED, FAILED, and COMPENSATED.


// Express middleware: idempotency check before processing transfer
async function idempotencyGuard(req, res, next) {
  const key = req.headers['x-idempotency-key'];
  if (!key) return res.status(400).json({ error: 'x-idempotency-key header required' });

  const existing = await db.transfers.findOne({
    where: { idempotency_key: key, wallet_id: req.user.walletId },
  });

  if (existing) {
    // If terminal: return cached response
    if (['SETTLED', 'FAILED', 'COMPENSATED'].includes(existing.status)) {
      return res.status(200).json({ transfer_id: existing.id, status: existing.status });
    }
    // If non-terminal: reject new request — do not process again
    return res.status(409).json({
      error: 'A transfer with this idempotency key is already in progress.',
      transfer_id: existing.id,
      status: existing.status,
    });
  }

  next();
}
    

4. Serialize transfers per wallet, not per user

Do not allow a second transfer to be initiated from the same wallet while any transfer from that wallet is in a non-terminal state. This is a stricter constraint than idempotency keys alone — it blocks even transfers with different idempotency keys. Serialize at the wallet level:


-- Check for any pending outbound transfer from this wallet before accepting a new one
SELECT COUNT(*) FROM transfers
WHERE wallet_id = $wallet_id
  AND direction = 'OUTBOUND'
  AND status NOT IN ('SETTLED', 'FAILED', 'COMPENSATED');

-- If COUNT > 0: reject with 409. Do not process.
-- If COUNT = 0: proceed to reservation step.
    

This query must run inside the same FOR UPDATE transaction as the balance reservation to prevent a concurrent initiation from slipping through between the check and the lock.

Business impact

The financial exposure is direct and scales with wallet size and NIBSS downtime frequency. A fintech processing ₦5 billion in monthly volume with a 30-minute nightly maintenance window has a recurring attack surface. Losses are booked as failed reconciliation items — they show up in the books as reversals or bad debt, not as security incidents, which means they are often attributed to "system issues" and not investigated for fraud patterns.

CBN's regulatory framework requires fintechs to maintain reconciliation accuracy and report material discrepancies. A pattern of double-spend losses surfaced during a CBN audit could trigger a formal inquiry into the fintech's transaction integrity controls. Under the NDPA, if the exploit results in unauthorized data exposure or financial loss to customers, there is an additional notification and remediation obligation.

For venture-backed fintechs, due diligence processes increasingly include security reviews. A broken state machine in the core transaction flow is a material finding — one that can trigger deal conditions or repricing in a funding round.

Example finding

Lending platform: NIBSS window allows double loan draw

During an evening NIBSS degradation window, we found that a lending platform's repayment endpoint marked incoming payments as processing before confirming settlement. The loan management system checked repayment status — saw processing — and immediately marked the loan obligation as "pending closure," which released the borrower's credit limit for a new loan draw. When the reconciliation job ran, several repayments failed to confirm (NIBSS had dropped the instructions during the degradation). The repayments were reversed. But the new loans had already disbursed. The platform held two active loan obligations against one repayment that never settled. Fix priority: critical. Remediation required adding a settlement confirmation gate before any credit limit release, and adding the balance reservation pattern to the repayment ingestion flow.

Can your reconciliation job create a financial loss if it runs 6 hours late — and does your state machine prevent that, or enable it?

Get a security review

Frequently asked questions

Should we just fail all transactions when NIBSS is unavailable?

For most consumer-facing transfers, yes — failing cleanly is safer than entering a pending state that creates exploitable windows. For time-sensitive use cases (payroll, bulk disbursement), implement pending states only with mandatory wallet locking. A hard fail with a clear error message costs you a support ticket. An unguarded pending state can cost you six figures.

How does a Saga pattern help here?

The Saga pattern models long-running transactions as a sequence of steps with defined rollbacks (compensating transactions). Each step either completes or triggers its compensating action. Applied to NIP transfers: initiate → reserve balance → submit to NIBSS → await confirmation → release or compensate. The balance reservation happens in step 2, before the external call. If NIBSS later confirms failure, the compensating transaction credits the reserved amount back. If it confirms success, the reservation is consumed. At no point is the balance available for a second transfer.

How do we detect this in production?

Monitor for users with more than one 'pending' outbound transaction simultaneously. That pattern should never occur in a correctly implemented wallet system. Add a database-level constraint or an application-layer check that rejects any new transfer initiation while an outbound transfer from the same wallet is in a pending or processing state. Alert on violations immediately — they indicate either a bug or an active exploit attempt.

Related reading

Blog: BOLA vulnerabilities in payment APIs · Rate limiting for anti-fraud in payment APIs

Services: API security testing · Payment gateway security