Demystifying Multifactor Authentication

Demystifying Multifactor Authentication

  1. 2022-07-16 21:00
  2. 10 min read

Authentication by username and password is well understood, but it’s also notoriously insecure. Adding extra factors—like a smartphone, hardware key, or biometric—strengthens login flows considerably. But what exactly is an authentication factor? And how do you evaluate the trade-offs between different approaches?

Understanding MFA deeply matters for every developer who builds authentication systems. Let’s cut through the confusion.

The Authentication Triad

Every authentication scheme relies on three components:

  • Something you know: A password, PIN, or secret answer
  • Something you have: A phone, hardware token, or smart card
  • Something you are: A fingerprint, face, or retina scan

Strong authentication requires factors from at least two of these categories. A password alone only covers “something you know.” Adding a TOTP code from your phone adds “something you have.” Proper MFA combines multiple categories to create defense in depth.

Identification vs. Authentication

Here’s a distinction that trips up many developers: identification is not authentication.

Biometrics—fingerprints, facial recognition, retina scans—are fundamentally identification mechanisms. They answer “who are you?” but not “do you intend to authenticate?”

When you unlock your iPhone with Face ID, you’re identifying yourself. The phone then uses that identification to authorize access to a password manager, which then authenticates you to services. The biometric alone doesn’t authenticate; it authorizes access to the thing that does the authenticating.

This distinction matters because identification is passive. There’s no intent verification. A fingerprint on a sensor proves your finger is there, but it doesn’t prove you meant to log in to a specific service. That’s why biometrics alone aren’t sufficient for strong authentication.

Types of MFA Factors

SMS and Email Codes

The most common form of “something you have” is a one-time code sent via SMS or email. The user proves possession of a phone number or email inbox.

Strengths: Universal (every phone supports SMS), familiar UX.

Weaknesses: SIM swapping attacks, SMS interception, email account compromise. NIST has deprecated SMS as an out-of-band verification method for this reason.

TOTP (Time-based One-Time Passwords)

TOTP generates codes from a shared secret and the current time. Both sides compute the same code, and the user proves they possess the shared secret (seeded into an authenticator app).

<?php
 
declare(strict_types=1);
 
/**
 * Generate a TOTP-compliant one-time password
 */
function generateTOTP(
    string $secret,
    int $timeSlice = 30,
    int $digits = 6
): string {
    $counter = intdiv(time(), $timeSlice);
    $counterBin = pack('N*', 0) . pack('N*', $counter);
 
    $hash = hash_hmac('sha1', $counterBin, base64_decode($secret), true);
 
    $offset = ord($hash[-1]) & 0x0f;
    $code = (
        (ord($hash[$offset]) & 0x7f) << 24 |
        (ord($hash[$offset + 1]) & 0xff) << 16 |
        (ord($hash[$offset + 2]) & 0xff) << 8 |
        (ord($hash[$offset + 3]) & 0xff)
    ) % (10 ** $digits);
 
    return str_pad((string) $code, $digits, '0', STR_PAD_LEFT);
}
 
/**
 * Verify a TOTP code with a window to handle clock drift
 */
function verifyTOTP(
    string $secret,
    string $code,
    int $window = 1,
    int $timeSlice = 30
): bool {
    $counter = intdiv(time(), $timeSlice);
 
    for ($i = -$window; $i <= $window; $i++) {
        if (generateTOTP($secret, $timeSlice, 6) === $code) {
            return true;
        }
    }
 
    return false;
}

In Laravel, you’d integrate TOTP verification into a custom authentication guard:

<?php
 
namespace App\Actions;
 
use App\Models\User;
use Illuminate\Support\Facades\Hash;
 
class AuthenticateWithMfa
{
    public function __invoke(array $credentials, string $totp): ?User
    {
        $user = User::where('email', $credentials['email'])->first();
 
        if (!$user || !Hash::check($credentials['password'], $user->password)) {
            return null;
        }
 
        if (!verifyTOTP($user->totp_secret, $totp)) {
            return null;
        }
 
        return $user;
    }
}

Strengths: No network dependency after initial seed, works offline, standardized (RFC 6238), widely supported.

Weaknesses: Vulnerable to phishing (user types code into attacker’s site), time synchronization required, no built-in MITM protection.

Hardware Security Keys (FIDO2/WebAuthn)

Hardware keys like YubiKeys implement the FIDO2/WebAuthn standard. The key performs a cryptographic challenge-response with the server. The private key never leaves the device.

<?php
 
declare(strict_types=1);
 
namespace App\Http\Controllers\Auth;
 
