The mechanics of digitized Esusu/Ajo

In Nigeria, micro-savings platforms, cooperative societies, and thrift savings clubs rely on Esusu (also called Ajo, Adashi, or contribution clubs) to drive financial inclusion. The digitized model translates these physical gatherings into automated payment cycles. A typical digital Ajo group consists of multiple participants. In our example, let us say 10 users contribute a fixed sum of ₦10,000 every week.

This generates a total weekly pot of ₦100,000. Each week, one participant is assigned the payout hand (receiving the entire ₦100,000 pot). The process repeats for 10 weeks until every member has received their payout hand.

The schedule is defined before the cycle starts, either through automated random assignment or a first-come, first-served selection. The application's backend keeps track of this schedule in a ledger or database table. Let us sketch a simplified representation of the schema for the rotation state:

CREATE TABLE group_cycles (
    id SERIAL PRIMARY KEY,
    group_id INT NOT NULL,
    cycle_number INT NOT NULL,
    payout_user_id INT NOT NULL,
    payout_amount DECIMAL(12, 2) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'paid'
    scheduled_date DATE NOT NULL
);

Each user deposits their contribution via automated card debits (using Paystack or Flutterwave) or bank transfers to a dedicated virtual account (powered by Wema Bank or Providus Bank). When all contributions hit the group's pooled wallet, a cron job or webhook-triggered service transfers the total pot to the user designated for that cycle's payout position.

Exploit 1: Payout position hijacking (BOLA)

The schedule rotation is determined during the group setup. However, members often need to swap positions or update the schedule because of cash flow emergencies. Group administrators are given the privilege to modify the order. The backend API exposes an endpoint to update this rotation sequence. Let us look at the HTTP request:

PATCH /api/groups/{groupId}/rotation
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...

{
  "payout_sequence": [
    { "cycle": 1, "user_id": 9942 },
    { "cycle": 2, "user_id": 4108 },
    { "cycle": 3, "user_id": 7881 }
  ]
}

A classic Broken Object Level Authorization (BOLA) vulnerability occurs here when the server's authorization logic only performs a binary check: "Is the requesting user a member of group groupId?" If the controller code verifies membership but fails to verify that the requesting user's role is ADMIN for that specific group, any member can execute this request.

An attacker (e.g., User ID 7881, originally scheduled for cycle 10) logs into the app, intercepts the traffic using an HTTP proxy like Burp Suite or OWASP ZAP, and sends a modified sequence array. By swapping their own user ID to the first position, they hijack the next payout hand. Below is an example of vulnerable Express/Node.js backend controller code:

// VULNERABLE CONTROLLER
app.patch('/api/groups/:groupId/rotation', async (req, res) => {
  const { groupId } = req.params;
  const { payout_sequence } = req.body;
  const userId = req.user.id; // From JWT

  // Check if user belongs to the group
  const membership = await db.query(
    'SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2',
    [groupId, userId]
  );

  if (membership.rows.length === 0) {
    return res.status(403).json({ error: 'Not a member of this group' });
  }

  // VULNERABILITY: Missing check if role === 'admin'
  // Anyone in the group can rewrite the schedule!
  await updateRotationSchedule(groupId, payout_sequence);
  
  return res.status(200).json({ message: 'Rotation updated successfully' });
});

Because the system lacks granular validation, the attacker changes the sequence, receives the pot of ₦100,000, transfers the funds to a neobank wallet, and abandons the savings group.

Exploit 2: Double-withdrawal race condition

Once the group pot is accumulated, the application automatically credits the cycle's payout recipient's internal ledger wallet. The user then triggers a withdrawal to cash out to their external bank account (e.g., Sterling Bank or GTBank) or their mobile money wallet (MTN MoMo or Airtel Money). The withdrawal endpoint is typically structured like this:

POST /api/wallets/withdraw
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...

{
  "amount": 100000.00,
  "destination_bank": "058",
  "account_number": "0123456789"
}

If the backend process is not written defensively, it handles the request in three sequential database operations:

Because database queries take time to execute (often a few milliseconds to a second depending on network latency to the gateway), this sequence creates a race condition. An attacker can write a script to fire multiple identical withdrawal requests at the exact same millisecond:

# Parallel curl execution simulating a race condition
curl -X POST -H "Authorization: Bearer JWT_TOKEN" -d '{"amount":100000,"account_number":"0123456789"}' https://api.ajoapp.com/api/wallets/withdraw &
curl -X POST -H "Authorization: Bearer JWT_TOKEN" -d '{"amount":100000,"account_number":"0123456789"}' https://api.ajoapp.com/api/wallets/withdraw &

