All writing

OWASP Top 10 2025 for Fintech Engineers — What Nigerian Devs Get Wrong

A practitioner's breakdown of OWASP Top 10 2025 through the lens of Nigerian regulatory frameworks — CBN, NDPA, PCI-DSS — and the attack patterns specific to FX and remittance systems.

The OWASP Top 10:2025 was announced in November 2025 and finalized in January 2026. Most write-ups summarize it. This one doesn't. It walks through each category the way it actually shows up in Nigerian FX and remittance code, what we tend to get wrong, and what the NDPC, the CBN and, where cards are involved, PCI DSS will care about when it goes wrong.

A note on the title. This isn't a claim that Nigerian engineers are worse at security; I've made most of these mistakes myself. It's that our context changes which mistakes hurt most. The phone number is the identity, so SIM swap is the master key. BVNs and NINs are worth real money to fraud rings. FX rates move fast enough to arbitrage. And two regulators now have the will and the precedent to fine.

The regulatory frame, in one minute

  • NDPA 2023 and GAID 2025. Personal and KYC data. Notify the NDPC within 72 hours of a breach that's likely to put people's rights at risk, and the people themselves when the risk is high. Fines for data controllers and processors of major importance go up to the greater of ₦10M or 2% of annual gross revenue. Encryption and minimization are expected, and DPIAs are required for high-risk processing.
  • CBN's risk-based cybersecurity framework. Written for banks and payment service banks, and the bar their fintech partners get measured against: incidents reported within 24 hours, a secure SDLC, and an annual self-assessment that has to be true.
  • PCI DSS v4.0.1, if you touch card numbers. Secure-coding training at least every 12 months (requirement 6.2.2), defined secure-coding techniques (6.2.4), and since 31 March 2025 an automated technical solution such as a WAF in front of public-facing web applications (6.4.2). Tokenize early and most of this stays out of scope.

A01 Broken Access Control

What we get wrong: we authenticate and forget to authorize. GET /transactions/:id returns any transaction to any logged-in user. Beneficiary and KYC endpoints trust the ID in the URL. Back-office routes are hidden in the admin frontend but open on the API. KYC documents (BVN slips, passports, utility bills) sit behind pre-signed URLs that stay valid for days, or links that follow a guessable pattern.

SSRF now lives in this category too, and FX systems are full of it: webhook callback URLs, rate-provider URLs and "fetch this document" features that let a user point your server at 169.254.169.254.

The fix: scope every query by owner or tenant at the data layer, deny by default, return 404 rather than 403, keep pre-signed URLs to minutes, give the back office real role-based access with an audit log, and for SSRF allowlist destinations, block private and link-local ranges, and enforce IMDSv2 on AWS. Regulator lens: one leaked KYC document is a personal data breach under the NDPA, which starts the 72-hour clock.

A02 Security Misconfiguration

What we get wrong: CORS that reflects any origin with credentials allowed on a money API, stack traces in production, a public Swagger page or Postman collection that documents every internal endpoint, an S3 bucket of KYC uploads left public for a demo, a Metabase or Grafana dashboard on the internet with default credentials, and staging environments loaded with real customer data.

The fix: configuration as code, reviewed like code; infrastructure scanning in CI; production in its own cloud account; and synthetic data in staging. Regulator lens: real customer data in staging fails the NDPA's minimization principle before anyone even attacks it.

A03 Software Supply Chain Failures

What we get wrong: floating version ranges, npm install in CI, an unofficial bank or BVN SDK copied from a GitHub repo nobody owns, third-party GitHub Actions pinned to a tag instead of a commit, and CI secrets with production write access available to every pull request.

The fix: commit lockfiles and use npm ci; run dependency and licence scanning on every PR; generate an SBOM per release; pin actions to a commit SHA; vet KYC, BVN and payment SDKs like any other vendor that will see customer data. Regulator lens: PCI DSS v4 expects an inventory of the third-party components in your software, and a vendor SDK that ships data abroad is a cross-border transfer under the NDPA.

