Hey there! I'm Olúwadémiládé, a Cyber Security Specialist and Software Engineer with a deep passion for breaking things before the wrong people do. When I'm not building systems, I'm tearing them apart to see what falls out, and trust me, things always fall out. Welcome to my fintech hack series, where I share real vulnerabilities I've uncovered — raw lessons straight from the trenches.
Let's get into it.
During a penetration test of a licensed microfinance banking platform, I found something that genuinely scared me. Not a data leak. Not a broken login. I found a way to create money.
With a regular customer account — the same type of account any user signs up with — I credited ₦60,000 into my savings wallet that didn't exist before. No deposit. No transfer from another account. I just… told the server to add money, and it did. Twice.
Then I found the loyalty engine. Cashback? Unlimited. Airtime rewards? Unlimited. All from endpoints that were supposed to be admin-only but had zero role checks.
This is that story.
Disclaimer: This was an authorized penetration test conducted under a signed agreement. All findings were reported to the client, and remediation was carried out. I'm sharing this without naming the platform so others can learn from the architectural mistakes that made this possible.
The Target
The platform was a personal banking application, a mobile app backed by a REST API. The kind of app where customers open savings accounts, transfer money, buy airtime, and manage their finances. A real, licensed microfinance bank serving thousands of customers.
My test account was a standard customer. No special privileges. No admin access. Just a regular user with a valid JWT and the same API key baked into every copy of the app.
During the early phase of the engagement, I decompiled the APK and extracted the full OpenAPI schema from the backend. The schema revealed 700+ API endpoints. Most of them were what you'd expect — login, profile, transactions, notifications. But scattered throughout the schema were endpoints that had no business being accessible to a regular customer.
One of them changed everything.
Finding #1 — Creating Money From Nothing (CVSS 10.0)
While reviewing the endpoint list, I noticed a path that immediately stood out:
POST /api/v1/saving-downstream/create-credit-transaction/The name alone was suspicious. "Saving-downstream" sounds like an internal service, the kind of endpoint a backend microservice would call to credit a user's account after processing a deposit or completing a transfer. It's not something a customer should ever touch.
I sent a test request with my customer JWT:
POST /api/v1/saving-downstream/create-credit-transaction/ HTTP/2
Host: [REDACTED]
Authorization: Bearer [CUSTOMER_JWT]
X-API-KEY: [FROM_APK]
Content-Type: application/json
{
"user_id": "[MY_USER_ID]",
"account_number": "[MY_ACCOUNT_NUMBER]",
"amount": 10000,
"narration": "Test credit"
}HTTP 200.
I stared at it. Then I opened the app and refreshed my balance.
₦10,000 had been added to my account.
No deposit. No transfer. No approval. No transaction PIN required. No PGP encryption on the payload (which the app uses for legitimate transactions). Just a raw JSON body with an amount, and the server said "okay."
My balance had gone from ₦1,283.25 to ₦11,283.25.
I needed to confirm this wasn't a display glitch or a cached balance. So I did it again, this time for ₦50,000.
HTTP 200. Balance updated to ₦68,428.50.
₦60,000 created from thin air across two transactions. Both had valid transaction IDs. Both showed up in my transaction history as completed, successful credits.
Why This Existed
This endpoint was designed for service-to-service communication. In the platform's architecture, when a deposit comes in through a payment provider (say, a bank transfer via their payment partner), the upstream service confirms the payment and then calls this downstream endpoint to credit the user's wallet.
The problem? This internal service endpoint was exposed on the same public-facing API that customers use. And there was no role check. The server verified that the JWT was valid (yes, this is a real customer), but never asked "is this customer allowed to call this endpoint?"
Additionally:
- The
user_idandaccount_numberfields accepted arbitrary values — I could have credited any user on the platform, not just myself - No transaction PIN was required
- No encryption was applied to the request body
- No approval workflow existed
This is a CVSS 10.0 — the highest possible severity score. It's not a data leak. It's not privilege escalation. It's direct financial fraud capability accessible to every single user on the platform.
Finding #2 — Unlimited Cashback via the Loyalty Engine (CVSS 9.8)
After the crediting endpoint, I started hunting for similar patterns, admin-intended endpoints with no role enforcement. The loyalty engine was next.
POST /api/v1/loyalty-engine/reward-with-cashback/This endpoint was clearly meant for internal use — an admin would trigger cashback rewards for users who met certain criteria. Marketing campaigns, referral bonuses, that sort of thing.
I sent a request with my customer JWT:
POST /api/v1/loyalty-engine/reward-with-cashback/ HTTP/2
Host: [REDACTED]
Authorization: Bearer [CUSTOMER_JWT]
Content-Type: application/json
{
"customer": "[MY_USER_ID]",
"amount": 10000,
"reference": "test-cashback-001"
}HTTP 200.
I checked the app. There it was: "Cashback ₦10,100.00" — the extra ₦100 was the platform's own interest or bonus calculation on top of the base amount.
But it got worse:
- The
customerfield accepted any user ID — I could grant cashback to anyone - There was no maximum amount — the
amountfield had no upper bound validation - I could call it repeatedly with different reference strings, and each call succeeded
- There was no admin role check whatsoever
This meant any authenticated customer could grant themselves, or anyone else, unlimited cashback. ₦10,000, ₦100,000, ₦10,000,000. The endpoint didn't care.
Finding #3 — Unlimited Airtime Rewards (CVSS 9.8)
Right next to the cashback endpoint sat its twin:
POST /api/v1/loyalty-engine/reward-with-airtime/Same story. Same lack of authorization. I sent a request for ₦1,000 airtime:
{
"customer": "[MY_USER_ID]",
"amount": 1000,
"reference": "test-airtime-001"
}HTTP 200. ₦1,000 airtime rewarded to my account.
Same as the cashback endpoint, the customer field accepted any user ID on the platform, there was no amount cap, and no role verification. Any customer could grant unlimited airtime to themselves or anyone else.
Finding #4 — Negative Amounts Accepted Everywhere (CVSS 8.1)
While analyzing the OpenAPI schema, I noticed something in the regex pattern used for amount validation across all financial endpoints:
^-?\d{0,10}(?:\.\d{0,2})?$See that ? after the minus sign? It means the minus is optional, which means it's also allowed. Every financial field on the platform — transfers, funding, betting, payouts, explicitly accepted negative values.
The fund-account endpoint was the worst offender. It accepted int64 range values:
Minimum: -9,223,372,036,854,775,808
Maximum: +9,223,372,036,854,775,807That's negative nine quintillion. No transaction PIN was required on this endpoint either.
In a financial system, negative amounts can cause chaos:
- A negative withdrawal becomes a deposit
- A negative transfer reverses the flow of funds
- A negative fee becomes a credit
- A negative deduction increases the balance
This isn't a theoretical concern. The regex was explicitly designed to allow negative values. Whether the backend logic handled them correctly on every single endpoint is a question the platform should never have to answer — because the input validation should have rejected them at the door.
The Remediation
After I finished testing, I compiled everything into a detailed report and sent it to the engineering team. To their credit, they moved fast.
The saving-downstream credit endpoint was pulled entirely from production — it now returns HTTP 404. Gone. The cashback endpoint started returning HTTP 500, and the airtime endpoint returned HTTP 400 with a failure message.
The patches were clearly rushed, a proper fix would have been an authorization check returning HTTP 403 Forbidden, not a 500 Internal Server Error. But I understand the urgency. When someone shows you that any customer on your platform can create money out of nothing, you stop the bleeding first and clean up later.
The Root Cause
Every finding in this report traces back to one root cause: no role-based access control (RBAC) at the API layer.
The platform had 700+ API endpoints. Some were for customers. Some were for admin staff. Some were for internal service-to-service communication. But the API didn't consistently distinguish between them. If your JWT was valid — regardless of whether you were a customer, a support agent, or a platform admin — many endpoints would happily process your request.
This is what happens when:
- Internal service endpoints are exposed on the same API gateway as customer endpoints
- Authorization checks are left to individual endpoint handlers instead of being enforced at the middleware or gateway level
- There's no automated test that verifies "this endpoint should reject a customer JWT" for every admin-only route
700+ endpoints. Thousands of customers. And a regular signup was all you needed to start creating money.
How This Should Have Been Built
1. API Gateway-Level RBAC
Every request should hit a role check before it reaches application code. The gateway should know: this endpoint requires admin role, this one requires service-account, this one is open to customer. If the JWT's role doesn't match, return 403. The application code never even sees the request.
2. Network Isolation for Internal Services
The saving-downstream endpoint had no business being on the public internet. Service-to-service APIs should live on an internal network, accessible only to other backend services, not to anyone with an internet connection and a valid JWT.
3. Defense in Depth on Financial Endpoints
Even if an endpoint is admin-only, financial operations should require:
- Transaction PIN verification
- Encrypted payloads (the platform used PGP encryption for legitimate transactions, why not here?)
- Amount validation (positive values only, reasonable upper bounds)
- Approval workflows for amounts above a threshold
- Audit logging with real-time alerting
4. Schema-Level Input Validation
That regex pattern allowing negative amounts should have been:
^\d{1,10}(?:\.\d{1,2})?$No minus sign. No zero-length digit match. Enforce this at the API schema level so no endpoint can accidentally accept garbage values.
Key Takeaways
1. Internal APIs on public gateways are a ticking time bomb. If a service-to-service endpoint is reachable by customers, it's not an internal endpoint anymore. It's a feature, and a dangerous one.
2. A valid JWT is not authorization. Authentication confirms who you are. Authorization confirms what you're allowed to do. This platform had authentication. It did not have authorization.
3. Your OpenAPI schema is your attack surface. I found these endpoints by reading the schema. Attackers will too. If an endpoint exists in your schema, it exists for your attackers. Audit every single one.
4. Rushed patches are better than no patches, but barely. HTTP 500 is not a security fix. It's a tourniquet. The real fix is an authorization check that returns 403, backed by a role-based access control system that covers every route.
5. Negative amounts in financial schemas are a disaster waiting to happen. If your regex allows a minus sign, someone will use it. Validate inputs at the schema level, not deep in your business logic where it might get missed.
If you enjoyed this breakdown and want to follow along as I document more of the wild things I've found inside fintech platforms, follow me here on Medium. This is the second in my fintech hack series, and trust me, the next one is even crazier.
For those just meeting me, I'm Olúwadémiládé, a Cyber Security Specialist and Software Engineer who spends his free time poking holes in fintech platforms. I believe the best way to build secure systems is to understand exactly how they break.
Stay safe.
#SecurityIsAnIllusion