The tension between launch speed and security
Fintech startups build at high speeds to prove product-market fit. In non-fintech sectors, an application bug is a minor user experience inconvenience or a layout glitch. In financial services, however, your code interfaces directly with transactional systems, bank networks, and digital wallets. A single logical flaw translates directly to capital drain.
The standard startup model of "launch first, secure later" breaks down immediately when you connect to payment rails like Paystack, Flutterwave, or Monnify, and lookup networks like NIBSS. Once live, attackers actively probe your public routes for logical loopholes, knowing that new apps rarely run security monitoring on day one.
Pillar 1: OTP fallback and session rate limits
User onboarding starts with phone number verification. Telco message delays are common across networks like MTN, Airtel, and Glo, which leads startups to set up alternative delivery channels. When SMS fails, systems automatically fall back to sending OTPs via WhatsApp API calls or email.
This fallback loop becomes an exploit target if it lacks strict, session-linked rate limits. Automated bots can hit the verification routes repeatedly, running up high WhatsApp and SMS API bills within minutes. Furthermore, without lockouts, attackers run brute-force scripts against the 4-digit or 6-digit verification code. If the server does not enforce attempt limits on verification, guessing a 4-digit PIN takes less than five minutes.
To prevent this, restrict OTP verification attempts to three per phone number per session. Require a minimum cooldown period of 60 to 90 seconds before allowing an OTP resend request.
Here is an example of an Express middleware configuration that tracks verification attempts in Redis and enforces a lockout:
// middleware/otpRateLimit.ts
import redisClient from './redis';
export async function rateLimitOtpVerify(req, res, next) {
const { phoneNumber } = req.body;
const attemptsKey = `otp_attempts:${phoneNumber}`;
const attempts = await redisClient.incr(attemptsKey);
if (attempts === 1) {
await redisClient.expire(attemptsKey, 600); // 10 minute lockout window
}
if (attempts > 3) {
return res.status(429).json({
error: "Too many failed attempts. Verification locked for 10 minutes."
});
}
next();
} Pillar 2: NIBSS name enquiry rate limiting
Fintech applications verify user identity and account details through NIBSS (Nigeria Inter-Bank Settlement System) name enquiry endpoints. These queries run via partner bank APIs (such as Wema or Providus) or aggregators like Dojah, Smile Identity, and Mono.
Third-party providers charge a fee for every lookup, typically ranging between ₦5 and ₦15 per request. Startups often expose name resolution APIs on public registration or transaction screens without authentication, resolving the account owner name as soon as a user enters a bank account number.
Attackers exploit this setup to validate databases of stolen credentials. By calling your un-rate-limited resolution endpoint, they clean lists of active account numbers against real names. The verified list is later used for phishing attacks, while your platform faces millions of Naira in un-reclaimable name enquiry invoices.
To secure this endpoint:
- Block name resolution for unauthenticated users; require an active session.
- Restrict each user to a maximum of 5 resolution requests per day.
- Cache resolved names in Redis for 24 hours to avoid repeating lookups for identical account details.
// controllers/bankController.ts
import redisClient from './redis';
import axios from 'axios';
export async function resolveAccount(req, res) {
const { accountNumber, bankCode } = req.body;
const userId = req.user.id;
const rateLimitKey = `user_lookups:${userId}`;
const lookupsToday = await redisClient.incr(rateLimitKey);
if (lookupsToday === 1) {
await redisClient.expire(rateLimitKey, 86400); // 24 hour expiry
}
if (lookupsToday > 5) {
return res.status(429).json({ error: "Daily lookup limit exceeded." });
}
const cacheKey = `name_cache:${bankCode}:${accountNumber}`;
const cachedName = await redisClient.get(cacheKey);
if (cachedName) {
return res.json({ accountName: cachedName, source: 'cache' });
}
const response = await axios.post('https://api.partnerbank.com/resolve', {
accountNumber,
bankCode
});
const accountName = response.data.accountName;
await redisClient.setEx(cacheKey, 86400, accountName);
return res.json({ accountName, source: 'live' });
} Pillar 3: Webhook isolation and signature validation
When a user completes a transaction, payment gateways communicate success to your backend via webhook callbacks. Your system processes these HTTP POST requests to credit user balances or update order statuses.
Accepting these webhooks without verification exposes the platform to wallet creation fraud. If an endpoint like /api/v1/payments/webhook accepts incoming JSON without validating its origin, an attacker can send simulated payment payloads directly to your endpoint and receive wallet credits.
Securing this vector requires two controls:
- HMAC Signature Verification: Verify that the payload hash matches the signature header. Paystack uses the
x-paystack-signatureheader (an HMAC-SHA512 signature generated with your secret key). Flutterwave uses a pre-shared hash in theverif-hashheader. - Idempotency Validation: Check the database for the unique payment reference before allocating any value. Attackers often attempt to replay valid webhook payloads multiple times to double-credit their account balance.
// routes/webhooks.ts
import crypto from 'crypto';
import db from './database';
export async function handlePaystackWebhook(req, res) {
const signature = req.headers['x-paystack-signature'];
const secret = process.env.PAYSTACK_SECRET_KEY;
if (!signature) {
return res.status(401).json({ error: "Missing signature header" });
}
const hash = crypto
.createHmac('sha512', secret)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== signature) {
return res.status(401).json({ error: "Invalid signature" });
}
const { event, data } = req.body;
if (event === 'charge.success') {
const reference = data.reference;
// Verify database for existing references using row locking
const transactionCheck = await db.query(
'SELECT id FROM transactions WHERE reference = $1 FOR UPDATE',
[reference]
);
if (transactionCheck.rows.length > 0) {
return res.status(200).json({ message: "Transaction already processed" });
}
await db.query(
'INSERT INTO transactions (reference, amount, status) VALUES ($1, $2, $3)',
[reference, data.amount, 'SUCCESS']
);
await db.query(
'UPDATE wallets SET balance = balance + $1 WHERE user_id = $2',
[data.amount, data.metadata.user_id]
);
}
return res.status(200).send("Processed");
} The business cost of ignoring MVP security
Exposing vulnerabilities early in a product life cycle brings severe commercial risks. In the local market, fraud rings run highly structured cash-out operations. If they locate an endpoint without verification controls, they will exploit the system to drain capital balances. An initial pre-seed runway can be entirely lost over a weekend.
Beyond direct cash losses, security failures expose the business to regulatory actions:
- CBN Sanctions: The Central Bank of Nigeria issues penalty fees for unlicensed or insecure operations that lead to financial loss.
- NDPA Fines: Under the Nigeria Data Protection Act (NDPA), the Nigeria Data Protection Commission (NDPC) issues regulatory fines up to ₦10 million or 2% of annual gross revenue for leaks involving customer PII, including BVNs and NINs.
- Due Diligence Failures: In institutional funding rounds, venture capital firms mandate manual penetration testing reviews. Structural flaws found during diligence can block funding.
The fix: Minimum security checklist
To secure your MVP without stopping your launch plans, complete these specific server configurations:
- Implement server-side rate limits: Enforce connection and request limits on authentication, sign-up, and verification endpoints using Redis.
- Configure signature validation: Require signature checks on every webhook endpoint connecting to Monnify, Paystack, and Flutterwave.
- Establish database transactions: Run wallet balance updates and transaction tracking inside database transactions using locking queries.
- Add payout bank matching: Restrict withdrawals on unverified (Tier 1) accounts to bank accounts that match the registered user name. This stops money mules from transferring out stolen balances.
- Apply database field encryption: Encrypt database columns storing BVNs, NINs, and payment gateway access tokens using AES-256-GCM.
Manual review vs. automated testing
Automated vulnerability scanners search for outdated libraries and generic server configuration errors. They cannot detect business logic flaws. A scanner will not identify a missing signature check on a webhook route or verify if your Wema name enquiry endpoint is being abused for database mining. Identifying transactional vulnerabilities requires manually checking logic paths and application flows.
Staging DB keys and unverified webhook endpoint
We reviewed an early-stage digital wallet app 2 weeks before launch. The team had left their staging and production DB keys in their client-side React Native package, and had no signature checking on their Monnify webhook endpoint. Anyone could credit their own wallet by sending a raw POST payload to the webhook route. Fix priority: critical. Remediated before public launch.
Want to confirm your payment endpoints and verification loops are secure before launch?
Get a security reviewFrequently asked questions
What is the cheapest way to secure webhooks during MVP phase?
Use the payment gateway's standard signature verification helper libraries and run validation inside middleware before the route controller processes the request.
Do we need a full penetration test for our seed round?
VCs frequently require a manual penetration test report of core money-movement endpoints (not a full scan of the whole stack) as a condition for fund disbursement.
How do we handle OTP costs?
Set a cooldown limit (60-90 seconds) between resend requests and block IPs that request >10 OTPs in an hour.
Related reading
Blog: OTP fallback vulnerability during SMS delays · Webhook security on payment platforms · Security guide for early-stage startups
Services: API security testing · Penetration testing