June 18, 2026
The MFA Bypass That Wasn’t an MFA Problem: A Lesson in Broken API Authorization
A bug bounty story about frontend trust, missing backend enforcement, and why attackers never use your UI.
Hangga Aji Sayekti
6 min read
Most security findings don't start with fancy tools.
No Burp Suite. No custom scripts. No zero-days.
Sometimes they start with a simple question.
In my case, the question was:
Does the backend actually enforce this requirement?
That question eventually led me to an MFA bypass on a payment platform.
Not PayPal.
But close enough.
The Requirement
While exploring the application, I noticed several features were locked behind what the platform called a "Pro" activation process.
To become a Pro user, you first had to enable Multi-Factor Authentication (MFA).
The user interface was very clear about it.
Certain features were inaccessible until MFA was activated.
At least, that's what the frontend wanted users to believe.
As a security researcher, I have learned to be skeptical whenever security controls appear to be enforced only through the UI.
A frontend can hide buttons.
A frontend can disable menus.
A frontend can display warnings.
But none of those things matter if the backend never verifies them.
So I started digging.
What Most Users Assume
┌──────────┐
│ User │
└────┬─────┘
│
▼
┌──────────┐
│ Frontend │
└────┬─────┘
│
▼
┌────────────────────┐
│ MFA Verification │
│ Server-side Check │
└────┬───────────────┘
│
▼
┌──────────┐
│ Features │
└──────────┘┌──────────┐
│ User │
└────┬─────┘
│
▼
┌──────────┐
│ Frontend │
└────┬─────┘
│
▼
┌────────────────────┐
│ MFA Verification │
│ Server-side Check │
└────┬───────────────┘
│
▼
┌──────────┐
│ Features │
└──────────┘Most users assume that if a feature requires MFA, both the frontend and backend enforce that requirement.
Unfortunately, assumptions are not security controls.
An Interesting Observation
While inspecting the application, I noticed something else.
The JWT token contained information about the user's MFA status.
A simplified version looked like this:
{
"id": 103XXXX,
"expiredAt": null,
"time": 1753215689,
"twoFactor": {
"passed": false
}
}{
"id": 103XXXX,
"expiredAt": null,
"time": 1753215689,
"twoFactor": {
"passed": false
}
}Two things immediately caught my attention.
First, the token appeared to have no expiration.
"expiredAt": null"expiredAt": nullSecond, MFA status was represented as a simple boolean flag.
"twoFactor": {
"passed": false
}"twoFactor": {
"passed": false
}
The missing expiration was certainly worth investigating.
But I deliberately ignored it.
I wanted to answer my original question first.
Does the backend actually care whether MFA has been completed?
Building the Test
To verify the behavior, I created two accounts.
Victim Account
victim@sample.comvictim@sample.com- MFA enabled
- Fully activated
- Access to Pro features
Attacker Account
attacker@sample.comattacker@sample.com- MFA disabled
- Standard account
- No access to MFA-restricted features
Nothing unusual so far.
I wasn't trying to bypass OTP codes.
I wasn't attempting session hijacking.
I wasn't manipulating MFA enrollment flows.
I simply wanted to see whether the backend enforced the same rules displayed by the frontend.
Test Setup
┌────────────────────────────┐
│ victim@sample.com │
│ MFA Enabled │
│ Pro Features Available │
└────────────┬───────────────┘
│
▼
Discover APIs
▲
│
┌────────────┴───────────────┐
│ attacker@sample.com │
│ MFA Disabled │
│ Standard Account │
└────────────────────────────┘┌────────────────────────────┐
│ victim@sample.com │
│ MFA Enabled │
│ Pro Features Available │
└────────────┬───────────────┘
│
▼
Discover APIs
▲
│
┌────────────┴───────────────┐
│ attacker@sample.com │
│ MFA Disabled │
│ Standard Account │
└────────────────────────────┘No Burp Suite. Just Postman.
Using the MFA-enabled account, I explored several API endpoints associated with Pro features.
Some examples included:
GET /v1/withdrawals/user-withdrawalsGET /v1/withdrawals/user-withdrawalsand
POST /v2/tickets/createPOST /v2/tickets/createalong with several other endpoints that I cannot disclose.
The process was straightforward.
- Access the feature using the MFA-enabled account.
- Identify the corresponding API endpoint.
- Switch to the non-MFA account.
- Replay the request.
- Observe the response.
I expected authorization failures.
Something like:
403 Forbidden403 ForbiddenInstead, I received:
200 OK200 OKand
201 Created201 Created
The requests succeeded.
The backend processed them without hesitation.
What Actually Happened
Attacker Account
(MFA Disabled)
│
▼
POST /v2/tickets/create
GET /v1/withdrawals/...
│
▼
API Server
│
▼
200 OK
201 CreatedAttacker Account
(MFA Disabled)
│
▼
POST /v2/tickets/create
GET /v1/withdrawals/...
│
▼
API Server
│
▼
200 OK
201 CreatedNo MFA challenge.
No authorization failure.
No additional verification.
The backend simply processed the requests.
The Moment Everything Became Clear
At that point, the problem was obvious.
The application was not enforcing MFA at the API layer.
The frontend blocked access.
The backend didn't.
The UI treated MFA as mandatory.
The server treated it as optional.
That difference created the vulnerability.
The Real Architecture
Frontend
│
├── MFA Enabled?
│
├── YES ────────────────┐
│ │
└── NO ── Hide Button │
▼
API Server
│
▼
Process RequestFrontend
│
├── MFA Enabled?
│
├── YES ────────────────┐
│ │
└── NO ── Hide Button │
▼
API Server
│
▼
Process RequestThe frontend was making the decision.
The backend wasn't making any decision at all.
And that is where the vulnerability lived.
Any user capable of sending requests directly to the API could access functionality that was supposedly protected by MFA.
No OTP bypass.
No brute force.
No race condition.
No cryptographic weakness.
Just a missing authorization check.
Why This Happens
This is a surprisingly common mistake.
Many development teams think about MFA as part of the user experience rather than part of authorization.
The implementation often looks something like this:
if (!user.hasMFA) {
hidePremiumFeatures();
}if (!user.hasMFA) {
hidePremiumFeatures();
}From a user interface perspective, everything appears secure.
Buttons disappear.
Menus become inaccessible.
Warning messages appear.
The product team is happy.
The QA team is happy.
The users believe MFA is protecting the feature.
But attackers do not use your frontend.
Attackers use your API.
The moment a request reaches the server, every frontend restriction becomes irrelevant.
That's why security controls must be enforced where trust actually exists:
On the backend.
The Real Vulnerability
Many people would describe this finding as an MFA bypass.
Technically, that's correct.
But I think that description misses the bigger lesson.
The real vulnerability was not MFA.
The real vulnerability was trusting the client.
MFA was simply the security control that happened to expose the design flaw.
If the application trusted the frontend for MFA enforcement, it could potentially trust the frontend for other authorization decisions as well.
That's a much larger concern.
This is why secure coding guidelines consistently emphasize one fundamental principle:
Never trust the client.
Not the browser.
Not the mobile application.
Not the JavaScript code.
Not the API consumer.
Every authorization decision must be validated on the server.
Every single time.
How MFA Should Be Implemented
A secure implementation requires the backend to verify MFA status before performing sensitive actions.
Proper Enforcement Flow
User Request
│
▼
API Server
│
▼
Is MFA Verified?
│
┌────┴────┐
│ │
NO YES
│ │
▼ ▼
403 Process
Forbidden RequestUser Request
│
▼
API Server
│
▼
Is MFA Verified?
│
┌────┴────┐
│ │
NO YES
│ │
▼ ▼
403 Process
Forbidden RequestA simplified implementation might look like this:
if (!user.mfa_verified) {
return 403;
}
processSensitiveOperation();if (!user.mfa_verified) {
return 403;
}
processSensitiveOperation();Every protected endpoint should independently verify MFA requirements.
Examples include:
- Withdrawals
- Money transfers
- Beneficiary management
- Security settings
- API key generation
- Account recovery operations
- Administrative functions
The backend should never assume the frontend has already performed these checks.
It should verify them itself.
Every time.
Additional Security Improvements
Beyond basic verification, there are several ways to strengthen MFA enforcement.
Use Authentication Assurance Levels
Instead of storing a simple boolean flag, maintain an assurance level for the session.
For example:
{
"auth_level": "password"
}{
"auth_level": "password"
}versus:
{
"auth_level": "mfa"
}{
"auth_level": "mfa"
}Sensitive endpoints should require the higher assurance level.
Require Recent MFA Verification
For high-risk actions such as withdrawals or payout management, require a fresh MFA challenge even if the user already completed MFA earlier.
Use Centralized Authorization Middleware
Rather than relying on developers to remember MFA checks in every controller, enforce MFA requirements through centralized middleware.
This significantly reduces the chance of accidentally exposing an endpoint.
Expire MFA Sessions
MFA verification should not last forever.
The longer a session remains trusted, the larger the attack window becomes if the session is compromised.
Disclosure
I responsibly reported the issue through the platform's vulnerability disclosure program.
The discussion became longer than expected.
What I believed was a straightforward server-side authorization issue eventually turned into multiple rounds of technical discussions and clarifications.
After several exchanges, the report was ultimately marked as a duplicate.😅
That outcome didn't change my opinion of the finding.
Because the most valuable lesson wasn't whether I was the first person to discover it.
The valuable lesson was understanding why it existed in the first place.
Final Thoughts
Security features do not become security controls simply because they exist.
They become security controls when the backend enforces them.
The platform had MFA.
The platform had warnings.
The platform had restrictions.
The platform had onboarding requirements.
What it didn't have was server-side enforcement.
And that's why one simple question was enough to uncover an MFA bypass.
Does the backend actually check?
In this case, the answer was no.
And that single missing check made all the difference.