Background: Legacy Multi-Factor Authentication (MFA) at Discord
Before we introduced WebAuthn, Discord supported the following MFA methods:
- Time-based one-time password (TOTP): Available for in-app challenges and login.
- 8-digit one-time use backup codes: Intended for users who lose access to their authenticators, but can still be used for in-app challenges and login.
- Text Message (SMS) 6-digit verification code: Only available if TOTP is enabled, can be used only during Login.
In order to obtain codes from users, it was the frontend’s job to check the user’s MFA status to determine whether to show a prompt. This generally led to frontend patterns such as:
The backend checked the sent codes based on whether the user should have sent a code or not, like so:
This pattern has some drawbacks that make it unwieldy. First, whenever teams wanted to onboard routes to be protected by MFA, they would have to make both backend and frontend changes: the backend needed to accept and verify each MFA code, while the frontend needed to show an input modal to users; each frontend input modal had to be supported by Desktop, Browser, and Mobile UIs. On top of that, Mobile deployments shipped weeks later due to App Store reviews making the whole process take weeks for a single MFA check.
The next unwieldy drawback is that teams would make their own promptForMFA function with custom components, meaning our app had unique MFA modals each making their own decisions and styling. Finally, this pattern only supported client-initiated challenges, meaning the frontend needed to self-determine whether to show an MFA prompt or silently fail. This was a non-starter for WebAuthn, because it requires a server-generated challenge: we cannot show the MFA modal until the server provides a challenge to the browser.
We found enough issues with the old system that it required both a redesign and a migration from the old system to the new one.
Migration Requirements
1. There should be a single MFA component used in the frontend.
This keeps the user experience of MFA in the application consistent and provides a centralized location for modification across the entire application. Adding a new MFA method in the future should be simple and work across all routes that expect MFA.
2. The server should be responsible for challenging a client with MFA.
The frontend should not have to specially handle MFA cases for each action, and a server-initiated challenge not only allows seamless integration of the WebAuthn challenge flow, making the server provide a challenge when necessary, but also allows developers to onboard routes to MFA by only changing backend code. Adding a challenge to a new route should not require waiting on App Store approvals.
3. Any registered form of MFA should be allowed by this singular component.
So long as a user has chosen one or more forms of MFA, we should give them all options for any MFA challenge. Previously, certain MFA methods like SMS were only for Login and our mfaEnabled property only considered TOTP; we found many cases where code only handled TOTP for these flows despite there being alternative ways a user could have enabled MFA (WebAuthn for example).
4. We should be able to cache a user's successful MFA for a period of time for ease of use.
We heard plenty of complaints, especially from admins and owners about having to MFA multiple times in the same minute to do server maintenance. Once a user has completed an MFA challenge, we can assume they’ve remained in control of the same device for a few minutes to improve their experience and hopefully remove a potential barrier to registering an MFA option.
To address the above, we established what we creatively call our MFA Version 2 (MFAv2) flow.