When people hear 2FA, they usually think about that extra 6-digit code they have to enter after typing their password.
That is basically correct, but there is an important detail behind it.
2FA (Two-Factor Authentication) means a user must pass two layers of verification before getting access. The first layer is usually something they know, like a password. The second layer is something they have, like a phone running an authenticator app such as Microsoft Authenticator or Google Authenticator.
In practice, this means even if someone knows the password, they still cannot log in without the second factor.
That is why 2FA is such a common security upgrade in modern apps.
One thing that is often less clear, though, is how to implement it properly in a backend that already uses JWT.
A lot of apps already have a simple login flow like this:
email + password
→ valid
→ return JWT
That works fine for normal authentication. But once you add 2FA, this flow should change.
Because if your backend returns an access token immediately after password verification, then the user is already considered logged in before completing the second factor.
And that is exactly where the idea of a challenge token comes in.
First, what does an authenticator app actually do?
Apps like Microsoft Authenticator or Google Authenticator generate a code that changes every few seconds.
At setup time, your backend creates a secret and shares it with the app through a QR code. After that:
- your server stores the secret
- the authenticator app stores the same secret
Then both sides use:
- the same secret
- the same algorithm
- the current time
Because both sides have the same inputs, they generate the same 6-digit code.
That is why authenticator apps do not need to contact your server every time they show a code. They can calculate it locally, even when the phone is offline.
So when people ask, “How does the authenticator already know the code?”, the answer is simple: it does not ask the server in real time. It already has the same secret and calculates the code on its own.
The mistake people often make in JWT apps
A very common flow looks like this:
email + password
→ valid
→ return access token
→ ask for OTP
At first, this seems okay. The user still has to enter the OTP, right?
But the real issue is that the backend already issued the access token. That means the user is technically authenticated before the second factor is verified.
So the OTP step becomes more like an extra screen, not a true security boundary.
The better approach
A better flow is to split login into two stages:
- verify email and password
- verify OTP
And the important rule is:
password verification should not immediately create a full session when 2FA is enabled
Instead, after the password is correct, the backend should return a temporary token that only says:
this user passed step one, but has not completed step two yet
That temporary token is what we call a challenge token.
Challenge token vs access token
These two tokens are different, even if both are technically JWTs.
A challenge token is short-lived and only used for the 2FA step. It is not meant to access protected endpoints. It only exists so the backend can continue the login process safely.
An access token is the real token for authenticated access. This one should only be issued after OTP verification succeeds.
So the meaning is very different:
- challenge token = password is correct, but login is not finished
- access token = login is fully complete
That separation is what makes the flow clean and secure.
A better login flow
Here is the recommended flow:
POST /auth/login
→ verify email + password
if 2FA is disabled:
→ return access token + refresh token
if 2FA is enabled:
→ return challenge token
POST /auth/2fa/verify
→ verify challenge token
→ verify OTP
→ return access token + refresh token
This way, the access token only appears after both checks are done.
That is the part many implementations get wrong.
Why this matters
This may look like a small architectural detail, but it changes the meaning of your auth system.
Compare these two models:
Wrong:
password valid
→ access token issued
→ ask for OTP
Better:
password valid
→ challenge token issued
→ verify OTP
→ access token issued
In the second version, 2FA is actually acting as a second factor, not just an extra confirmation screen.
Final thought
Adding 2FA to a JWT-based app does not mean rebuilding your auth system from scratch.
In many cases, the real change is just this:
from:
password valid → access token
to:
password valid → challenge token
OTP valid → access token
That is a small change in flow, but a big improvement in how correct the authentication model is.
For me, that is the main takeaway: when 2FA is enabled, a correct password should mean “step one passed”, not “login complete.”
A real login should only be complete after the OTP is verified.
Top comments (0)