Implementing Two-Factor Authentication (2FA) is easy on paper: verify password, verify 6-digit TOTP code, issue JWT.
The difficulty lies in what happens between step 1 and step 2.
When a user submits their email and password correctly, what token do you give them? If you issue a full access token before they enter their 6-digit 2FA code, 2FA is completely useless because a compromised password already yields valid authorization.
If you don't issue any token, how does the frontend prove it passed password verification when it submits the TOTP code to /api/auth/verify-2fa?
The Temporary Handshake Token
We solved this state transition using a short-lived Handshake Token:
1. POST /api/auth/login (Email + Password)
└── Backend verifies credentials. If 2FA enabled:
└── Returns: { requires2FA: true, tempToken: "handshake_xyz..." }
(Valid for exactly 5 minutes, scoped ONLY to /verify-2fa)
2. POST /api/auth/verify-2fa (Bearer: tempToken, Code: "849201")
└── Backend validates TOTP code & expires tempToken.
└── Sets HttpOnly Refresh Token cookie & returns Access Token.
The temporary handshake token carries zero authorization permissions: it cannot fetch catalog items, it cannot mutate settings, and it expires after 300 seconds.
The Takeaway
Security is about defining strict, unambiguous intermediate states.
By making the transition between password verification and TOTP confirmation a temporary, single-purpose handshake, you eliminate session leaks while keeping the login flow smooth.