The subtle way 2FA gets implemented wrong
Two-factor auth sounds like two independent checks: password, then code. The common implementation mistake is treating them as two steps of one token issuance — verify the password, hand back a real session JWT, then just gate certain routes behind "has this user completed 2FA today." That's a UX flow, not a security boundary. If the "pending" state lives in application logic rather than the token itself, any code path that forgets to check it grants a fully authenticated session to someone who only proved they knew a password.
What WKFGo issues instead: a token that can't be mistaken for a session
When a password check succeeds and the account has 2FA enabled, the login handler does not issue a normal session token. It issues a different, narrower one:
if user.TwoFactorEnabled {
pending, perr := auth.GeneratePendingToken(user.ID)
...
json.NewEncoder(w).Encode(map[string]interface{}{
"twoFactorRequired": true,
"token": pending,
})
return
}
The critical part isn't that this token is short-lived — plenty of insecure designs use short-lived tokens. It's that the token carries a stage claim baked into the JWT itself:
claims := Claims{
UserID: userID,
Stage: StageTwoFactor,
...
}
And the code path that resolves any incoming bearer token into an authenticated request — ResolveUserID, used by every protected route in the app — checks that stage and refuses to treat a StageTwoFactor token as a real identity. It's not a per-route check that a developer could forget to add on a new endpoint; it's enforced at the single choke point every request passes through to be considered "logged in" at all. A pending token literally cannot open a dashboard, list tasks, or call any authenticated API — not because those endpoints happen to check for it, but because the thing that establishes identity for all endpoints rejects it outright.
Completing the second factor
The client sends the pending token plus a TOTP code (or a recovery code) to a single, narrowly-scoped endpoint:
r.HandleFunc("/api/2fa/login-verify", h.LoginVerify).Methods("POST").Name("TwoFALoginVerify")
ParsePendingToken verifies the JWT signature and confirms Stage == StageTwoFactor before extracting the user ID — so this endpoint only accepts tokens that were actually issued as second-factor challenges, not a session token repurposed by mistake. Only after the TOTP code validates does the handler call the normal GenerateToken, issuing a real session JWT with no stage restriction — that's the moment the user is actually, fully authenticated.
The TOTP validation itself
The code check tolerates a small clock skew — it accepts the current 30-second time step and the one immediately before or after:
for _, delta := range []int64{0, -1, 1} {
want, _ := code(secret, uint64(int64(counter)+delta))
if subtle.ConstantTimeCompare([]byte(want), []byte(input)) == 1 {
return true
}
}
Two things matter here beyond the skew tolerance. It's a constant-time comparison (subtle.ConstantTimeCompare), not a plain ==, which matters because a naive string comparison leaks timing information about how many leading characters matched — a real, if narrow, side channel for guessing the correct code faster than brute force alone would allow. And recovery codes get the same treatment: stored as SHA-256 hashes, matched with the same constant-time comparison, never stored or compared in plaintext.
Common mistakes
Storing "2FA verified" as a boolean flag checked per-route. This is the flow described above, done wrong — it works right up until one new endpoint forgets the check, and then it's not actually enforcing anything.
Making the pending token long-lived. A pending token is a narrow window of trust ("this password was correct a moment ago") — the longer it lives, the longer a leaked or intercepted pending token is useful to an attacker who still needs the second factor, but shouldn't get an unlimited number of tries to find one.
Using a non-constant-time comparison for the code or recovery codes. It's an easy thing to overlook because a plain == "works" in every functional test — the vulnerability is a timing side channel, not a correctness bug, so it never shows up until someone specifically looks for it.
FAQ
What if I lose my authenticator app?
Recovery codes — generated once at 2FA enrollment, shown exactly once — let you complete /api/2fa/login-verify without a TOTP code. Each one is single-use and matched against its stored hash.
Can a pending token be reused after a failed code attempt?
Yes, until it expires — you get to retry with the same challenge rather than restarting the password step, but the token's short TTL bounds how long that window stays open.
Does 2FA change anything about API key or MCP authentication?
No — personal API keys (wk_…) and MCP auth are a separate credential path entirely; 2FA gates interactive password login specifically, not programmatic access already scoped by a key.
Summary
Real two-factor authentication means the system is structurally incapable of issuing a full session from a password alone when 2FA is on — not "issues a session and hopes every route remembers to check a flag." Baking the restriction into the token's own claims, verified at the one place all requests get authenticated, removes an entire category of "forgot to add the check on this new route" bugs.