Skip to content

Multi-factor authentication is not enforced

CWE-308CWE-287OWASP A07:2021Updated September 4, 20265 min read

An application that offers two-factor authentication but does not require it protects, in practice, only the users who think of it themselves. That is a small minority, and rarely the accounts with the most rights. MFA enforced only in the web interface and not on the API is effectively optional as well.

Offering two-factor authentication and enforcing it are two very different measures with very different results. The first reads well in documentation; the second protects your users. The voluntary variant achieves very little, and introducing the requirement without causing an outage takes a plan.

What does “not enforced” mean?

Multi-factor authentication that is not enforced is a second factor the application supports but does not require. Users can switch it on; they do not have to. The functionality is present, the protection is optional.

Three forms occur in practice and all amount to the same thing. The first is the purely optional variant, hidden in a settings screen few users ever open. The second is the variant mandatory for ordinary users but not for administrators, because they “know what they are doing”, while those accounts carry the most rights. The third is the variant enforced in the web interface but not on the API, which means the requirement can be bypassed with one different endpoint.

Compare it with a building where visitors may identify themselves if they wish. The measure exists, there is a desk, there is a procedure. Only the visitor decides whether to take part, and that is exactly the wrong party to leave the choice to.

How does an attacker bypass optional MFA?

Vulnerable:

app.post('/login', async (req, res) => {
  const user = await checkCredentials(req.body);
  if (!user) return res.status(401).send('Invalid credentials');

  if (user.mfaEnabled) {                     // only if the user wanted it
    req.session.secondFactorFor = user.id;
    return res.redirect('/login/verify');
  }

  req.session.userId = user.id;              // otherwise straight in
  res.send('Welcome');
});

// The API does not know that step at all
app.post('/api/token', async (req, res) => {
  const user = await checkCredentials(req.body);
  if (!user) return res.sendStatus(401);
  res.json({ token: makeToken(user) });
});

Two problems at once. Verification depends on a setting the user has to have enabled themselves, and the second endpoint skips the step entirely. Even a user who has neatly enabled MFA can be reached through /api/token with a password alone:

POST /api/token HTTP/1.1
Content-Type: application/json

{"email":"admin@company.com","password":"<from an earlier breach>"}

HTTP/1.1 200 OK
{"token":"eyJhbGciOiJIUzI1NiIs..."}

The whole measure has been bypassed with a request to a different address.

Safe:

// One place decides whether someone gets a session
async function maySessionBeCreated(user) {
  const required = user.role === 'admin'
    || user.role === 'finance'
    || POLICY.mfaForEveryone;

  if (!user.mfaEnabled && required) return { status: 'setup-required' };
  if (user.mfaEnabled) return { status: 'verification-required' };
  return { status: 'ok' };
}

app.post('/login', async (req, res) => {
  const user = await checkCredentials(req.body);
  if (!user) return res.status(401).send('Invalid credentials');

  const { status } = await maySessionBeCreated(user);
  if (status === 'setup-required') {
    req.session.mfaSetupFor = user.id;              // restricted state
    return res.redirect('/mfa/setup');
  }
  if (status === 'verification-required') {
    req.session.secondFactorFor = user.id;
    return res.redirect('/login/verify');
  }
  req.session.regenerate(() => { req.session.userId = user.id; res.send('Welcome'); });
});

The decisive difference is that there is now only one place where it is decided whether someone gets a session, and every channel, web interface, API and mobile application, passes through it. A user for whom MFA is mandatory but who has not yet set it up ends up in a restricted state where they can only arrange that.

Watch the older channels. An application with proper MFA in the web interface that also supports a legacy protocol, an old API version, a connection using basic authentication or an integration with a fixed key, does not have that requirement in practice. Attackers look precisely for that one channel.

What is the impact of MFA that is not enforced?

The severity is medium to high, depending on which accounts remain unprotected. In practice that is an unfavourable selection: users who enable MFA voluntarily tend to be the more security-aware ones, while the accounts carrying the most risk often belong to busy managers and administrators.

The result is that you have the measure but not the protection. In an attack using credentials from an earlier breach, it does not matter what percentage of your users has a second factor; it matters whether the account the attacker hits had one. Across thousands of automated attempts, that is a matter of statistics.

There is also an organisational effect that weighs heavier than the technical one. An optional measure gives the organisation the feeling that MFA is “arranged”. In audits, in reports and in conversations with customers it is presented that way too. The difference between available and mandatory often goes unmentioned, which makes the risk invisible rather than solved.

How do you detect MFA that is not enforced?

A tester first checks whether MFA is available and whether it is required, and for which roles. Then they try whether the requirement can be bypassed.

That happens along a number of fixed routes. Can an account with MFA enabled still obtain a session or token through the API, through an older API version or through a mobile endpoint? Can the verification screen be skipped by calling a protected route directly after the password step? Is there a “remember this device” function that skips verification for a long period, and is it bound to the device? Does the password reset route work without a second factor, which would make the whole measure moot through a detour? AssistSec also explicitly tests the accounts with the highest privileges, because an exception for administrators is the most common in practice and at the same time the heaviest.

How do you prevent MFA that is not enforced?

  • Make multi-factor authentication mandatory for administrators and for roles with access to sensitive data.
  • Introduce the requirement in phases with an announcement, a grace period and a setup screen after login.
  • Decide in one central place whether someone gets a session, so every channel follows the same rules.
  • Enforce verification on the API, on mobile endpoints and on older versions, not only in the web interface.
  • Close off legacy protocols and integrations that permit authentication without a second factor.
  • Bind a “remember this device” function to the device, give it a limited lifetime and make it revocable.
  • Ask for the second factor at password reset too, so that route does not become a detour.
  • Register exceptions with an end date and restrict the rights of those accounts.
  • Report periodically what share of accounts actually uses a second factor, broken down by role.

Sources

Frequently asked questions

How do I introduce a requirement without locking users out?

In phases. Start with administrators and sensitive roles, announce the change with a deadline, and show a screen after login that asks for setup with the option to postpone a number of times. Then make it mandatory. The burden shifts to the moment of setup rather than the moment of exclusion.

Why is the API so often the weak spot?

Because the second factor is enforced in the web interface while the API issues tokens with the same credentials without that step. The requirement is then bypassed simply by using a different endpoint. Enforce verification where the session is created, not in the interface.

Should I require MFA for all users?

For administrators and roles with access to sensitive data, without exception. For ordinary users it depends on your audience and on what sits behind the account. At the very least, offer it enabled by default, so that switching it off is a deliberate choice rather than switching it on.

What do I do with accounts that cannot use it?

Treat exceptions as exceptions: register them, give them an end date and restrict their rights. A permanent exception list that nobody reviews any more is precisely the detour an attacker looks for.

Related articles

Press / to search · Esc