Secure coding for FX: shipping software that doesn't leak money or PII
The secure-coding training I gave Cede's engineers and product team: the threat model for an FX product, what Nigeria's NDPA and the CBN cybersecurity framework mean in code, and the OWASP Top 10:2025 mapped to a Flutter, Node, React and AWS stack.
From the 85-minute training I gave Cede's engineering and product team on 10 July 2026. The live exercise used snippets from our own codebase, so it stays in the room.
Why this talk exists
An FX product is two high-value targets fused together: a money-movement system and a pile of identity data. In Nigeria, getting either wrong is no longer a quiet engineering bug. It is a reportable, fineable event with two regulators watching.
- $220M: the fine Nigeria's consumer-protection regulator levied on Meta. Enforcement here is real, not theoretical.
- ₦766.2M: the NDPC fine against MultiChoice for cross-border data transfers and privacy breaches.
- 24 hours: the CBN incident-reporting window once a cyber incident is detected.
- 72 hours: the NDPA deadline to notify the NDPC of a breach likely to put people's rights at risk.
The session was itself a compliance artifact. The CBN framework and the NDPA both expect documented secure-development practice, and PCI DSS v4.0.1 requires secure-coding training at least once every 12 months for anyone building bespoke software (requirement 6.2.2).
Who attacks an FX fintech, and what they're monetizing
- Account takeover. Credential stuffing, SIM swap and session theft, to drain balances and exfiltrate transaction history.
- Money-movement abuse. Manipulating transfer, withdrawal or FX-conversion endpoints to move funds that aren't theirs.
- KYC and PII harvesting. BVNs, IDs, selfies and addresses are gold for fraud rings and resale, and every leak triggers NDPA breach duties.
- Rate and logic manipulation. Race conditions or weak validation on FX rates, limits and ledger logic, exploited for arbitrage.
- Supply chain and insiders. A compromised npm or pub.dev dependency, or an over-privileged service that quietly siphons data or funds.
The rule for the rest of the session: if a slide didn't trace back to one of these five, it didn't belong in the talk.
The Nigerian regime: two regulators, plus a card standard
- NDPC: the NDPA 2023 and GAID 2025. Governs all personal and KYC data: a 72-hour breach notice, cross-border transfer rules, DPIAs for high-risk processing, and fines of up to the greater of ₦10M or 2% of annual gross revenue for data controllers of major importance.
- CBN: the Risk-Based Cybersecurity Framework (2024). Written for banks and payment service banks, and the bar their fintech partners are measured against: 24-hour incident reporting, a secure SDLC and controls, and an annual CISO self-assessment due by 31 March.
- PCI DSS v4.0.1, conditionally. Only if you store or process card numbers. Tokenize early to stay out of scope. The future-dated requirements have applied since 31 March 2025, including an automated technical solution such as a WAF in front of public-facing web applications (requirement 6.4.2).
Default assumption: the NDPA and the CBN framework always apply. PCI enters scope the moment a real card number touches your systems.
What the NDPA means in code
- Data residency and transfers. Pushing KYC data to a non-Nigerian AWS region is a cross-border transfer. You need a lawful basis plus adequacy or safeguards, documented. Pick regions and sub-processors deliberately.
- The 72-hour clock. A leak isn't just an incident; it starts a statutory countdown. Logging and detection have to make breaches visible fast.
- Data minimization. Don't collect or keep PII just in case. Every extra field is liability and audit surface. Mask BVNs and IDs at rest and in any UI that doesn't need them.
- DPIAs and automated decisions. Automated KYC scoring, fraud rules and limit decisions are high-risk processing. They need a documented DPIA and a path to human review.
The CBN framework pushes operational duties onto the build in the same way: security engineered in, evidenced and reported rather than assumed. Engineering owns the detection and evidence trail that makes 24-hour reporting possible, and money-movement and KYC paths get the heaviest scrutiny first.
User data, end to end
Every stage of the data lifecycle is both a control point and an NDPA obligation.
- Collect: only fields with a lawful basis. Minimize at the form.
- Transit: TLS 1.2 or later everywhere, certificate-pinned on mobile. Never put PII or tokens in URLs or query strings.
- Process: least-privilege access to PII. Mask BVNs and IDs in working data, and run a DPIA on any automated scoring.
- Store: envelope-encrypt PII at rest with KMS, field-level for BVNs and IDs, in a region chosen for residency.
- Retain: a time-boxed retention schedule. Old PII is pure liability.
- Delete: honor erasure requests and crypto-shred keys. Check that deletion reaches logs, backups and replicas, not just the hot database.
Minimization and deletion aren't legal box-ticking. They shrink the blast radius of every breach you'll ever have.
OWASP Top 10:2025, and the five that hit an FX app
The list was rewritten in 2025, so presenting the 2021 edition dates you. It now focuses on root causes: Broken Access Control is still number one and absorbs SSRF, Security Misconfiguration rises to number two, Software Supply Chain Failures is an expanded category, and Mishandling of Exceptional Conditions is new. Mobile has its own list, the OWASP Mobile Top 10 (2024). The five that matter most for an FX app: broken access control, misconfiguration, the supply chain, cryptographic and secrets failures, and injection and SSRF in services.
A01: broken access control
BOLA and IDOR are the single biggest killer for FX APIs. Authentication answers who you are. Authorization answers whether you're allowed this particular record. Skip the second check and any logged-in user can read, or move, someone else's money.
// Vulnerable: authenticated, but not authorized
router.get('/accounts/:id/txns', auth, async (req, res) => {
const txns = await Txn.find({ accountId: req.params.id });
res.json(txns); // any id works!
});
// Fixed: ownership verified on every request
router.get('/accounts/:id/txns', auth, async (req, res) => {
const acct = await Account.findOne({ _id: req.params.id, ownerId: req.user.id });
if (!acct) return res.sendStatus(404);
res.json(await Txn.find({ accountId: acct._id }));
});Never trust an ID from the client as proof of access. Enforce ownership at the data layer, return 404 rather than 403 so you don't confirm the record exists, and add automated tests that try to read another user's resources.
API hygiene: over-serving and mass assignment
Two sides of one mistake: returning more than the client needs, and binding whatever the client sends straight onto a model. One leaks data; the other lets users grant themselves privileges.
// Vulnerable
res.json(await User.findById(id)); // -> hash, kycFlags, internal notes
await User.update(id, req.body); // body: { role: 'admin', ... }
// Fixed: allowlist in both directions
res.json(toUserDTO(user));
const { name, phone } = schema.parse(req.body);
await User.update(id, { name, phone });Never serialize a raw model or bind req.body to an entity. Paginate and rate-limit list endpoints so one call can't walk the whole table, and keep a single error shape that never leaks internals.
A02: security misconfiguration
- Verbose errors and stack traces leak query fragments and library versions. Return generic errors; log detail server-side.
- Permissive CORS.
origin: '*'with credentials on a money API is CSRF and token theft waiting to happen. Pin known origins and never reflect the request origin blindly. - Missing security headers. Add HSTS, CSP and
X-Content-Type-Options(helmet in Node) and enforce TLS end to end. - Over-permissioned IAM. No wildcard actions or resources on service roles, and no shared god-roles.
- Public buckets and open ports. Deny by default, and scan infrastructure config in CI.
A03: software supply chain failures
The 2025 headline category. An FX stack pulls thousands of transitive packages from npm and pub.dev, most of them never read. One compromised maintainer or typosquat ships straight to production.
- Pin and lock. Commit lockfiles, use
npm cirather thannpm installin CI, and no floating ranges for critical dependencies. - Scan continuously. npm audit, Dependabot or Snyk on every PR, failing the build on critical CVEs in direct and transitive dependencies.
- Generate an SBOM per release (CycloneDX). It is increasingly an audit expectation, and it speeds up breach response.
- Vet before adding. A three-line utility isn't worth a supply-chain risk.
Injection, still everywhere
Whether it's SQL, NoSQL or a shell call, the fix is the same idea: never build executable strings from user input.
// Vulnerable: NoSQL operator injection
// body: { email: { "$gt": "" }, ... } matches the first user
User.findOne({ email: req.body.email, password: req.body.password });
// Fixed: validate types, then compare hashes
const { email, password } = schema.parse(req.body); // zod
const u = await User.findOne({ email: String(email) });
await argon2.verify(u.hash, password);Schema-validate every request body at the edge with zod or joi, cast types explicitly, use parameterized queries everywhere, never pass user input to child_process or eval, and output-encode anything rendered back.
SSRF, now part of broken access control
FX apps fetch a lot of URLs server-side: rate providers, webhook callbacks, KYC document links. If an attacker can influence the destination, they can pivot to internal services and cloud metadata.
// Vulnerable: fetch a "rate source" from user input
const r = await fetch(req.body.callbackUrl);
// -> http://169.254.169.254/... steals cloud credentials
// Fixed: allowlist the host and block internal addresses
const { host } = new URL(url);
if (!ALLOWED.has(host)) throw new Error('host not allowed');
// then resolve DNS and reject private and link-local ranges before fetchingDefense in depth: allowlist destinations, block RFC 1918, link-local and metadata IPs, disable redirects to new hosts, and route outbound fetches through an egress proxy. On AWS, enforce IMDSv2 so a stray SSRF can't trivially mint credentials.
Authentication and sessions
// Vulnerable: common JWT mistakes
jwt.verify(token, key, { algorithms: ['none', 'HS256', 'RS256'] }); // 'none' = forge any token
// no exp, secret = 'secret123', access token lives for 30 days
// Fixed
jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'], maxAge: '15m' });
// short-lived access token + rotating refresh token, revocable server-side- Pin the algorithm. Never accept
noneor a list mixing HS and RS. Keep secrets long and out of source. - Short access, rotating refresh. Access tokens measured in minutes, refresh tokens that rotate, and server-side revocation on logout or compromise.
- Step up for money moves. Re-authenticate or require 2FA before transfers, withdrawals, beneficiary changes and limit changes, not just at login.
Transactional integrity: don't move money twice
// Vulnerable: read-modify-write race (double spend)
const a = await Account.findById(id);
if (a.balance >= amt) a.balance -= amt; // two concurrent requests both pass
await a.save();
// Fixed: one atomic, guarded decrement
const r = await Account.updateOne(
{ _id: id, balance: { $gte: amt } },
{ $inc: { balance: -amt } },
);
if (!r.modifiedCount) throw new InsufficientFunds();- Idempotency keys on every money-movement POST. Store key and result, so a replay returns the cached result, never a second transfer.
- Row locking. Guard ledger writes with
SELECT ... FOR UPDATEor optimistic version checks, and never trust a balance read across a request boundary. - One session per token. Bind tokens to a session, allow server-side revocation, detect concurrent use, and treat a replay from a new device or IP as suspect.
Cryptography and secrets
- Don't roll your own crypto. Vetted libraries and AEAD modes only: no home-grown ciphers, no ECB, no static IVs.
- Encrypt KYC and PII at rest with KMS envelope encryption. Rotate keys, and keep key management separate from data access.
- Hash passwords properly with argon2 or bcrypt at a sane cost. Never MD5 or SHA-1, never plaintext, never reversible.
- Keep secrets out of source. Use a secrets manager, inject at runtime, and scan history with gitleaks.
- Mind what gets logged. Environment dumps and request logs leak secrets into CloudWatch.
- Rotate on exposure. Any secret that ever touched a repo or a client is burned. Rotate it now.
React: the browser is hostile territory
// Vulnerable: XSS via raw HTML, and a "secret" that ships to every user
<div dangerouslySetInnerHTML={{ __html: userBio }} />
const KEY = process.env.REACT_APP_API_SECRET;
// Fixed: render as text (or DOMPurify.sanitize), keep secrets on the server
<div>{userBio}</div>
// the browser gets short-lived tokens from an authenticated endpointAnything in the bundle is public: REACT_APP_* variables, API keys and feature flags all ship to the client. JSX escapes by default, so avoid dangerouslySetInnerHTML, set a strict Content-Security-Policy to limit the blast radius of any XSS, and store tokens carefully, because XSS beats localStorage.
Flutter: secure storage is not a vault
flutter_secure_storage is the most over-trusted control in mobile. It wraps the iOS Keychain and Android Keystore, which is useful, but on a rooted or jailbroken device the threat model collapses.
// Vulnerable: sensitive data in plain prefs, readable on rooted devices
final prefs = await SharedPreferences.getInstance();
prefs.setString('bvn', user.bvn);
prefs.setString('authToken', t);
// Better: secure storage for tokens...
const store = FlutterSecureStorage();
await store.write(key: 'rt', value: t);
// ...but don't cache PII like a BVN at all. Fetch it on demand over an authenticated channel.Secrets in the binary are the most common fintech mobile mistake. API keys, HMAC secrets and encryption keys compiled into the Dart AOT snapshot can be pulled out with strings or by reversing libapp.so, even with --obfuscate.
// Dead on arrival: extractable even with --obfuscate
const apiSecret = 'sk_live_3a8f9c2b...';
// Fixed: the secret never ships in the app
final token = await authClient.fetchEphemeralToken();
// expires in minutes; the backend holds the real signing secretIf a key authorizes money movement and it's in the app, treat it as already compromised. The only safe secret on a client is one that doesn't exist there.
- Certificate pinning defeats MITM through rogue CAs and proxies. Pin the intermediate, or ship backup pins and a remote kill switch, because a single leaf pin bricks the app on certificate rotation.
- Root and jailbreak detection is a speed bump, not a wall. Detect, degrade gracefully by blocking high-risk actions, and combine it with server-side risk checks.
- Obfuscation and integrity checks (
--obfuscate --split-debug-info, tamper and repackaging checks) raise the cost of reverse engineering. Never rely on them for secrecy. - Trust the server, not the client. Every limit, balance and rate is re-validated on the backend.
Logging: where PII quietly leaks
// Vulnerable: PII and secrets in logs
logger.info('login', { body: req.body, headers: req.headers }); // password, BVN, tokens
// Fixed: log IDs and outcomes, not payloads
logger.info('login', { userId, ok: true, ip: hash(ip) }); // plus a central redaction filterLogging a whole request object feels harmless until it drops a BVN, a token or a card number into CloudWatch, searchable by anyone with log access and a reportable NDPA breach if exposed. Allowlist what you log, add a central redaction layer, never log auth headers or bodies on sensitive routes, and set log retention deliberately.
Shift left: make the secure path the default path
Manual security review doesn't scale, and PCI DSS v4 now expects automated protection in front of public-facing web applications. Automating checks into CI means insecure code struggles to merge, and it is also the evidence the CBN self-assessment expects.
- Commit: pre-commit secret scanning with gitleaks, so keys never reach history.
- Pull request: SAST, SCA and a dependency audit on every PR, failing on critical findings.
- Build:
npm ci, pinned dependencies, an SBOM and signed artifacts. - Pre-deploy: DAST against staging, an infrastructure-as-code config scan, and secrets injected at runtime.
- Runtime: a WAF, anomaly detection, and incident detection fast enough for the 24-hour clock.
And threat-model new money-movement and KYC features at design time. The cheapest place to kill a vulnerability is before it's written.
The overconfidence list
Where teams think they're safe, and aren't:
- “We use HTTPS, so we're encrypted.” That says nothing about data at rest, PII in logs, or SDKs exfiltrating data.
- “flutter_secure_storage keeps it safe.” Not on a rooted device. Minimize what lives on the client.
- “The key is obfuscated in the app.” Obfuscation isn't secrecy. Anything in the binary is public.
- “An auth check at login is enough.” BOLA lives in per-record authorization, on every request.
- “It's an internal, trusted URL.” SSRF turns server-side fetches into a path to cloud credentials.
- “We'll log everything for debugging.” Until a BVN or a token lands in CloudWatch and becomes a breach.
What the team committed to
The session ended with concrete, ownable actions rather than good intentions:
- This sprint: secret scanning and dependency audits in CI, a BOLA audit of every money and KYC endpoint, and PII and secrets stripped from logs behind a redaction layer.
- This quarter: SAST and DAST in the pipeline, certificate pinning and token minimization in the app, KMS envelope encryption for KYC data at rest, and a threat model of the FX and withdrawal flows.
- Ongoing: an SBOM per release with reviews of new dependencies, incident detection sized for the CBN's 24-hour clock, a current DPIA and self-assessment, and this training repeated for every new joiner.
Then came the live part: three snippets taken from patterns in our own code, and the first person to spot the flaw and name the fix won. The point wasn't to shame anyone. It was to train the reflex.
References
- OWASP Top 10:2025, OWASP Mobile Top 10 (2024) and OWASP API Security Top 10 (2023).
- Nigeria Data Protection Act 2023 and the General Application and Implementation Directive (GAID) 2025, from the Nigeria Data Protection Commission.
- CBN Risk-Based Cybersecurity Framework and Guidelines for DMBs and PSBs (2024).
- PCI DSS v4.0.1, whose future-dated requirements became mandatory on 31 March 2025.
- OWASP ASVS and MASVS, the OWASP Cheat Sheet Series, and CycloneDX for SBOMs.