use Illuminate\Http\Request;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthh\PublicKeyCredentialSourceRepository;
 
class WebAuthnLoginController
{
    public function challenge(Request $request)
    {
        $options = new PublicKeyCredentialRequestOptions(
            challenge: random_bytes(32),
            rpId: config('app.domain'),
            timeout: 60000,
        );
 
        $request->session()->put('webauthn_challenge', $options);
 
        return response()->json($options);
    }
 
    public function verify(Request $request)
    {
        $credential = $request->input('credential');
        // Verify assertion against stored public key
        // If valid, log the user in
    }
}

Strengths: Phishing-resistant (bound to origin), no shared secrets, hardware-level security, supports passwordless flows.

Weaknesses: Hardware cost, device loss requires backup keys, less familiar UX for average users.

Push Notifications

The user receives a push notification on their phone and approves or denies the login attempt.

Strengths: Best UX (single tap), real-time, user sees contextual info (location, device, time).

Weaknesses: Requires network connectivity, push notification delays, notification fatigue.

2FA vs. MFA

Many developers use “2FA” and “MFA” interchangeably, but they’re not the same. 2FA (two-factor authentication) uses exactly two factors. MFA uses two or more.

The real distinction matters when evaluating security: are you using two factors from the same category (two “something you know” items doesn’t count as MFA) or from different categories? A password plus a security question is still single-factor authentication—you’re just verifying two things you know.

The Recovery Problem

Every MFA system must answer the question: what happens when the user loses their factor? A lost phone, broken hardware key, or factory-reset device can lock users out permanently if you haven’t planned for recovery.

Recovery codes are the most common approach. Generate 8-12 single-use codes when the user sets up MFA. Each code is a long random string that bypasses MFA. Store them hashed in the database. Show them once to the user; they must save them somewhere safe.

<?php
 
declare(strict_types=1);
 
namespace App\Actions;
 
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
 
class GenerateRecoveryCodes
{
    private const CODE_COUNT = 10;
 
    /**
     * @return array<int, string> Plain-text codes to show the user
     */
    public function __invoke(User $user): array
    {
        $plainCodes = [];
        $hashedCodes = [];
 
        for ($i = 0; $i < self::CODE_COUNT; $i++) {
            $code = Str::random(32);
            $plainCodes[] = $code;
            $hashedCodes[] = Hash::make($code);
        }
 
        $user->recovery_codes = json_encode($hashedCodes);
        $user->save();
 
        return $plainCodes;
    }
}

Fallback MFA methods provide alternative verification paths. If the user has TOTP as primary and has set up a hardware key as backup, losing the phone doesn’t mean losing access. The trade-off is that each additional method increases attack surface.

Admin override should exist but be heavily audited. A “bypass MFA for 24 hours” capability, logged with full context and limited to senior administrators, provides an emergency escape valve for production incidents.

Social recovery is an emerging pattern where trusted contacts can vouch for the user’s identity. This is complex to implement securely but can be the most user-friendly option for consumer applications.

MFA Deployment Checklist

Before shipping MFA to production, verify every item on this checklist:

  1. Rate limiting on MFA endpoints: Maximum 5 attempts per minute per user
  2. Account lockout after N failures: Temporary lock after 10 failed MFA attempts
  3. Session binding: The MFA verification is tied to the initial authentication session
  4. Device fingerprinting: Store user agent, IP, and other context with trusted devices
  5. Audit logging: Every MFA attempt (success and failure) logged with timestamp, IP, device
  6. Backup codes rendered exactly once: No way to retrieve codes after the initial display
  7. Clear user feedback: “MFA code required” vs. “MFA code incorrect” without leaking information
  8. MFA setup enforcement: Option to require MFA for all users or based on roles
  9. Grace period: Allow existing users a window to set up MFA before enforcement begins
  10. Monitoring and alerting: Alert on unusual MFA failure patterns (possible brute force)

Implementing MFA in PHP Applications

Symfony with LexikJWTAuthenticationBundle

<?php
 
namespace App\Security;
 
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
 
class MfaAuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    public function onAuthenticationSuccess(Request $request, TokenInterface $token): JsonResponse
    {
        $user = $token->getUser();
 
        if (!$user->isMfaVerified()) {
            return new JsonResponse([
                'mfa_required' => true,
                'mfa_token' => $this->generateMfaToken($user),
            ]);
        }
 
        return new JsonResponse([
            'token' => $this->generateJwt($user),
        ]);
    }
}

Laravel with Sanctum

<?php
 
namespace App\Http\Middleware;
 
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
 
