The scale of sports betting in Nigeria
Sports betting in Nigeria is a high-volume transactional industry. Major platforms like Bet9ja, SportyBet, and BetKing process millions of payments daily. The integration logic powering these systems is tuned for extreme speed to reduce user friction. Users deposit funds using cards, direct bank transfers, or USSD channels powered by payment processors like Paystack, Flutterwave, and Monnify. While these systems excel at crediting accounts in real time, the connection between how money enters a wallet and how it leaves is often entirely lost on the backend.
This architectural separation exists because sports betting apps prioritize instant deposits to drive user action. The database schema typically stores deposits as generic credit actions that increase a user's wallet balance. When a user requests a payout, the system checks if the balance is sufficient, but doesn't check the historical lineage of those specific funds. This makes the platform a perfect target for financial routing loops.
The laundering loophole
Standard Anti-Money Laundering (AML) guidelines require a closed-loop transaction design. In a closed-loop system, any funds returned to a user must go back to the exact funding source that initiated the transaction. If a wallet is funded with a specific debit card, any withdrawal must be refunded to that same card or its underlying bank account.
On many Nigerian betting sites, this closed-loop rule is enforced in policy documents to satisfy regulators, but it is absent from the application code. This code-to-policy gap creates a direct exploit path:
- An attacker acquires stolen card credentials or gains unauthorized access to a victim's bank account.
- The attacker registers a new account on a sports betting platform using random or falsified credentials.
- The attacker deposits ₦500,000 into the betting account via a local payment gateway integration.
- To avoid triggering simple fraud detection systems that flag immediate cash-outs, the attacker places a low-risk ₦5,000 bet (e.g., on a high-probability match outcome).
- After the bet is settled, the attacker requests a payout of the remaining ₦495,000 to an unrelated bank account under their control.
The gateway logic flaw
The vulnerability occurs because the withdrawal endpoint accepts arbitrary bank routing parameters without validation. The application does not check if the withdrawal destination matches the deposit source.
Let's look at the vulnerable endpoint interface. The client application triggers a withdrawal by sending a payload directly to the backend API:
POST /api/withdraw HTTP/1.1
Host: api.bettingplatform.ng
Authorization: Bearer [attacker_jwt_token]
Content-Type: application/json
{
"amount": 495000,
"destinationAccount": "0098765432",
"destinationBank": "058"
} When this payload is processed, the backend only checks the following conditions:
- Is the session token valid? (Yes)
- Does the wallet balance associated with this user ID contain at least ₦495,000? (Yes)
The server then initiates a transfer call directly to the payment gateway (like Paystack or Flutterwave) to send ₦495,000 to account number 0098765432 (bank code 058). The platform has no logic that compares the name on the destination account with the cardholder name of the card used to make the initial deposit.
Vulnerable backend implementation
Here is an example of the vulnerable Node.js Express endpoint handling withdrawals. The logic fails to trace funding history and processes payouts purely based on wallet balance checks:
// Vulnerable Node.js Express endpoint handling withdrawals
app.post('/api/withdraw', async (req, res) => {
const { amount, destinationAccount, destinationBank } = req.body;
const userId = req.user.id; // From JWT authentication
const wallet = await db.wallets.findOne({ userId });
if (wallet.balance < amount) {
return res.status(400).json({ error: 'Insufficient balance' });
}
// VULNERABILITY: No check comparing destinationAccount to deposit source
// No check verifying if the user has wagered the deposited funds
const transferResponse = await paymentGateway.initiateTransfer({
amount,
account_number: destinationAccount,
bank_code: destinationBank,
currency: 'NGN'
});
if (transferResponse.status === 'success') {
await db.wallets.decrementBalance(userId, amount);
await db.transactions.create({ userId, amount, type: 'withdrawal', status: 'success' });
return res.json({ status: 'success', reference: transferResponse.reference });
}
return res.status(500).json({ error: 'Transfer failed' });
}); The tumbler effect
By routing stolen money through a sports betting account, the attacker breaks the direct link between the theft and the cash-out.
If the attacker transferred the stolen money directly to their own account, the victim's bank would immediately trace and flag that destination account. However, when the money goes through a betting operator, the victim's card statement shows a payment to a legitimate betting platform. The subsequent cash-out to the attacker's account appears on their bank statement as a payout from a licensed betting merchant.
Banks treat payouts from betting operators as legitimate winnings. The victim of the stolen card disputes the charge, leading the card network to initiate a chargeback against the betting operator. The betting company suffers a 100% loss of the disputed funds and must pay a chargeback penalty, while the launderer walks away with clean cash.
Business impact
The business consequences for betting operators are direct and severe. They face massive chargeback liabilities from payment processors, which can lead to merchant account suspension by gateways like Paystack or Flutterwave if chargeback rates exceed 1% of total transaction volume.
Also, the Nigerian Financial Intelligence Unit (NFIU) and the Central Bank of Nigeria (CBN) impose strict AML/CFT rules. Betting platforms operating digital wallets without transactional correlation controls face heavy administrative fines starting at ₦10 million per incident, along with potential license suspension.
The Fix
Securing this flow requires implementing strict server-side validation and transactional constraints. Vague policies cannot replace hardcoded enforcement.
1. Implement closed-loop withdrawal validation
Make sure withdrawals are only routed back to the exact source account or card that funded the wallet. If multiple payment sources were used, restrict withdrawals to bank accounts that match the verified Bank Verification Number (BVN) on the user profile.
2. Enforce minimum wagering requirements
Require users to bet a minimum percentage of their total deposited volume (e.g., 50% to 100%) before enabling withdrawal functionality. Any request to withdraw unplayed funds must be flagged for manual review.
3. Server-side validation of recipient name
Before calling the payout gateway, query a name lookup endpoint (such as a NIBSS name enquiry API or providers like Dojah or Smile Identity) to retrieve the name of the destination account owner. Compare this name with the verified KYC name on the betting account using a string similarity check.
Secure implementation pattern
Here is a secure implementation pattern using Node.js and TypeScript that enforces these security controls:
import { db } from './db';
import { paymentGateway } from './gateways';
import { verifyAccountName, compareNames } from './kyc';
app.post('/api/withdraw', async (req, res) => {
const { amount, destinationAccount, destinationBank } = req.body;
const userId = req.user.id; // Extracted securely from JWT session
const user = await db.users.findUnique({ where: { id: userId } });
const wallet = await db.wallets.findUnique({ where: { userId } });
if (!user || !wallet || wallet.balance < amount) {
return res.status(400).json({ error: 'Invalid transaction parameters.' });
}
// 1. Enforce Wagering Volume Check (e.g., minimum 80% playthrough required)
const totalDeposits = await db.deposits.sum({
where: { userId, status: 'completed' }
});
const totalWagered = await db.bets.sum({
where: { userId, status: 'settled' }
});
const playthroughThreshold = 0.8;
if (totalWagered < totalDeposits * playthroughThreshold) {
return res.status(400).json({
error: 'You must play at least 80% of your deposits before requesting a withdrawal.'
});
}
// 2. Validate Closed-Loop or Identity Correlation
const hasMatchingDeposit = await db.deposits.findFirst({
where: {
userId,
fundingAccount: destinationAccount,
status: 'completed'
}
});
if (!hasMatchingDeposit) {
// Perform NIBSS name enquiry check
const resolvedAccount = await verifyAccountName(destinationAccount, destinationBank);
// Compare resolved name with verified registration name
const isNameMatch = compareNames(user.kycFullName, resolvedAccount.accountName);
if (!isNameMatch) {
// Log alert for security monitoring and NFIU compliance
await db.securityAlerts.create({
data: {
userId,
type: 'NAME_MISMATCH_WITHDRAWAL',
details: {
kycName: user.kycFullName,
recipientName: resolvedAccount.accountName,
account: destinationAccount
}
}
});
return res.status(400).json({ error: 'Withdrawal account name does not match the registered user.' });
}
}
// 3. Execute Transfer
const transfer = await paymentGateway.initiateTransfer({
amount,
account_number: destinationAccount,
bank_code: destinationBank,
currency: 'NGN'
});
if (transfer.status === 'success') {
await db.wallets.decrement({ userId, amount });
return res.json({ status: 'success', txId: transfer.id });
}
return res.status(500).json({ error: 'Payout processing failed.' });
}); Why automated vulnerability scanners miss this
Automated scanners do not place bets, simulate game play, or check transactional closed-loop paths. Identifying these logic flaws requires manual business flow testing.
Decoupled Wallet Withdrawal Logic
During a security review of a sports betting system, we funded a wallet with a test card, placed a ₦500 bet (1% of the deposit), and successfully withdrew the remaining ₦49,500 to a different bank account under a different name. The backend did not verify the playthrough volume or correlate the withdrawal destination account with the funding source. Fix priority: high.
Are your transaction loops vulnerable to automated laundering exploits?
Get a security reviewFrequently asked questions
What is the NFIU rule on betting withdrawals?
The Nigerian Financial Intelligence Unit (NFIU) requires suspicious activity reporting (SAR) for any transaction pattern that indicates structuring or immediate withdrawal of deposits without wagering.
Does Paystack track chargebacks by merchant type?
Yes. Betting platforms have a higher chargeback threshold allowance but are still subject to suspension if chargeback rates exceed 1% of total transaction volume.
How do we implement closed-loop withdrawals for bank transfers?
Save the funding bank account details returned in the Paystack or Monnify webhook payload and restrict withdrawal selections to that specific account number.
Related reading
Blog: Webhook security on payment platforms · Understanding BOLA vulnerabilities
Services: API security testing · Penetration testing · For CBN compliance