Missing Multi-Factor Authentication

MFA Not EnforcedSecond Factor Missing

Missing Multi-Factor Authentication at a glance

What it is: MFA is not enforced when it should be, or enforcement is easy to bypass with alternate flows or tokens.
Why it happens: MFA bypass occurs when authentication flows fail to enforce MFA for logins or sensitive actions, misapply MFA policies in OAuth flows, or use backup codes without single-use enforcement or rate limits.
How to fix: Enforce consistent MFA across all auth paths, require step-up for sensitive actions, and secure backup codes with single-use limits, throttling, alerts, and device binding.

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.

sequenceDiagram participant Browser participant App as App Server participant DB as Database Browser->>App: POST /login {email, password} App->>DB: SELECT user by email DB-->>App: user { mfaEnabled: true } App-->>Browser: 200 OK + session (no MFA) note over App: Session should be pending until MFA completes
A potential flow for a Missing Multi-Factor Authentication exploit

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.

Vulnerable
JavaScript • Express — Bad
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.

Secure
JavaScript • Express — Good
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.

Vulnerable
JAVASCRIPT
// 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');
});
Secure
JAVASCRIPT
// 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. 1. Baseline authentication check

    http

    Action

    Log in with valid credentials to observe whether MFA is enforced for accounts with it enabled

    Request

    POST https://app.example.com/login
    Headers:
    Content-Type: application/json
    Body:
    {
      "email": "victim@example.com",
      "password": "<VICTIM_PW>"
    }

    Response

    Status: 200
    Body:
    {
      "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. 2. Test remember-me token bypass

    http

    Action

    Check if remember-me cookies bypass MFA enforcement by reusing long-lived tokens

    Request

    GET https://app.example.com/dashboard
    Headers:
    Cookie: remember_token=<REMEMBER_TOKEN>

    Response

    Status: 200
    Body:
    {
      "note": "Full authenticated session restored without MFA prompt, even for sensitive operations"
    }

    Artifacts

    http_response_body session_state remember_token_validation
  3. 3. API token endpoint MFA check

    http

    Action

    Test whether API token generation endpoints require MFA verification

    Request

    POST https://app.example.com/api/tokens
    Headers:
    Cookie: session=<PASSWORD_ONLY_SESSION>
    Content-Type: application/json
    Body:
    {
      "name": "test-token",
      "scopes": [
        "read",
        "write"
      ]
    }

    Response

    Status: 200
    Body:
    {
      "note": "API token created successfully without MFA step-up verification"
    }

    Artifacts

    api_token http_response_body
  4. 4. Sensitive action step-up test

    http

    Action

    Verify whether high-risk actions like email changes require recent MFA verification

    Request

    POST https://app.example.com/account/change-email
    Headers:
    Cookie: session=<OLD_SESSION>
    Content-Type: application/json
    Body:
    {
      "email": "attacker@evil.com"
    }

    Response

    Status: 200
    Body:
    {
      "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. 1. Initial access via password-only authentication

    Login with compromised password

    http

    Action

    Authenticate using phished or leaked credentials without MFA challenge

    Request

    POST https://app.example.com/login
    Headers:
    Content-Type: application/json
    Body:
    {
      "email": "victim@example.com",
      "password": "<COMPROMISED_PW>"
    }

    Response

    Status: 200
    Body:
    {
      "note": "Full authenticated session established without MFA verification"
    }

    Artifacts

    session_cookie http_response_body user_privileges
  2. 2. Change recovery email without MFA step-up

    Hijack account recovery

    http

    Action

    Change account email to attacker-controlled address to enable persistent access

    Request

    POST https://app.example.com/account/change-email
    Headers:
    Cookie: sessionid=<STOLEN_OR_NEW_SID>
    Content-Type: application/x-www-form-urlencoded
    Body:
    "email=attacker%40example.com"

    Response

    Status: 200
    Body:
    {
      "note": "Email changed without MFA prompt, recovery now under attacker control"
    }

    Artifacts

    http_status email_change_confirmation database_record
  3. 3. Generate long-lived API token

    Create persistent access token

    http

    Action

    Generate API token for programmatic access without MFA verification

    Request

    POST https://app.example.com/api/tokens
    Headers:
    Cookie: sessionid=<STOLEN_OR_NEW_SID>
    Content-Type: application/json
    Body:
    {
      "name": "mobile-access",
      "scopes": [
        "*"
      ]
    }

    Response

    Status: 200
    Body:
    {
      "note": "API token created and returned, enabling offline access"
    }

    Artifacts

    api_token token_permissions
  4. 4. Access financial data and perform sensitive operations

    Exfiltrate data and execute transactions

    http

    Action

    Use compromised account to access PII, financial data, and initiate unauthorized transactions

    Request

    GET https://app.example.com/api/account/transactions
    Headers:
    Authorization: Bearer <API_TOKEN>

    Response

    Status: 200
    Body:
    {
      "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