A04 Cryptographic Failures

What we get wrong: BVNs, NINs and dates of birth stored in plaintext "because we encrypt the disk", AES in ECB mode or with a fixed IV, home-made "encryption" for card data, encryption keys compiled into the Flutter app, and plain HTTP between internal services.

The fix: envelope encryption with KMS and field-level encryption for identity numbers; vetted libraries and AEAD modes only; argon2id or bcrypt for passwords, and for short transaction PINs an HMAC with a pepper held in KMS plus strict attempt limits, because 10,000 combinations fall to any offline attack; tokenize card numbers through your processor; TLS everywhere; and keep keys out of the client entirely. Regulator lens: the NDPA expects appropriate technical measures such as encryption, and a breach of encrypted data with well-managed keys is a very different conversation with the NDPC than a breach of plaintext BVNs.

A05 Injection

What we get wrong: Mongo operator injection in login ({ "$gt": "" } as a password), raw SQL concatenated in reporting and reconciliation scripts, and the one nobody thinks about in fintech: CSV injection. Operations and finance teams export statements and settlement reports to Excel, and a beneficiary name that starts with = becomes a live formula in their spreadsheet, one click from sending data to an attacker.

The fix: schema-validate every request at the edge (zod or joi), cast types, use parameterized queries everywhere including back-office scripts, and prefix any exported cell that starts with =, +, -, @, a tab or a carriage return with a single quote.

A06 Insecure Design

This is the category where FX systems bleed money without any "hack" at all, because the design itself is exploitable:

  • Client-supplied rates. The app sends the rate back with the transfer, and the server trusts it.
  • Quotes without expiry. A user locks a good rate, waits for the market to move, and converts at yesterday's price.
  • Floating-point money. 0.1 + 0.2 is not 0.3, and rounding errors in conversion add up when someone automates thousands of small trades.
  • Negative and zero amounts that slip past validation and reverse the direction of a transfer.
  • Per-transaction limits with no cumulative limit, so a ₦5M transfer becomes ten ₦500K transfers. That's also a structuring pattern your AML monitoring should flag.
  • Refunds that credit the wallet before the reversal settles with the partner.
routes/convert.jsjavascript
// Insecure: the client tells the server what the rate is
const { amount, rate } = req.body;
await wallet.convert(req.user.id, amount, amount * rate);

// Better: the server issued the quote, so the server checks it and spends it exactly once
const { quoteId } = ConvertSchema.parse(req.body); // zod: a string id, never an object
const quote = await Quote.findOneAndUpdate(
  { _id: quoteId, userId: req.user.id, usedAt: null, expiresAt: { $gt: new Date() } },
  { $set: { usedAt: new Date() } },
); // atomic: two concurrent requests can't both spend one quote
if (!quote) throw new QuoteExpired();
await ledger.convert(quote, { idempotencyKey: req.get('Idempotency-Key') });
// amounts held in minor units (kobo, pence, cents) as integers, rounded once
Illustrative code written for this page.

The fix: threat-model money movement at design time, model KYC tiers and transfers as explicit state machines, keep money in integer minor units or a decimal type, issue short-lived single-use quotes bound to the user and amount, and enforce limits cumulatively per day and per beneficiary. Regulator lens: the CBN expects controls to scale with risk, and money-movement paths carry the most of it.

A07 Authentication Failures

What we get wrong: SMS OTP as the only second factor in a market where SIM swap is common; a four-digit OTP with no attempt limit (10,000 guesses); an OTP that isn't bound to the specific transaction or amount; a transaction PIN treated as the only check on high-value moves; and access tokens that live for weeks.

The fix: rate-limit and lock out per identity as well as per IP, bind each OTP to the action and amount it approves, bind sessions to devices and treat a new device as a risk event, offer a non-SMS factor for high-value actions, and keep access tokens short with rotating refresh tokens. Step up on beneficiary changes, limit changes and withdrawals, not just at login.

