Missing Multi-Factor Authentication
Missing Multi-Factor Authentication at a glance
Overview
MFA reduces risk from stolen passwords and phishing. Many apps implement MFA but fail to enforce it consistently. Common gaps include issuing a full session after only a password even for users who enabled MFA, skipping MFA on token or mobile flows, restoring full sessions from remember-me cookies, or allowing unlimited, reusable backup codes.
Where it occurs
It occurs in authentication flows that skip MFA enforcement, lack step-up verification for sensitive actions, misconfigure OAuth integration, or implement weak backup code controls.
Impact
Attackers who obtain a password or a cookie can gain full access despite MFA being advertised. They can change account recovery email, create API tokens, drain balances, or access PII without triggering step-up.
Prevention
Prevent this vulnerability by requiring MFA for session creation and sensitive actions, unifying authentication policies across platforms, using device binding and risk-based prompts, and securing backup codes with strict, single-use controls.
Examples
Switch tabs to view language/framework variants.
Express login returns session after password check even when MFA is enabled
The handler verifies password and issues a session without checking the MFA flag.
app.post('/login', async (req,res)=>{
const u = await Users.findOne({email:req.body.email});
if (!u || !await compare(req.body.password, u.pw)) return res.status(401).send('bad');
req.session.uid = u.id; // BUG: no MFA gate
res.json({ok:true});
});- Line 4: Session is established before any MFA check
Users with MFA enabled still get a session after only a password, which defeats MFA.
app.post('/login', async (req,res)=>{
const u = await Users.findOne({email:req.body.email});
if (!u || !await compare(req.body.password, u.pw)) return res.status(401).send('bad');
if (u.mfaEnabled) { req.session.pendingUid = u.id; return res.status(202).json({mfa:'required'}); }
req.session.uid = u.id; res.json({ok:true});
});
app.post('/mfa/totp', async (req,res)=>{
const u = await Users.findById(req.session.pendingUid);
if (!u || !verifyTOTP(u.totpSecret, req.body.code)) return res.status(401).send('bad');
req.session.uid = u.id; delete req.session.pendingUid; res.json({ok:true});
});- Line 4: Branch to MFA pending state, do not grant full session until TOTP verified
Gate session issuance on successful MFA challenge when the account requires it.
Engineer Checklist
-
Do not establish full sessions until MFA completes for accounts that require it
-
Require step-up MFA for sensitive actions and recent MFA freshness
-
Align OAuth and API token endpoints with MFA policy
-
Implement device binding or risk-based re-prompting for high risk logins
-
Make backup codes single-use, high entropy, and rate limited, with alerting on failures
End-to-End Example
A fintech app supports TOTP MFA. The login handler establishes a session after only a password and never checks the user's mfaEnabled flag. An attacker who phishes a password logs in and immediately changes the email and generates an API token.
// Node.js/Express - Vulnerable missing MFA enforcement
const bcrypt = require('bcrypt');
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// VULNERABLE: Grants full session without checking if MFA is required!
// Even though user.mfaEnabled = true, we skip MFA verification
req.session.userId = user._id;
req.session.authenticated = true;
req.session.role = user.role;
res.json({
message: 'Login successful',
userId: user._id,
// Note: User has mfaEnabled=true but we didn't check it!
requiresMfa: user.mfaEnabled // We return this but don't enforce it
});
});
// ALSO VULNERABLE: Sensitive action with no MFA step-up
app.post('/api/change-email', authenticateSession, async (req, res) => {
const userId = req.session.userId;
const newEmail = req.body.email;
// VULNERABLE: No MFA verification required for sensitive action!
// Attacker with just password can change recovery email
await User.findByIdAndUpdate(userId, { email: newEmail });
res.json({ message: 'Email updated' });
});
// VULNERABLE: Remember-me cookie bypasses MFA
app.get('/auto-login', async (req, res) => {
const rememberToken = req.cookies.remember_me;
if (rememberToken) {
const user = await User.findOne({ rememberToken });
if (user) {
// VULNERABLE: Full session from remember-me without MFA!
req.session.userId = user._id;
req.session.authenticated = true;
return res.redirect('/dashboard');
}
}
res.redirect('/login');
});// Node.js/Express - SECURE MFA enforcement
const speakeasy = require('speakeasy');
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// SECURE: Check if user has MFA enabled
if (user.mfaEnabled) {
// Create PENDING session - not fully authenticated yet
req.session.pendingUserId = user._id;
req.session.passwordVerified = true;
req.session.mfaRequired = true;
// Don't grant full access yet!
return res.status(202).json({
message: 'Password verified',
requiresMfa: true,
nextStep: '/verify-mfa'
});
}
// User doesn't have MFA - grant full session
req.session.userId = user._id;
req.session.authenticated = true;
req.session.mfaVerifiedAt = new Date();
res.json({ message: 'Login successful' });
});
app.post('/verify-mfa', async (req, res) => {
const { totpCode } = req.body;
// Check for pending session
if (!req.session.pendingUserId || !req.session.passwordVerified) {
return res.status(401).json({ error: 'No pending authentication' });
}
const user = await User.findById(req.session.pendingUserId);
// Verify TOTP code
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: totpCode,
window: 1 // Allow 1 step time skew
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
// SECURE: Now grant full session after MFA verification
req.session.userId = user._id;
req.session.authenticated = true;
req.session.mfaVerifiedAt = new Date();
// Clear pending state
delete req.session.pendingUserId;
delete req.session.passwordVerified;
delete req.session.mfaRequired;
res.json({ message: 'MFA verified, login complete' });
});
// SECURE: Sensitive actions require recent MFA
function requireRecentMfa(maxAge = 15 * 60 * 1000) { // 15 minutes
return (req, res, next) => {
if (!req.session.authenticated) {
return res.status(401).json({ error: 'Not authenticated' });
}
const mfaAge = Date.now() - new Date(req.session.mfaVerifiedAt).getTime();
if (mfaAge > maxAge) {
return res.status(403).json({
error: 'MFA verification required',
stepUp: '/verify-mfa'
});
}
next();
};
}
app.post('/api/change-email', requireRecentMfa(), async (req, res) => {
const userId = req.session.userId;
const newEmail = req.body.email;
// SECURE: Only allowed after recent MFA verification
await User.findByIdAndUpdate(userId, { email: newEmail });
res.json({ message: 'Email updated' });
});
// SECURE: Remember-me still requires MFA if enabled
app.get('/auto-login', async (req, res) => {
const rememberToken = req.cookies.remember_me;
if (rememberToken) {
const user = await User.findOne({ rememberToken });
if (user) {
if (user.mfaEnabled) {
// SECURE: Create pending session, still need MFA
req.session.pendingUserId = user._id;
req.session.passwordVerified = true;
req.session.mfaRequired = true;
return res.redirect('/verify-mfa');
}
// No MFA required - grant full session
req.session.userId = user._id;
req.session.authenticated = true;
return res.redirect('/dashboard');
}
}
res.redirect('/login');
});Discovery
This vulnerability is discovered by analyzing authentication flows and observing that the application relies solely on password authentication without requiring or offering multi-factor authentication options, especially for privileged accounts.
-
1. Baseline authentication check
httpAction
Log in with valid credentials to observe whether MFA is enforced for accounts with it enabled
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "victim@example.com", "password": "<VICTIM_PW>" }Response
Status: 200Body:{ "note": "200 OK with full session immediately, no MFA challenge despite account having MFA enabled" }Artifacts
http_response_headers session_cookie user_account_metadata -
2. Test remember-me token bypass
httpAction
Check if remember-me cookies bypass MFA enforcement by reusing long-lived tokens
Request
GET https://app.example.com/dashboardHeaders:Cookie: remember_token=<REMEMBER_TOKEN>Response
Status: 200Body:{ "note": "Full authenticated session restored without MFA prompt, even for sensitive operations" }Artifacts
http_response_body session_state remember_token_validation -
3. API token endpoint MFA check
httpAction
Test whether API token generation endpoints require MFA verification
Request
POST https://app.example.com/api/tokensHeaders:Cookie: session=<PASSWORD_ONLY_SESSION>Content-Type: application/jsonBody:{ "name": "test-token", "scopes": [ "read", "write" ] }Response
Status: 200Body:{ "note": "API token created successfully without MFA step-up verification" }Artifacts
api_token http_response_body -
4. Sensitive action step-up test
httpAction
Verify whether high-risk actions like email changes require recent MFA verification
Request
POST https://app.example.com/account/change-emailHeaders:Cookie: session=<OLD_SESSION>Content-Type: application/jsonBody:{ "email": "attacker@evil.com" }Response
Status: 200Body:{ "note": "Email changed without MFA step-up, enabling account takeover vector" }Artifacts
http_response_status email_change_confirmation audit_log
Exploit steps
An attacker exploits this by using compromised credentials (from phishing, breaches, or brute force) to gain full account access without facing additional authentication challenges, making credential-based attacks significantly more effective.
-
1. Initial access via password-only authentication
Login with compromised password
httpAction
Authenticate using phished or leaked credentials without MFA challenge
Request
POST https://app.example.com/loginHeaders:Content-Type: application/jsonBody:{ "email": "victim@example.com", "password": "<COMPROMISED_PW>" }Response
Status: 200Body:{ "note": "Full authenticated session established without MFA verification" }Artifacts
session_cookie http_response_body user_privileges -
2. Change recovery email without MFA step-up
Hijack account recovery
httpAction
Change account email to attacker-controlled address to enable persistent access
Request
POST https://app.example.com/account/change-emailHeaders:Cookie: sessionid=<STOLEN_OR_NEW_SID>Content-Type: application/x-www-form-urlencodedBody:"email=attacker%40example.com"
Response
Status: 200Body:{ "note": "Email changed without MFA prompt, recovery now under attacker control" }Artifacts
http_status email_change_confirmation database_record -
3. Generate long-lived API token
Create persistent access token
httpAction
Generate API token for programmatic access without MFA verification
Request
POST https://app.example.com/api/tokensHeaders:Cookie: sessionid=<STOLEN_OR_NEW_SID>Content-Type: application/jsonBody:{ "name": "mobile-access", "scopes": [ "*" ] }Response
Status: 200Body:{ "note": "API token created and returned, enabling offline access" }Artifacts
api_token token_permissions -
4. Access financial data and perform sensitive operations
Exfiltrate data and execute transactions
httpAction
Use compromised account to access PII, financial data, and initiate unauthorized transactions
Request
GET https://app.example.com/api/account/transactionsHeaders:Authorization: Bearer <API_TOKEN>Response
Status: 200Body:{ "note": "Full access to transaction history, balances, and ability to initiate transfers" }Artifacts
financial_records transaction_data pii_exposure
Specific Impact
The attacker gains persistent control of the account by changing recovery email and minting tokens. They can access financial data and transfer funds without further prompts. Incident response must rotate tokens, revert email changes, and review transactions.
Because MFA was advertised but not enforced, trust is damaged and regulators may treat this as a preventable breach. The team must demonstrate consistent MFA enforcement across all auth paths.
Fix
Do not issue a full session until MFA completes for accounts with MFA enabled. Require a recent MFA verification for sensitive actions. Align OAuth and mobile flows with the same policy. Make backup codes single-use and rate limited. Add tests that verify enforcement and that alternate flows do not bypass MFA.
Detect This Vulnerability in Your Code
Sourcery automatically identifies missing multi-factor authentication vulnerabilities and many other security issues in your codebase.
Scan Your Code for Free