Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
Two-factor authentication is one of the highest-impact security improvements you can add to any application that handles user accounts — and one of the most commonly implemented incorrectly. "Adding 2FA" to an app can mean anything from a genuinely phishing-resistant hardware key flow to a SMS OTP implementation that's trivially bypassable through SIM swapping. The difference between strong and weak 2FA matters significantly in practice, and building it well is only moderately more work than building it poorly.
This post covers the 2FA landscape from a developer's perspective — the different factors, how TOTP works technically, a complete implementation in PHP, and how to think about 2FA bypass scenarios that your implementation needs to handle.
Why 2FA Matters More in 2026 Than It Did in 2020
The threat environment for password-only authentication has changed meaningfully. Credential stuffing attacks (testing breached credentials against other services) run continuously against every significant login endpoint. AI-generated phishing captures credentials from users who would have spotted cruder fake sites. Infostealer malware harvests saved browser credentials from devices at scale.
In this environment, a stolen or leaked password alone isn't enough for an attacker to access an account protected by properly implemented 2FA. This is the core value: 2FA means that breached credential databases become dramatically less useful for account takeover, because knowing the password is no longer sufficient. It doesn't solve all authentication problems, but it eliminates the largest single class of account compromise by a significant margin.
The 2FA Landscape: Not All Second Factors Are Equal
Second authentication factors vary significantly in their security properties:
SMS OTP (weakest widely-used option). A one-time code sent via text message. The vulnerabilities are well-documented: SIM swapping (convincing a carrier to transfer someone's phone number to an attacker-controlled SIM) is a viable attack against targeted individuals; SS7 vulnerabilities in the phone network allow sophisticated attackers to intercept SMS messages; phishing sites can relay OTPs in real time (the user enters the code on the fake site, the attacker immediately uses it on the real site). SMS 2FA is substantially better than no 2FA, but for applications with high-value accounts, it should be a fallback rather than the primary option.
TOTP authenticator apps (strong option). Time-based One-Time Passwords generated by an authenticator app (Google Authenticator, Authy, 1Password, etc.) are based on a shared secret and the current time. They don't require a network connection to generate, they're not interceptable via SS7, and they rotate every 30 seconds. The main vulnerabilities are real-time phishing (same as SMS — a fast attacker can relay the code) and device loss (though cloud-syncing authenticators like Authy mitigate this). TOTP is the right choice for most web applications.
Hardware security keys / Passkeys (strongest option). FIDO2/WebAuthn credentials — physical keys like YubiKey, or platform passkeys stored in device secure enclaves — are phishing-resistant by design. The credential is cryptographically bound to the specific origin it was created for; there's no code to relay to a fake site because the protocol doesn't use codes. For highest-security accounts, WebAuthn should be the preferred option. Browser support is now universal across all major platforms.
App-based push notifications (convenient, moderate security). Services like Duo Security or TOTP apps with push approval send a push notification to the user's phone asking them to approve or deny the login. Convenient but vulnerable to "MFA fatigue" attacks — attackers send many push notifications hoping the user approves one to make them stop. Duo has mitigated this with number matching (the user must enter the number shown on the login screen), but raw push approval without number matching is weaker than TOTP.
How TOTP Works Technically
Understanding the TOTP algorithm helps you implement it correctly and understand its security properties. TOTP (RFC 6238) is built on HOTP (HMAC-based One-Time Password, RFC 4226).
The process: during 2FA setup, you generate a random secret key (typically 80-160 bits) and share it between your server and the user's authenticator app — usually via a QR code. When the user needs a code, the authenticator app and your server both independently compute: HOTP(secret, counter) where the counter is derived from the current Unix timestamp divided by 30 (the standard 30-second interval). Since both sides share the secret and use the same time, they compute the same value. The computed value is truncated to 6 digits using a specific extraction algorithm defined in the RFC.
<?php
/**
* Pure PHP TOTP implementation (for understanding)
* In production, use a library like pragmarx/google2fa or OTPHP
*/
class TOTPGenerator {
/**
* Generate the current TOTP code for a given base32-encoded secret
*/
public function getCurrentCode(string $base32Secret): string {
$secret = $this->base32Decode($base32Secret);
$counter = floor(time() / 30); // 30-second time steps
return $this->generateHOTP($secret, $counter);
}
/**
* Verify a TOTP code, allowing for clock drift
*/
public function verifyCode(string $base32Secret, string $userCode, int $window = 1): bool {
$secret = $this->base32Decode($base32Secret);
$currentCounter = floor(time() / 30);
// Check current time step and adjacent windows (handles clock drift)
for ($i = -$window; $i <= $window; $i++) {
$expected = $this->generateHOTP($secret, $currentCounter + $i);
if (hash_equals($expected, $userCode)) {
return true;
}
}
return false;
}
private function generateHOTP(string $secret, int $counter): string {
$data = pack('N*', 0) . pack('N*', $counter); // 8-byte counter, big-endian
$hash = hash_hmac('sha1', $data, $secret, true);
$offset = ord($hash[19]) & 0xf;
$code = (
((ord($hash[$offset]) & 0x7f) << 24) |
((ord($hash[$offset + 1]) & 0xff) << 16) |
((ord($hash[$offset + 2]) & 0xff) << 8) |
(ord($hash[$offset + 3]) & 0xff)
) % 1000000;
return str_pad((string)$code, 6, '0', STR_PAD_LEFT);
}
private function base32Decode(string $input): string {
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$input = strtoupper(rtrim($input, '='));
$output = '';
$bits = 0;
$value = 0;
foreach (str_split($input) as $char) {
$value = ($value << 5) | strpos($base32chars, $char);
$bits += 5;
if ($bits >= 8) {
$output .= chr(($value >> ($bits - 8)) & 0xFF);
$bits -= 8;
}
}
return $output;
}
}
Complete TOTP Implementation in PHP
For production use, I recommend the pragmarx/google2fa package rather than the raw implementation above. Here's a complete setup and verification flow:
<?php
// composer require pragmarx/google2fa
// composer require bacon/bacon-qr-code (for QR generation)
use PragmaRXGoogle2FAGoogle2FA;
class TwoFactorAuthManager {
private Google2FA $google2fa;
private PDO $db;
public function __construct(PDO $db) {
$this->google2fa = new Google2FA();
$this->db = $db;
}
/**
* Generate a new 2FA setup for a user
* Returns secret and QR code URL
*/
public function generateSetup(int $userId, string $userEmail, string $appName): array {
$secret = $this->google2fa->generateSecretKey(32); // 160-bit secret
// Store pending secret (not yet confirmed)
$stmt = $this->db->prepare(
"UPDATE users SET totp_secret_pending = ? WHERE id = ?"
);
$stmt->execute([$secret, $userId]);
// Generate QR code URL (user scans with authenticator app)
$qrUrl = $this->google2fa->getQRCodeUrl(
$appName,
$userEmail,
$secret
);
return [
'secret' => $secret,
'qr_url' => $qrUrl,
'backup_codes' => $this->generateBackupCodes()
];
}
/**
* Confirm 2FA setup by verifying the first code from the app
* This ensures the user has successfully configured their authenticator
*/
public function confirmSetup(int $userId, string $code): bool {
$stmt = $this->db->prepare(
"SELECT totp_secret_pending FROM users WHERE id = ?"
);
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user || !$user['totp_secret_pending']) {
return false;
}
if (!$this->google2fa->verifyKey($user['totp_secret_pending'], $code)) {
return false;
}
// Move pending secret to active
$stmt = $this->db->prepare(
"UPDATE users
SET totp_secret = totp_secret_pending,
totp_secret_pending = NULL,
totp_enabled = 1
WHERE id = ?"
);
$stmt->execute([$userId]);
return true;
}
/**
* Verify a TOTP code during login
* Includes replay attack prevention via used_codes tracking
*/
public function verifyLoginCode(int $userId, string $code): bool {
$stmt = $this->db->prepare(
"SELECT totp_secret FROM users WHERE id = ? AND totp_enabled = 1"
);
$stmt->execute([$userId]);
$user = $stmt->fetch();
if (!$user) {
return false;
}
// Check if this code has already been used (replay attack prevention)
if ($this->isCodeUsed($userId, $code)) {
return false;
}
$valid = $this->google2fa->verifyKey($user['totp_secret'], $code, 1);
if ($valid) {
// Mark code as used to prevent replay
$this->markCodeUsed($userId, $code);
}
return $valid;
}
private function isCodeUsed(int $userId, string $code): bool {
$stmt = $this->db->prepare(
"SELECT id FROM totp_used_codes
WHERE user_id = ? AND code = ? AND used_at > NOW() - INTERVAL 90 SECOND"
);
$stmt->execute([$userId, $code]);
return (bool)$stmt->fetch();
}
private function markCodeUsed(int $userId, string $code): void {
$stmt = $this->db->prepare(
"INSERT IGNORE INTO totp_used_codes (user_id, code, used_at) VALUES (?, ?, NOW())"
);
$stmt->execute([$userId, $code]);
// Clean up old used codes
$this->db->exec(
"DELETE FROM totp_used_codes WHERE used_at < NOW() - INTERVAL 90 SECOND"
);
}
private function generateBackupCodes(int $count = 8): array {
$codes = [];
for ($i = 0; $i < $count; $i++) {
// Format: XXXX-XXXX (8 random hex chars, formatted for readability)
$codes[] = strtoupper(substr(bin2hex(random_bytes(4)), 0, 4) . '-' .
substr(bin2hex(random_bytes(4)), 0, 4));
}
return $codes;
}
}
The Login Flow With 2FA
A 2FA login flow requires careful session management between the password step and the 2FA step:
<?php
// Step 1: Validate credentials
// (Standard login validation from previous post)
if (!$user || !password_verify($password, $user['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'Invalid email or password']);
exit;
}
// Step 2: Check if 2FA is required
if ($user['totp_enabled']) {
// Don't create a full session yet — create a partial session
// This prevents attacks where someone with only the password gains access
session_start();
session_regenerate_id(true);
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_expires'] = time() + 300; // 5 minutes to complete 2FA
http_response_code(200);
echo json_encode(['requires_2fa' => true, 'message' => 'Enter your authenticator code']);
exit;
}
// Step 3: If no 2FA, complete login normally
session_start();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['authenticated_at'] = time();
echo json_encode(['success' => true]);
// === Separate 2FA verification endpoint ===
// POST /api/auth/verify-2fa
session_start();
// Validate the pending session exists and hasn't expired
if (!isset($_SESSION['pending_2fa_user_id']) ||
!isset($_SESSION['pending_2fa_expires']) ||
time() > $_SESSION['pending_2fa_expires']) {
session_destroy();
http_response_code(401);
echo json_encode(['error' => 'Session expired. Please log in again.']);
exit;
}
$userId = $_SESSION['pending_2fa_user_id'];
$code = trim($_POST['totp_code'] ?? '');
// Validate code format
if (!preg_match('/^d{6}$/', $code)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid code format']);
exit;
}
$twoFA = new TwoFactorAuthManager($db);
if (!$twoFA->verifyLoginCode($userId, $code)) {
// Log failed 2FA attempt
error_log("[2FA] Failed verification for user $userId from " . $_SERVER['REMOTE_ADDR']);
http_response_code(401);
echo json_encode(['error' => 'Invalid code. Please try again.']);
exit;
}
// 2FA successful - complete the login
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_expires']);
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
$_SESSION['authenticated_at'] = time();
echo json_encode(['success' => true]);
Backup Codes and Account Recovery
Users will lose access to their authenticator app — through phone loss, device replacement, or app data deletion. Without a recovery mechanism, this means permanent account lockout. Without a secure recovery mechanism, it becomes a bypass vector.
Backup codes (single-use codes generated at 2FA setup) are the standard approach. Store them hashed, not in plain text:
<?php
// Store backup codes hashed
function storeBackupCodes(int $userId, array $codes, PDO $db): void {
$db->prepare("DELETE FROM backup_codes WHERE user_id = ?")->execute([$userId]);
$stmt = $db->prepare(
"INSERT INTO backup_codes (user_id, code_hash, created_at) VALUES (?, ?, NOW())"
);
foreach ($codes as $code) {
$stmt->execute([$userId, password_hash($code, PASSWORD_BCRYPT)]);
}
}
// Verify and consume a backup code
function useBackupCode(int $userId, string $code, PDO $db): bool {
$stmt = $db->prepare(
"SELECT id, code_hash FROM backup_codes
WHERE user_id = ? AND used_at IS NULL"
);
$stmt->execute([$userId]);
$backupCodes = $stmt->fetchAll();
foreach ($backupCodes as $backupCode) {
if (password_verify($code, $backupCode['code_hash'])) {
// Mark as used
$db->prepare("UPDATE backup_codes SET used_at = NOW() WHERE id = ?")
->execute([$backupCode['id']]);
return true;
}
}
return false;
}
Present backup codes to users exactly once (at setup), with clear instructions to save them somewhere safe. Never email or display them again after the initial setup screen. Always show users how many backup codes they have remaining so they know when to regenerate them.
2FA Bypass Vectors to Defend Against
Implementing 2FA correctly means thinking about how it can be bypassed and closing those paths:
The "remember this device" feature needs careful implementation — store a cryptographically random token in a long-lived cookie, hash it before storage, and invalidate all remembered devices when the user changes their password or explicitly revokes trusted devices. Don't implement "remember device" as simply skipping 2FA for a recognized IP address — IP addresses change and are easily spoofed.
Account recovery flows are a common bypass. Password reset emails that automatically log the user in and bypass 2FA defeat the purpose of having 2FA. A password reset should require 2FA verification before completing (for users who have 2FA enabled) unless the user has explicitly invoked the "lost authenticator" backup code flow.
Session management after 2FA should create a fresh session ID (using session_regenerate_id(true)) and timestamp the authentication. Long-lived sessions without re-authentication requirements mean a stolen session token from weeks ago can bypass 2FA indefinitely.
Building 2FA into your applications today is worth the investment. The incremental implementation cost is a few hours of careful coding. The protection it provides against credential stuffing and account takeover is substantial and immediate. — Skand K.