A08 Software or Data Integrity Failures

What we get wrong: the most expensive mistake on this list in FX: accepting payout and payment webhooks without verifying them. If anyone who finds your webhook URL can post transfer.success, anyone can mark a payout as paid. Close behind: trusting amounts and statuses from the client, and loading app behavior from unsigned remote config.

routes/webhooks.jsjavascript
// Insecure: any caller can mark a payout as successful
app.post('/webhooks/payout', async (req, res) => {
  await markPaid(req.body.reference);
  res.sendStatus(200);
});

// Fixed: verify the signature on the raw body, then confirm with the provider
app.post('/webhooks/payout', async (req, res) => {
  const sig = Buffer.from(req.get('X-Signature') ?? '');
  const expected = Buffer.from(
    crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.rawBody).digest('hex'),
  );
  const valid = sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
  if (!valid) return res.sendStatus(401);

  const transfer = await provider.getTransfer(req.body.reference); // trust the API, not the payload
  if (transfer.status === 'successful') await markPaidOnce(transfer.reference);
  res.sendStatus(200);
});
Illustrative code written for this page. req.rawBody comes from express.raw() or a verify hook; follow your provider's documented signing scheme.

The fix: verify signatures and timestamps on every webhook, process each event idempotently, re-query the provider before you credit or debit, and sign build artifacts so what reaches production is what you reviewed.

A09 Security Logging and Alerting Failures

What we get wrong: two opposite things. Request logging dumps BVNs, card numbers and tokens into CloudWatch, which turns your logs into the breach. Meanwhile the things that matter (an admin changing a limit, a back-office refund, a spike in failed OTPs) aren't logged at all, or raise alerts nobody watches.

The fix: allowlist what you log and redact centrally; write a structured audit trail for every money movement and every privileged action; alert on the patterns that precede fraud (OTP failure spikes, new-device withdrawals, velocity breaches) and route them to a person; and keep audit logs where nobody can quietly edit them. Regulator lens: you can't report an incident to the CBN within 24 hours, or to the NDPC within 72, if you don't know it happened.

A10 Mishandling of Exceptional Conditions

The newest category, and the one closest to money. What we get wrong: a risk engine that times out and quietly allows the transfer; a payout that times out and gets retried, so the customer is paid twice; a catch block that logs and returns success; a half-finished conversion that debits one wallet and never credits the other; and error messages that tell an attacker exactly which check failed.

services/payout.jsjavascript
// Insecure: a timeout is treated as a failure, so the retry pays twice
try { await rail.send(payout); } catch { await rail.send(payout); }

// Fixed: unless the rail definitely said no, the outcome is unknown, so ask
try {
  await rail.send(payout, { idempotencyKey: payout.id });
} catch (err) {
  if (isDefinitiveRejection(err)) return markFailed(payout, err); // explicit, logged, reversible
  return scheduleStatusQuery(payout); // timeouts, resets, 5xx: ask, never resend
}
Illustrative code written for this page.

The fix: decide, per path and in writing, whether each failure fails open or closed; turn timeouts into status queries; post money in single database transactions or compensating sagas; and return one generic error shape to clients while logging the detail server-side. More on this in Attacks will land. Design so they stay small.

The release checklist

Ten questions to ask before any money-movement or KYC change ships:

  • Is every record scoped to its owner at the data layer?
  • Would this config survive being public?
  • Do we know, and pin, every new dependency?
  • Is every identity number encrypted at field level, and every key out of the client?
  • Is every input schema-validated, including what we export?
  • Did we threat-model the money flow: rates, quotes, limits, refunds?
  • Does this action need step-up auth, and is the OTP bound to it?
  • Are webhooks verified and re-queried before money moves?
  • Will the logs show this happened, without showing anyone's BVN?
  • What happens when it times out?

If your team wants the longer, code-level version, I wrote up the secure-coding training I gave at Cede.

I've led product, engineering and security for regulated payment platforms. If your team is working through the same problems, I'd like to hear about it.