If the API handles these requests concurrently on multi-threaded or event-driven backends, both threads will query the balance at step 1 and see ₦100,000. Both threads will successfully initiate two separate outbound transfers of ₦100,000 via the payment gateway. By the time the database updates the balance in step 3, the wallet balance goes negative to -₦100,000. The attacker has successfully double-withdrawn the pot, leaving the platform with a deficit.

Exploit 3: Defaulting and synthetic group injection

This is a business logic design flaw. Traditional Esusu operates within a tight-knit physical market or community where peer pressure prevents default. If a participant defaults on their contributions after taking the first week's hand, the entire market knows, destroying their business reputation.

In a digital app, this constraint is removed. Attackers buy cheap SIM cards (or use pre-registered MTN/Airtel numbers) and obtain synthetic identities. They bypass KYC verification checkpoints or purchase BVNs and NINs from the dark web.

The attacker creates a group savings pool consisting of 10 slots. Nine slots are controlled by the attacker using synthetic profiles and mule accounts, while one slot is occupied by a real victim. The scheduling algorithm is manipulated (or BOLA is used) to ensure that the nine synthetic accounts get the first nine payout positions.

During the first cycle, the first mule receives the pot, withdraws it, and disappears. Since the digital platform guarantees payouts to prevent group collapse, the platform covers the payment. By the time the cycle reaches week 10—when the real victim is scheduled to cash out—all nine mules have defaulted, leaving the platform to pay out the final hand out of its own reserves. The platform loses ₦900,000 on a single 10-person pool.

The business impact

The damage is not just technical; it affects the platform's survival.

The fix

Here are the exact technical defenses required to secure digitized group savings.

1. Multi-signature schedule controls

Do not allow a single group admin to rewrite the payout rotation once a cycle has commenced. Any modification must require approval from a majority of the group members, or a secondary admin confirmation. Store rotation changes in a pending state until members approve them via their own accounts.

// SECURE APPROVAL LEDGER
async function proposeScheduleChange(groupId, proposerId, newSequence) {
  // Create a proposal record requiring sign-offs
  const proposal = await db.query(
    'INSERT INTO schedule_proposals (group_id, proposed_by, sequence_data, status) VALUES ($1, $2, $3, $4) RETURNING id',
    [groupId, proposerId, JSON.stringify(newSequence), 'pending']
  );
  
  // Trigger push notifications to all other group members to vote/approve
  await notifyMembersForApproval(groupId, proposal.rows[0].id);
}

2. Pessimistic ledger locking

To prevent double-withdrawal race conditions, enforce pessimistic locking on the database ledger row. Use SELECT FOR UPDATE inside a database transaction to lock the user's wallet before verifying the balance and issuing payment.

-- PostgreSQL implementation of pessimistic locking
BEGIN;

-- Lock the wallet row to prevent other concurrent reads/writes
SELECT balance FROM wallets 
WHERE id = $1 FOR UPDATE;

-- Perform balance validation in the application logic
-- If balance >= withdrawal_amount:
UPDATE wallets SET balance = balance - $2 WHERE id = $1;

COMMIT;

This forces all concurrent requests to queue up sequentially. The second request will only execute after the first has completed and deducted the balance, making double-withdrawal impossible.

3. Tiered KYC checks

Ban unverified accounts from participating in early payout slots. Implement Dojah or Smile Identity integrations to check that the BVN and NIN match the user's registered name and bank account details.

4. Reputation gating

Analyze historical data before assigning payout positions. Limit early payout hands to users who have completed at least one full savings cycle without defaulting. Alternatively, integrate with credit bureaus like CRC Credit Bureau or FirstCentral to pull the user's credit score and block high-risk profiles from taking early hands.

Example finding

Unauthorized drag-and-drop rotation schedule manipulation

We reviewed a digitized Ajo platform where group admins could drag-and-drop the payout schedule. The API request passed a JSON array of member IDs. We intercepted the request as a regular group member and successfully reordered the schedule, putting our test account first in the payout line. Fix priority: critical.

Is your savings rotation and payout logic protected against authorization bypasses?

Get a security review

Frequently asked questions

Do we need CBN licensing to run a digitized Esusu app?

Yes, operating a cooperative or saving scheme under digital models typically requires a cooperative license or a microfinance bank license depending on scale.

How do we prevent members from defaulting after getting their payout?

Restrict early payout slots to users with verified BVNs matching their bank accounts, and integrate with credit bureaus to report defaults.

What database model is best for rotation scheduling?

A stateful ledger table with immutable event logging for every contribution and payout transition.

Related reading

Blog: Webhook security on payment platforms · CBN compliance and security guide

Services: API security testing · Penetration testing