class RequireMfa
{
    public function handle(Request $request, Closure $next)
    {
        if (
            Auth::check()
            && Auth::user()->hasMfaEnabled()
            && !$request->session()->get('mfa_verified')
        ) {
            return redirect()->route('mfa.challenge');
        }
 
        return $next($request);
    }
}

Security Considerations

Rate limiting: MFA attempts must be rate-limited. Without it, an attacker can brute-force TOTP codes (only 1,000,000 possibilities for 6 digits).

Recovery codes: When enabling MFA, generate 8-10 single-use recovery codes. Store them hashed in the database and show them exactly once to the user.

Device management: Let users view and revoke trusted devices. An “remember this device for 30 days” option balances security with UX for trusted environments.

Backup methods: Support at least two MFA methods. If a user loses their phone and only has TOTP, they’re locked out. Backup codes or a second hardware key prevent this.

Timing attacks: When verifying TOTP codes, use hash_equals() or a constant-time comparison to prevent timing leaks. The verification loop example above must compare codes in constant time.

Session invalidation: After MFA verification, invalidate the MFA session token if the user logs out or changes their password.

UX Considerations

Adaptive MFA

Not every login requires the same level of authentication. Adaptive MFA (sometimes called risk-based authentication) adjusts the number and type of factors based on contextual risk signals:

  • Low risk: Recognized device, same IP range, normal geolocation → password only or skip MFA
  • Medium risk: New device, different city, unusual time → password + TOTP
  • High risk: Unknown IP, different country, known attacker IP range → password + hardware key + admin notification
<?php
 
declare(strict_types=1);
 
namespace App\Security;
 
use App\Models\User;
use Illuminate\Http\Request;
 
class RiskEvaluator
{
    public function evaluate(Request $request, User $user): RiskLevel
    {
        $riskScore = 0;
 
        // Device recognition
        $deviceHash = md5($request->userAgent() . $request->ip());
        if (!$user->knownDevices()->where('hash', $deviceHash)->exists()) {
            $riskScore += 30;
        }
 
        // Geographic anomaly
        $geo = geoip($request->ip());
        if ($geo->country !== $user->lastLoginCountry) {
            $riskScore += 25;
        }
 
        // Time anomaly
        $hour = now()->hour;
        if ($user->typicalLoginHours && !in_array($hour, $user->typicalLoginHours)) {
            $riskScore += 15;
        }
 
        // Known bad IPs
        if ($this->isKnownMaliciousIp($request->ip())) {
            $riskScore += 50;
        }
 
        return match (true) {
            $riskScore >= 60 => RiskLevel::High,
            $riskScore >= 25 => RiskLevel::Medium,
            default => RiskLevel::Low,
        };
    }
}
 
enum RiskLevel: string
{
    case Low = 'low';
    case Medium = 'medium';
    case High = 'high';
 
    public function requiresMfa(): bool
    {
        return $this !== self::Low;
    }
 
    public function requiredFactors(): int
    {
        return match ($this) {
            self::High => 2, // Password + hardware key
            self::Medium => 1, // Password + any second factor
            self::Low => 0, // Password only
        };
    }
}

Adaptive MFA reduces friction for legitimate users while increasing security precisely when it’s needed most. Symfony’s security component supports this pattern through voter-based access control, and Laravel can implement it through middleware that evaluates the request context.

MFA introduces friction. The goal is to make that friction purposeful and minimal:

  • Allow “remember this device” cookies for trusted machines
  • Support multiple authenticator apps (Google Authenticator, Authy, 1Password)
  • Clearly label which MFA method the user is using
  • Provide setup instructions with QR codes (TOTP) or step-by-step guides (hardware keys)
  • Offer fallback methods for every flow

The Future: Passkeys

Passkeys are the next evolution of MFA. Built on FIDO2/WebAuthn, they replace passwords with device-bound cryptographic credentials synced across devices via cloud services. A passkey is “something you have” (the device) plus “something you are” (biometric to unlock) without requiring a password at all.

Laravel and Symfony applications can already implement passkey authentication using packages like laravel-webauthn or by integrating with the web-auth/webauthn-lib PHP library. As browser and operating system support expands, passkeys will likely become the default authentication method for most applications.

Conclusion

MFA isn’t a single feature—it’s a category of approaches with different security properties and UX trade-offs. SMS codes are universally available but phishable. TOTP is the current standard for balance of security and usability. Hardware keys provide the strongest protection but at a cost and UX premium.

The best MFA implementation is the one your users will actually use. Support multiple methods, provide clear setup flows, and always offer recovery options. Your authentication system is only as strong as its weakest factor.

MFA Authentication Security PHP 2FA TOTP