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.
Sometime in the last few years, "use a strong password" quietly stopped being enough advice. Not because strong passwords stopped mattering — they absolutely still do — but because the threat landscape shifted in a way that makes passwords alone an insufficient defense regardless of their strength.
Data breaches happen constantly. Phishing attacks have gotten frighteningly convincing. Password reuse across services is nearly universal despite everyone knowing better. In this environment, a password is something an attacker can obtain without ever touching your application — through a breach of a completely different service your user registered with five years ago.
Two-factor authentication is the practical answer to this. It means that even when a password is exposed, the account it protects stays closed. This post covers how different 2FA methods actually work, which ones are worth implementing, and how to build proper TOTP-based authentication into a PHP application from scratch.
The Basic Idea Behind Two-Factor Authentication
Authentication factors fall into three categories: something you know (a password), something you have (a phone or hardware key), and something you are (biometrics). Single-factor authentication relies on just one of these. Two-factor authentication requires two — typically a password plus something tied to a physical device the user possesses.
The security improvement is significant because the two factors are independent. Obtaining one doesn't give you the other. An attacker who gets your password through a breach doesn't have your phone. An attacker who steals your phone doesn't have your password. To compromise an account protected by proper 2FA, an attacker needs to obtain both simultaneously — which is a dramatically harder problem than stealing a password from a database.
The Different Types of 2FA and How They Compare
Not all 2FA implementations are equal. Understanding the differences helps you make a better decision about what to build.
SMS-based OTP — A one-time code gets sent to the user's phone number via text message. This is the most widely recognized form of 2FA and the easiest to understand. It's also the weakest form of proper 2FA. SIM swapping attacks — where an attacker convinces a mobile carrier to transfer a victim's number to an attacker-controlled SIM — have been used to bypass SMS 2FA on high-profile accounts. SS7 network vulnerabilities also allow SMS interception in targeted attacks. SMS 2FA is significantly better than no 2FA, but it shouldn't be your only option for security-sensitive applications.
Email OTP — Similar to SMS but delivered to an email address. Slightly more resistant to SIM swapping but email accounts are themselves frequently compromised, and if an attacker already has access to a user's email, email OTP provides no meaningful protection. Useful as a low-friction fallback, not as a primary security measure.
Authenticator app TOTP — Time-based One-Time Passwords generated by an app like Google Authenticator, Authy, or any standard TOTP client. These work entirely offline — no SMS, no email, no network request. The app and your server share a secret key, and both independently compute a six-digit code that changes every thirty seconds. This is the gold standard for most web applications and what the rest of this post focuses on.
Hardware security keys — Physical devices like YubiKey that implement the FIDO2/WebAuthn standard. The strongest form of 2FA available — completely phishing-resistant because the key cryptographically verifies the domain it's being used on. Primarily relevant for high-security applications and enterprise environments. Worth knowing about but beyond the scope of a typical web app implementation.
How TOTP Actually Works
TOTP stands for Time-based One-Time Password, defined in RFC 6238. Understanding the mechanism makes implementation feel less like magic.
When a user sets up TOTP 2FA on your application, two things happen:
First, your server generates a random secret key — typically 20 bytes of random data, base32-encoded into a string like JBSWY3DPEHPK3PXP. This secret is stored in your database associated with the user's account.
Second, that secret is shared with the user's authenticator app — usually by displaying a QR code the app scans. From that point on, both your server and the user's app have the same secret.
Every thirty seconds, both sides independently compute a six-digit code using the same algorithm: take the shared secret, combine it with the current Unix timestamp divided by 30 (to get a counter that increments every thirty seconds), run it through HMAC-SHA1, and extract six digits from the result. Because both sides have the same secret and the same timestamp, they produce the same code — without ever communicating.
When the user types the six-digit code from their app into your login form, your server computes what the code should be right now and compares. If they match, the user has proven they possess the device that has the shared secret. Because the code changes every thirty seconds and each code can only be used once, intercepted codes are useless almost immediately.
Building TOTP 2FA in PHP
The easiest way to implement TOTP in PHP is with the spomky-labs/otphp library, which handles the cryptographic details correctly and is well maintained.
composer require spomky-labs/otphp
Step 1 — Generate a secret and QR code for setup:
<?php
require 'vendor/autoload.php';
use OTPHP\TOTP;
// Generate a new TOTP secret for a user
$totp = TOTP::create();
$totp->setLabel($user_email); // shown in the authenticator app
$totp->setIssuer('JSHook'); // your app name shown in the authenticator
// The secret to store in your database
$secret = $totp->getSecret();
// Store it temporarily (before the user confirms setup)
$_SESSION['pending_2fa_secret'] = $secret;
$_SESSION['pending_2fa_user_id'] = $user_id;
// Generate the QR code URI — feed this to a QR code library
$qrCodeUri = $totp->getQrCodeUri(
'https://api.qrserver.com/v1/create-qr-code/?data=[DATA]&size=300x300',
'[DATA]'
);
// Display the QR code to the user
echo '<img src="' . htmlspecialchars($qrCodeUri) . '" alt="Scan this QR code">';
// Also show the secret as text for manual entry
echo '<p>Manual entry code: ' . htmlspecialchars($secret) . '</p>';
Step 2 — Verify the user scanned it correctly before saving:
<?php
// User submits the 6-digit code from their app to confirm setup
use OTPHP\TOTP;
$secret = $_SESSION['pending_2fa_secret'];
$user_id = $_SESSION['pending_2fa_user_id'];
$submitted_code = trim($_POST['code']);
$totp = TOTP::createFromSecret($secret);
if ($totp->verify($submitted_code, null, 1)) {
// Code is valid — save the secret to the database
$stmt = $db->prepare(
"UPDATE users SET totp_secret = ?, two_factor_enabled = 1 WHERE id = ?"
);
$stmt->execute([$secret, $user_id]);
// Clear the session values
unset($_SESSION['pending_2fa_secret'], $_SESSION['pending_2fa_user_id']);
echo json_encode(['success' => true, 'message' => '2FA enabled successfully']);
} else {
echo json_encode(['success' => false, 'message' => 'Invalid code. Please try again.']);
}
The second parameter to verify() is a timestamp (null means now), and the third parameter is a window — how many thirty-second periods either side of the current one to accept. A window of 1 means you accept codes from the previous period, the current period, and the next period. This accounts for clock drift between the user's device and your server without being so generous that replay attacks become practical.
Step 3 — Verify at login:
<?php
// After username and password are verified successfully
$stmt = $db->prepare(
"SELECT two_factor_enabled, totp_secret FROM users WHERE id = ?"
);
$stmt->execute([$user_id]);
$user = $stmt->fetch();
if ($user['two_factor_enabled']) {
// Don't create full session yet
// Store that password was verified and redirect to 2FA step
$_SESSION['2fa_pending_user_id'] = $user_id;
header('Location: /login/verify-2fa');
exit;
} else {
// No 2FA — create full session
$_SESSION['user_id'] = $user_id;
header('Location: /dashboard');
exit;
}
<?php
// On the 2FA verification page
$user_id = $_SESSION['2fa_pending_user_id'] ?? null;
if (!$user_id) {
header('Location: /login');
exit;
}
$submitted_code = trim($_POST['code']);
$stmt = $db->prepare("SELECT totp_secret FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$user = $stmt->fetch();
$totp = TOTP::createFromSecret($user['totp_secret']);
if ($totp->verify($submitted_code, null, 1)) {
// 2FA passed — create the real session
unset($_SESSION['2fa_pending_user_id']);
$_SESSION['user_id'] = $user_id;
// Regenerate session ID to prevent fixation
session_regenerate_id(true);
header('Location: /dashboard');
exit;
} else {
// Wrong code — increment failure counter
// After too many failures, lock temporarily
$error = 'Invalid authentication code. Please try again.';
}
Handling Backup Codes
Every 2FA implementation needs backup codes — one-time use codes the user can store somewhere safe and use if they lose access to their authenticator app. Without backup codes, a lost or broken phone locks a user out of their account permanently.
<?php
function generateBackupCodes($user_id, $db, $count = 8) {
$codes = [];
// Delete any existing backup codes for this user
$db->prepare("DELETE FROM backup_codes WHERE user_id = ?")->execute([$user_id]);
for ($i = 0; $i < $count; $i++) {
// Generate a random 10-character code
$code = strtoupper(bin2hex(random_bytes(5)));
// Format as XXXXX-XXXXX for readability
$formatted = substr($code, 0, 5) . '-' . substr($code, 5, 5);
$codes[] = $formatted;
// Store hashed version — treat like passwords
$hashed = password_hash($formatted, PASSWORD_BCRYPT);
$db->prepare(
"INSERT INTO backup_codes (user_id, code_hash, used) VALUES (?, ?, 0)"
)->execute([$user_id, $hashed]);
}
return $codes; // Show these to the user once — never again
}
// Using a backup code at login
function verifyBackupCode($user_id, $submitted_code, $db) {
$stmt = $db->prepare(
"SELECT id, code_hash FROM backup_codes WHERE user_id = ? AND used = 0"
);
$stmt->execute([$user_id]);
$codes = $stmt->fetchAll();
foreach ($codes as $row) {
if (password_verify($submitted_code, $row['code_hash'])) {
// Mark this code as used — it can never be used again
$db->prepare(
"UPDATE backup_codes SET used = 1 WHERE id = ?"
)->execute([$row['id']]);
return true;
}
}
return false;
}
Show backup codes to the user exactly once — immediately after 2FA setup, before they leave the page. Tell them clearly to store them somewhere safe. Never display them again. Store only the hashed versions in your database.
Preventing Common 2FA Implementation Mistakes
Rate limit the verification endpoint — A six-digit TOTP code has one million possible values. Without rate limiting, an attacker who knows the username and password can brute force the 2FA step by cycling through codes. Limit to five to ten attempts before a temporary lockout.
Prevent code reuse — Each TOTP code should only be valid once. Store the last successfully used code and reject it if submitted again within the same thirty-second window. This prevents replay attacks where an intercepted code gets reused immediately.
<?php
// After successful TOTP verification, store the used code
$db->prepare(
"UPDATE users SET last_totp_code = ?, last_totp_time = NOW() WHERE id = ?"
)->execute([$submitted_code, $user_id]);
// Before accepting a code, check it wasn't just used
$stmt = $db->prepare(
"SELECT last_totp_code, last_totp_time FROM users WHERE id = ?"
);
$stmt->execute([$user_id]);
$user = $stmt->fetch();
$recently_used = ($user['last_totp_code'] === $submitted_code
&& strtotime($user['last_totp_time']) > time() - 30);
if ($recently_used) {
// Reject — code already used
exit;
}
Keep the 2FA step separate from the password step — Never create a full authenticated session until both factors have been verified. The intermediate state — password verified but 2FA pending — should use a separate session variable that only grants access to the 2FA verification page, nothing else.
Allow users to disable 2FA securely — Require the user to verify their current password before disabling 2FA. Otherwise an attacker who gets past the password step somehow could turn off 2FA before completing authentication.
The User Experience Side
2FA only protects users who actually enable it. The implementation details matter but so does how you present it.
Don't bury 2FA in security settings that users never visit. Surface it during onboarding with a clear explanation of what it does. Show a visible indicator on the account page for users who don't have it enabled yet. For applications handling financial data, sensitive information, or anything where account compromise has real consequences — consider making 2FA mandatory rather than optional.
The friction of a six-digit code at login is small. The protection it provides is significant. Most users who understand what it does will enable it willingly if you make the setup process clear and the ongoing experience smooth.
Wrapping Up
Two-factor authentication is one of those security measures where the implementation effort is relatively contained and the security improvement is substantial. A TOTP setup — secret generation, QR code display, verification at login, backup codes — can be built and tested in a day. Once it's in, it fundamentally changes what an attacker needs to do to access an account.
Passwords will keep getting exposed in breaches. Phishing will keep improving. The defensive response to both of those realities is making sure that a password alone isn't enough to get in. TOTP 2FA is the most practical way to do that for most web applications — it works offline, it doesn't depend on mobile carriers, and the libraries to implement it correctly are mature and well maintained.
Build it in. Your users' accounts will be meaningfully more secure for it.
— Skand K.