13.16. Multi-Factor Authentication
Table of Contents
Why Multi-Factor Authentication Matters
Multi Factor Authentication, often shortened to MFA, adds extra protection on top of a username and password. Instead of trusting only "something you know" like a password, MFA requires at least one more factor before the user is allowed in.
Common factors are:
| Factor type | Description | Examples |
|---|---|---|
| Something you know | A secret in your head | Password, PIN, answer to a security question |
| Something you have | A physical object or device | Phone with authenticator app, hardware token |
| Something you are | A physical or behavioral trait | Fingerprint, face, voice, retina |
| Somewhere you are | Location based | Logging in only from a specific country |
| Something you do | Behavioral pattern | Typing rhythm, way you move the mouse |
With MFA, an attacker who steals a password still cannot log in, because they do not have the second factor.
Core idea: MFA requires at least two independent factors from different categories, for example password + time based one time code.
As a backend developer, you do not implement fingerprint scanners yourself, but you integrate MFA flows into your authentication system and make sure they are secure, reliable, and user friendly.
Typical MFA Flows
From a backend point of view, MFA is mostly about flows and state. Here are the most common flows you will implement.
Login with MFA
- User enters username and password.
- Backend verifies the password.
- If the account has MFA enabled, backend marks the user as "partially authenticated".
- Backend triggers the second factor, for example:
- Shows a page asking for a 6 digit code.
- Sends an email or SMS with a one time code.
- Sends a push notification to a mobile app.
- User provides or approves the second factor.
- Backend completes authentication, creates final session or access token.
In code, you usually represent this as two steps:
- Step 1: password correct ⇒ issue a temporary token like
mfa_challenge_tokenwith very short lifetime. - Step 2: client sends
mfa_challenge_token + code⇒ backend verifies ⇒ issues normal session / JWT.
This keeps logic clear and prevents partial logins from becoming full sessions by mistake.
Enabling MFA
When a user turns MFA on, you need a safe setup flow:
- User is already logged in with password.
- User chooses MFA method, for example "Authenticator app".
- Backend generates a shared secret and returns it as:
- a QR code image (or URL for QR), or
- a base32 encoded string.
- User scans the QR code into their authentication app (Google Authenticator, Authy, etc).
- App starts generating 6 digit codes.
- Backend asks user to enter one code to confirm they set it up correctly.
- Backend validates the code once.
- Backend stores the secret for that user in the database, and marks MFA as enabled.
Never enable MFA for a user without first verifying at least one valid code from their authenticator app.
Disabling MFA
Disabling MFA is sensitive, because an attacker with only a password should not be able to easily remove the extra protection.
Typical rules:
- Require the user to re enter their password.
- Optionally ask for a current MFA code as well.
- Only then mark MFA disabled and delete or archive MFA secrets.
You will later use some of the same ideas for password reset, email verification, and other security sensitive flows.
Common MFA Methods
You will meet several MFA methods as a backend developer. Each method has a different security level and integration style.
Time-Based One-Time Passwords (TOTP)
This is the most common strong MFA method. It uses a shared secret between the server and the user’s device.
How TOTP Works in Simple Terms
- Server generates a random secret key (for example 160 bits).
- Server shows the secret to the user as a base32 string or QR code.
- User stores this secret in an authenticator app.
- Every 30 seconds, both server and app compute a new 6 digit code from:
- the shared secret
- the current time window
The code is one time and short lived. Even if someone sees the code, they can only use it for a few seconds.
You do not need to implement the math yourself, most languages have libraries. Still, it helps to know the formula:
- You compute the time step:
$$ T = \left\lfloor \frac{\text{current\_unix\_time} - T_0}{\text{step}} \right\rfloor $$
Usually $T_0 = 0$, $step = 30$ seconds. - You apply HMAC with the shared secret and $T$ and then transform the result into a 6 digit code.
TOTP rule: Valid codes are only accepted for a small time window, usually the current 30 second step, and sometimes 1 step before and after to allow for small clock differences.
Example: Generating a TOTP Secret and QR URI
A common format for providing TOTP data to authenticator apps is the otpauth URI:
otpauth://totp/{ISSUER}:{ACCOUNT_NAME}?secret={BASE32_SECRET}&issuer={ISSUER}&digits=6&period=30Example values:
ISSUER: your application name, for exampleMyShopACCOUNT_NAME: usually the user emailBASE32_SECRET: random base32 string
As backend you return this URI to the frontend, which then renders it as a QR code.
Verifying a TOTP Code
When a user sends a 6 digit code, the backend:
- Looks up the stored secret for this user.
- Computes codes for:
- current time step
- maybe 1 step before and after
- Checks if any of those codes match the user input.
If yes, MFA step is passed. If not, reject and maybe increment a counter for failed attempts.
SMS and Email Codes
Some systems send one time codes over SMS or email. The flow is similar to TOTP but with important differences.
Typical flow:
- User starts login.
- Backend generates a random numeric code, for example 6 digits.
- Backend stores a hashed version of the code, together with:
- the user id
- expiration time, for example 5 minutes
- number of remaining attempts
- Backend sends the plain code over SMS or email.
- User enters the code.
- Backend compares the hashed code and checks expiration and attempts.
You can represent stored codes in a table like:
| Field | Example value |
|---|---|
| id | 123 |
| user_id | 42 |
| code_hash | hash("483920") |
| created_at | 2026-08-27 12:30:00 UTC |
| expires_at | 2026-08-27 12:35:00 UTC |
| attempts_left | 3 |
| used | false |
Never store the code in plain text in the database. Store a hash of the code and compare hashes.
SMS and email codes are easier to integrate but less secure than TOTP. Attackers can sometimes intercept SMS or compromise email accounts.
Push Notification Approvals
Some apps use push notifications for MFA:
- User enters username and password.
- Backend sends a "login request" to a mobile app via push.
- Mobile app shows "Approve login?" prompt to the user.
- User taps "Approve".
- Mobile app calls a backend endpoint, for example
/mfa/approve, with: - the login request id
- a signed token or device secret
- Backend verifies the approval and completes login.
This requires:
- backend endpoints for starting a push challenge
- backend endpoints for receiving approval
- device registration and secure device identifiers
The concept is similar to TOTP, but the "code" is replaced with "user physically approved this on a trusted device".
Hardware Security Keys (U2F / WebAuthn)
Hardware keys like YubiKey plug into USB or stay near the phone. Modern browsers support them through WebAuthn.
As a backend developer, you typically:
- Support "register security key" endpoint.
- Support "login with security key" endpoint.
- Use a WebAuthn library that handles:
- challenges
- cryptographic signatures
- public key storage and verification.
The high level idea is:
- The server stores a public key for the device.
- At login, the server sends a random challenge.
- Device signs the challenge with its private key.
- Server verifies the signature with the stored public key.
Hardware security keys are one of the strongest MFA methods, but they are more advanced to integrate.
Designing MFA in Your Backend
Data Model for MFA
You need to store MFA configuration per user. A simple starting point:
| Column | Type | Meaning |
|---|---|---|
| id | integer | user id |
| mfa_enabled | boolean | whether MFA is required for login |
| mfa_type | string | for example totp, sms, email, webauthn |
| mfa_secret | string / blob | TOTP secret or encrypted data |
| phone_number | string | for SMS MFA |
| backup_codes_hashes | json | list of hashed backup codes |
| last_mfa_verified_at | timestamp | last time MFA was successfully passed |
For TOTP you usually store mfa_secret. For SMS or email, you may not need a long term secret, but you need fields to store temporary codes.
You might also use a separate table for MFA methods if you want multiple methods per user.
MFA During Login vs Sensitive Actions
Sometimes MFA is required:
- On every login.
- Only on new devices or new locations.
- Only before sensitive actions, for example:
- viewing stored credit card numbers
- changing password or email
- deleting account
From the backend view, this means:
- you tag the user session with information such as:
mfa_verified_atmfa_level(for examplenone,standard,high)- before sensitive operations, you check that the session has an appropriate MFA status.
Simple examples:
- On login success with MFA, set
mfa_verified_at = now. - For critical actions, require
now - mfa_verified_at < 10 minutes.
Step-up Authentication
Step-up authentication means asking for more authentication when risk is higher.
Example:
- User is logged in with password only and can browse products.
- When the user tries to change email or transfer money, backend responds:
- HTTP 401 or 403 with a body that says
"mfa_required": true. - Client then starts MFA flow. After success, the backend marks session as "steped up".
This is useful when you do not want to ask for MFA every time, but you still want protection for critical operations.
Security Considerations and Best Practices
MFA is security sensitive, so the backend must follow some strict rules.
Rate Limits and Brute Force Protection
Attackers may try many MFA codes. You must limit attempts.
Typical protections:
- Only allow a few attempts per challenge, for example 3 tries.
- Lock the challenge after too many wrong attempts.
- Add rate limiting per:
- user
- IP address
- phone number or email
For example:
- If a user enters wrong TOTP codes 6 times, stop accepting new attempts for that session, and maybe pause MFA login for this account for a short period.
Short Lifetimes
One time codes should expire quickly.
Typical settings:
| Code type | Lifetime |
|---|---|
| TOTP code | 30 seconds |
| SMS code | 3 to 10 minutes |
| Email code | 5 to 15 minutes |
| Challenge token | 5 to 15 minutes |
Always set expiration times for MFA challenges and codes, and check them on the backend for every verification.
Never rely only on the frontend to hide old forms or messages.
Storage and Encryption
For TOTP:
- Store the shared secret in a secure form.
- At minimum, encrypt secrets at rest, for example using a database encryption key.
- Restrict who in your system can read those secrets.
For SMS / email codes:
- Do not store the full code in plain text.
- Store the hash of the code and a random token or id to look it up.
Example model:
| Column | Example |
|---|---|
| id | df0481aa-... |
| user_id | 42 |
| code_hash | hash("928372") |
| expires_at | 2026-08-27T12:35Z |
| attempts_left | 3 |
You send only the code to the user, but the user must also send back the id or some token that lets you find the correct record.
Device and Session Binding
For some MFA methods, you should bind challenges to sessions or devices.
For example:
- When you create an MFA challenge for login, you issue a
mfa_challenge_token. - This token is tied to:
- the user id
- client IP or user agent (optional)
- the login attempt id
- Only that token is allowed to complete that specific login.
This prevents someone from using an MFA code from another device or from reusing an old challenge.
Backup Codes and Recovery
Users will lose their phones or change numbers. To avoid account lockout, you can offer backup codes.
These are one time codes generated in advance, for example 10 random strings like:
BA9F-TK3DZP2L-8RM4- ...
Backend behavior:
- Generate random backup codes.
- Show them to the user once, tell them to store them offline.
- Save only hashes of each code in the database.
- When a user uses a backup code, mark it as used.
Table example:
| Column | Example |
|---|---|
| user_id | 42 |
| code_hash | hash("BA9F-TK3D") |
| used | false |
Rules:
- Treat backup codes like passwords.
- Do not show backup codes again after initial display.
- Limit how many backup codes exist at one time.
UX Considerations That Affect Backend
User experience and backend security go together. Some backend decisions to improve UX:
- Allow codes that ignore spaces, so both
123 456and123456are accepted. - Provide useful error messages:
- "Code expired, please request a new one."
- "Too many attempts, please try again later."
- Allow small clock skew for TOTP, for example accept previous and next time steps.
Still, avoid leaking too much information. For example, do not say "Valid username but wrong MFA code" when revealing that a username exists is sensitive.
Putting It All Together: Example MFA Workflow
Here is a simple sequence for a TOTP MFA system on top of an existing password login.
1. Setup TOTP
- User opens "Enable MFA" page.
- Backend generates:
- a random secret, base32 encoded
- an
otpauth://URI - Backend stores secret temporarily in a
pending_mfa_setuprecord, bound to the user and an expiration. - Backend returns the URI to client.
- Client shows QR code to user.
- User scans QR code and enters a 6 digit code from their app.
- Client sends code to
/mfa/setup/confirmwith a reference to the pending setup. - Backend reads the secret from
pending_mfa_setup. - Backend computes TOTP and compares.
- If it matches:
- move the secret to the permanent user record
- mark
mfa_enabled = true - Delete
pending_mfa_setup.
2. Login with MFA
- Client sends username and password to
/login. - Backend verifies password.
- If user does not have MFA:
- backend issues normal session or JWT and login is complete.
- If user has MFA:
- backend creates
mfa_challengewith: user_idchallenge_idor random tokencreated_atexpires_at, for example now + 5 minutes- backend returns:
- HTTP 200 with
mfa_required: truemfa_challenge_token: ...- optionally list of available methods
- Client shows "Enter your 6 digit code" screen.
- User enters code, client sends to
/login/mfa/verify: mfa_challenge_tokencode- Backend:
- looks up
mfa_challengeby token - checks
expires_at - checks if already used
- loads user and TOTP secret
- verifies code for current time step
- If valid:
- mark
mfa_challengeas used - issue final session cookie or JWT
- mark user session as
mfa_verified_at = now - If not valid:
- decrement attempts counter
- if attempts exceeded, delete challenge or lock it.
With this structure you separate:
- password checking
- MFA verification
- session issuing
This makes your system easier to test and maintain.
When and Where to Use MFA
In a backend application you are building in this course, you will normally add MFA in at least these areas:
- User account section, under "Security" or "Account settings":
- enable or disable MFA
- view and generate backup codes
- Login flow:
- ask for second factor if MFA is enabled
- Sensitive operations:
- step up authentication before:
- changing email
- changing password
- changing MFA settings
MFA is not a silver bullet, but it significantly raises the cost for attackers. Combined with proper password hashing, secure session management, and other security practices from this course, it is a powerful part of a modern backend authentication system.
Views: 4
KAHIBARO