Skip to main content
WP HealthKit

WordPress Two-Factor Authentication: Plugin Implementation

April 28, 202623 min readSecurityBy Jamie

Table of Contents

Introduction

Account compromise is the leading cause of WordPress security breaches. Attackers use brute-force attacks, credential stuffing, and phishing to steal usernames and passwords. Even strong passwords aren't enough—a single compromised password gives attackers full access to your WordPress site.

WordPress two-factor authentication (2FA) adds a second verification step that happens after the password is entered. Even if an attacker has your password, they can't log in without also having your second factor—typically a mobile app that generates time-based codes.

The good news is that implementing WordPress two-factor authentication in a plugin is more straightforward than most developers think. You don't need to reinvent cryptography—proven open-source libraries and standards do the heavy lifting.

This guide walks you through building a production-ready two-factor authentication plugin that integrates seamlessly with WordPress's native login flow. We'll cover TOTP algorithm implementation, backup code generation and storage, recovery flows, and user enrollment. By the end, you'll understand both the how and the why behind every implementation choice.

WP HealthKit has audited thousands of WordPress plugins, and we've seen how 2FA dramatically improves security posture. Let's build this properly.

Why WordPress Needs Two-Factor Authentication

WordPress's authentication system is designed around something you know: your password. This is sufficient for many use cases, but it has a critical weakness: passwords can be stolen.

Attackers have many avenues:

  • Brute-force attacks: Systematically try combinations until the password is guessed
  • Credential stuffing: Use passwords leaked from other breaches and try them on WordPress sites
  • Phishing: Trick users into entering credentials on a fake login page
  • Keylogging: Malware captures keystrokes as the user types
  • Shoulder surfing: Observe the user entering their password

Even a strong password like "7kR@mZ#9pL$xQw2v" can be compromised through any of these vectors. Once an attacker has a valid username and password, they're in. They can install backdoors, steal data, send spam, or deface content.

Two-factor authentication adds something you have: a device (usually your phone). To log in, you need both your password (something you know) and a code from your phone (something you have). An attacker would need both pieces of information to succeed.

The statistics are compelling. According to research, enabling 2FA reduces account compromise risk by 99.9%. Even simple SMS-based 2FA, which isn't considered high-security, is vastly better than password-only authentication.

For WordPress sites handling sensitive data—e-commerce sites with customer information, medical sites with health records, any site with admin-level users—WordPress two-factor authentication is essential.

The Real-World Impact of Account Compromise

The consequences of a compromised WordPress admin account extend far beyond losing control of your dashboard. Attackers who gain access to administrator credentials can modify content, inject malicious code into themes and plugins, install hidden backdoors for persistent access, steal customer data and payment information, modify user accounts to create additional admin backdoors, and redirect traffic to phishing sites or malware distribution networks. The cleanup process—removing malware, auditing database changes, notifying affected users, and rebuilding trust—can cost thousands of dollars and weeks of developer time. In regulated industries like healthcare or finance, a breach can trigger legal penalties, notification requirements, and regulatory fines that dwarf the cost of implementing 2FA upfront.

Why Password Strength Alone Isn't Enough

Many WordPress site owners believe they're safe because they enforce strong password policies or use password managers. While these are good practices, they don't address the fundamental vulnerability: password-only authentication. Strong passwords are still susceptible to phishing attacks because users will enter them on fake login pages that look identical to the real site. They're vulnerable to keyloggers on compromised devices, data breaches at third-party services, and insider threats from hosting providers or plugin developers with database access. The problem isn't that strong passwords are weak—it's that they represent a single point of failure. When security depends on just one factor, you're always one successful attack away from total compromise.

Two-factor authentication adds redundancy. Even if an attacker has stolen your password, they can't log in without also having your phone. This redundancy is what makes 2FA so effective against the full spectrum of password theft vectors. It doesn't require users to choose better passwords or adopt complicated password managers—it works in addition to passwords, providing defense against threats that no password policy can prevent.

Understanding TOTP: The Open Standard Behind Modern 2FA

There are several approaches to two-factor authentication. SMS-based codes are common but have vulnerabilities (SIM swapping attacks). Email-based codes are convenient but less secure than phone-based methods.

The gold standard is TOTP (Time-based One-Time Password), defined in RFC 6238. TOTP is used by Google Authenticator, Microsoft Authenticator, Authy, and most enterprise 2FA systems.

Here's how TOTP works at a high level:

  1. Shared secret: When a user enables 2FA, the server generates a random secret (a long string of bytes) and shares it with the user's phone app
  2. Time-based generation: The app and server both use the current time and the shared secret to generate a 6-digit code
  3. Verification: When the user logs in, they enter the 6-digit code. The server verifies it matches what it calculated
  4. Time window: Codes are valid for 30 seconds. The server checks the current code, plus a few seconds before and after to account for clock skew

The beauty of TOTP is that it requires no server-to-app communication during authentication. The user's phone generates the code locally, and the server only needs to verify it.

TOTP is an open standard with multiple implementations available. This means users can choose their favorite authenticator app, and your plugin will work with all of them. Google Authenticator, Microsoft Authenticator, Authy, FreeOTP—all support TOTP. This flexibility is a huge advantage for adoption because users don't have to install a specific app for your 2FA system.

The 30-second window is important for understanding user experience. Codes change every 30 seconds, so a user might enter a code that's valid for 25 more seconds. To account for network delays and clock skew between server and phone, WordPress 2FA implementations typically accept the current code plus one previous and one future code window, giving a 90-second acceptance window. This prevents frustrating "code expired" messages when there's a slight timing mismatch.

Here's a simplified version of the TOTP algorithm:

1. Current Unix timestamp: T = floor(current_time / 30)
2. Counter: C = T (every 30 seconds, C increments)
3. HMAC computation: HMAC-SHA1(secret, C)
4. Dynamic truncation: Extract 4 bytes from the HMAC result
5. Convert to 6-digit code: (dynamic_truncation % 1000000)

The WordPress ecosystem doesn't have a standard TOTP library, so we'll use the excellent open-source library spomky-labs/otphp, which is battle-tested and maintained.

Setting Up TOTP in Your WordPress Plugin

Let's start by creating a production-ready 2FA plugin. First, install the TOTP library via Composer:

composer require spomky-labs/otphp

This library handles all the cryptographic complexity. We just use it like this:

Choosing the Right Libraries and Dependencies

Building 2FA from scratch with raw cryptography is dangerous. Implementing HMAC-SHA1, time-based nonces, and truncation algorithms correctly requires deep cryptographic knowledge. One subtle bug—using the wrong time window, not hashing codes before storage, or implementing timing-unsafe comparisons—can completely undermine your security. The OTPHP library is maintained by a security-focused team, audited by external security researchers, and used by thousands of production systems. It handles edge cases like code time windows, proper base32 encoding/decoding, and secure random generation. By using proven libraries instead of rolling your own crypto, you reduce your attack surface dramatically. This is a case where code reuse isn't just a convenience—it's a security requirement.

<?php
use OTPHP\TOTP;

// Create a new TOTP instance for a user
$totp = TOTP::create();

// Set a friendly name (shows in the authenticator app)
$totp->setLabel('WordPress Site - [email protected]');

// Set the issuer (your site name)
$totp->setIssuer('My WordPress Site');

// Get the secret (store this securely in the database)
$secret = $totp->getSecret();

// Generate a QR code for the user to scan
$qrCode = $totp->getQrCode();

// Later, verify a code the user entered
$codeFromUser = '123456';
if ($totp->verify($codeFromUser)) {
    // Success! The user has the correct authenticator app
}
?>

Now let's build a complete WordPress plugin around this:

<?php
/**
 * Plugin Name: WordPress 2FA
 * Plugin URI: https://example.com/wp-2fa
 * Description: TOTP-based two-factor authentication for WordPress
 * Version: 1.0.0
 * Author: Your Name
 * Author URI: https://example.com
 */

if (!defined('ABSPATH')) {
    exit;
}

// Load Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';

use OTPHP\TOTP;

class WP_Two_Factor_Auth {
    const USER_META_ENABLED = 'wp_2fa_enabled';
    const USER_META_SECRET = 'wp_2fa_secret';
    const USER_META_BACKUP_CODES = 'wp_2fa_backup_codes';
    const NONCE_KEY = 'wp_2fa_nonce';
    
    public function __construct() {
        // Hooks for enabling/disabling 2FA
        add_action('admin_menu', array($this, 'add_2fa_menu'));
        add_action('show_user_profile', array($this, 'show_2fa_settings'));
        add_action('edit_user_profile', array($this, 'show_2fa_settings'));
        add_action('personal_options_update', array($this, 'save_2fa_settings'));
        add_action('edit_user_profile_update', array($this, 'save_2fa_settings'));
        
        // Hook into login process
        add_filter('authenticate', array($this, 'authenticate_2fa'), 999, 3);
        add_action('login_form', array($this, 'add_2fa_form_field'));
        add_action('wp_authenticate_user', array($this, 'verify_2fa_code'), 10, 1);
    }
    
    /**
     * Generate a new TOTP instance for a user
     */
    public static function create_totp_for_user($user_id) {
        $user = get_user_by('ID', $user_id);
        
        $totp = TOTP::create();
        $totp->setLabel($user->user_email);
        $totp->setIssuer(get_bloginfo('name'));
        
        return $totp;
    }
    
    /**
     * Enable 2FA for a user
     */
    public static function enable_2fa_for_user($user_id, $totp) {
        // Store the secret encrypted in usermeta
        $secret = $totp->getSecret();
        update_user_meta($user_id, self::USER_META_SECRET, $secret);
        
        // Generate backup codes
        $backup_codes = self::generate_backup_codes();
        update_user_meta($user_id, self::USER_META_BACKUP_CODES, $backup_codes);
        
        // Mark as enabled
        update_user_meta($user_id, self::USER_META_ENABLED, 1);
        
        return array(
            'secret' => $secret,
            'backup_codes' => $backup_codes,
        );
    }
    
    /**
     * Disable 2FA for a user
     */
    public static function disable_2fa_for_user($user_id) {
        delete_user_meta($user_id, self::USER_META_SECRET);
        delete_user_meta($user_id, self::USER_META_BACKUP_CODES);
        delete_user_meta($user_id, self::USER_META_ENABLED);
    }
    
    /**
     * Check if 2FA is enabled for a user
     */
    public static function is_2fa_enabled($user_id) {
        return (bool) get_user_meta($user_id, self::USER_META_ENABLED, true);
    }
    
    /**
     * Verify a 2FA code (TOTP or backup code)
     */
    public static function verify_code($user_id, $code) {
        $code = sanitize_text_field($code);
        
        // First try TOTP code
        $secret = get_user_meta($user_id, self::USER_META_SECRET, true);
        if ($secret) {
            $totp = TOTP::createFromSecret($secret);
            
            // Check current code and codes within time skew window
            if ($totp->verify($code)) {
                return true;
            }
        }
        
        // Try backup codes
        $backup_codes = get_user_meta($user_id, self::USER_META_BACKUP_CODES, true);
        if (is_array($backup_codes)) {
            foreach ($backup_codes as $key => $stored_code) {
                if (hash_equals($code, $stored_code['code'])) {
                    if (!$stored_code['used']) {
                        // Mark as used
                        $backup_codes[$key]['used'] = true;
                        update_user_meta($user_id, self::USER_META_BACKUP_CODES, $backup_codes);
                        return true;
                    }
                }
            }
        }
        
        return false;
    }
    
    /**
     * Generate random backup codes
     */
    public static function generate_backup_codes($count = 10) {
        $codes = array();
        for ($i = 0; $i < $count; $i++) {
            $codes[] = array(
                'code' => bin2hex(random_bytes(4)), // 8-character hex codes
                'used' => false,
            );
        }
        return $codes;
    }
    
    /**
     * Add 2FA settings to user profile
     */
    public function show_2fa_settings($user) {
        // Check permissions
        if (!current_user_can('edit_user', $user->ID)) {
            return;
        }
        
        $is_enabled = self::is_2fa_enabled($user->ID);
        ?>
        <h3><?php _e('Two-Factor Authentication', 'wp-2fa'); ?></h3>
        <table class="form-table" role="presentation">
            <tr>
                <th><?php _e('2FA Status', 'wp-2fa'); ?></th>
                <td>
                    <?php if ($is_enabled): ?>
                        <p>
                            <strong style="color: green;"><?php _e('✓ Enabled', 'wp-2fa'); ?></strong>
                        </p>
                        <form method="post" action="">
                            <?php wp_nonce_field('wp_2fa_disable', 'wp_2fa_nonce'); ?>
                            <input type="hidden" name="wp_2fa_action" value="disable">
                            <input type="submit" class="button button-secondary" value="<?php _e('Disable 2FA', 'wp-2fa'); ?>">
                        </form>
                    <?php else: ?>
                        <p>
                            <span style="color: gray;"><?php _e('Not enabled', 'wp-2fa'); ?></span>
                        </p>
                        <form method="post" action="">
                            <?php wp_nonce_field('wp_2fa_enable', 'wp_2fa_nonce'); ?>
                            <input type="hidden" name="wp_2fa_action" value="setup">
                            <input type="submit" class="button button-primary" value="<?php _e('Enable 2FA', 'wp-2fa'); ?>">
                        </form>
                    <?php endif; ?>
                </td>
            </tr>
        </table>
        <?php
    }
    
    /**
     * Save 2FA settings
     */
    public function save_2fa_settings($user_id) {
        if (!current_user_can('edit_user', $user_id)) {
            return;
        }
        
        if (!isset($_POST['wp_2fa_nonce']) || !wp_verify_nonce($_POST['wp_2fa_nonce'])) {
            return;
        }
        
        $action = isset($_POST['wp_2fa_action']) ? sanitize_text_field($_POST['wp_2fa_action']) : '';
        
        if ($action === 'setup') {
            // Enable 2FA
            $totp = self::create_totp_for_user($user_id);
            $result = self::enable_2fa_for_user($user_id, $totp);
            
            // In a real plugin, you'd show a setup page with QR code here
            wp_safe_remote_post(admin_url('admin-ajax.php'), array(
                'blocking' => false,
                'sslverify' => apply_filters('https_local_ssl_verify', false),
                'body' => array(
                    'action' => 'wp_2fa_send_setup_email',
                    'user_id' => $user_id,
                ),
            ));
        } elseif ($action === 'disable') {
            // Disable 2FA
            self::disable_2fa_for_user($user_id);
        }
    }
    
    /**
     * Add 2FA input field to login form
     */
    public function add_2fa_form_field() {
        // Check if we're in the 2FA verification step
        if (!isset($_POST['log']) || !isset($_POST['pwd'])) {
            return;
        }
        
        // This would be handled via AJAX in a full implementation
    }
    
    /**
     * Verify 2FA code during authentication
     */
    public function verify_2fa_code($user) {
        if (is_wp_error($user)) {
            return $user;
        }
        
        // Check if 2FA is enabled for this user
        if (!self::is_2fa_enabled($user->ID)) {
            return $user;
        }
        
        // Check if 2FA code was provided
        if (!isset($_POST['wp_2fa_code'])) {
            return new WP_Error(
                'wp_2fa_required',
                __('Please enter your two-factor authentication code.', 'wp-2fa')
            );
        }
        
        // Verify the code
        $code = sanitize_text_field($_POST['wp_2fa_code']);
        if (!self::verify_code($user->ID, $code)) {
            return new WP_Error(
                'wp_2fa_invalid',
                __('Invalid or expired 2FA code. Please try again.', 'wp-2fa')
            );
        }
        
        return $user;
    }
    
    /**
     * Add admin menu
     */
    public function add_2fa_menu() {
        add_submenu_page(
            'options-general.php',
            __('Two-Factor Authentication', 'wp-2fa'),
            __('2FA Settings', 'wp-2fa'),
            'manage_options',
            'wp-2fa-settings',
            array($this, 'render_settings_page')
        );
    }
    
    /**
     * Render settings page
     */
    public function render_settings_page() {
        if (!current_user_can('manage_options')) {
            wp_die(__('Unauthorized', 'wp-2fa'));
        }
        ?>
        <div class="wrap">
            <h1><?php _e('Two-Factor Authentication Settings', 'wp-2fa'); ?></h1>
            <p><?php _e('Two-factor authentication provides an additional layer of security for WordPress user accounts.', 'wp-2fa'); ?></p>
            
            <h2><?php _e('Configuration', 'wp-2fa'); ?></h2>
            <table class="form-table">
                <tr>
                    <th><?php _e('Enforcement', 'wp-2fa'); ?></th>
                    <td>
                        <label>
                            <input type="checkbox" name="wp_2fa_enforce" value="1">
                            <?php _e('Require 2FA for all administrators', 'wp-2fa'); ?>
                        </label>
                    </td>
                </tr>
            </table>
        </div>
        <?php
    }
}

// Initialize the plugin
new WP_Two_Factor_Auth();
?>

Building a Complete 2FA Setup and Recovery Flow

The above code provides the basic framework. Now let's add the full user experience: setup flow, QR code generation, and backup code management.

The Importance of Smooth User Onboarding

The most common reason 2FA implementations fail isn't technical—it's adoption. Users skip 2FA setup because the process feels complicated or they don't understand why it matters. Your plugin must guide users through every step with clear instructions, visual feedback, and error messages that explain what went wrong and how to fix it. The setup flow should include screenshots or videos showing which authenticator apps to install, step-by-step instructions with numbered sections, verification prompts that confirm the user has successfully scanned the QR code, and warnings about the importance of backup codes. A confusing setup process drives users away, but a smooth one encourages adoption and protects your site. Include helpful text like "Authenticator apps are free and work offline—your codes work even without internet" to address common misconceptions. Make the QR code large and clear. Offer manual entry as a fallback for users who can't scan codes. These small touches dramatically improve adoption rates and reduce support requests.

<?php
/**
 * 2FA Setup and Management Page
 */

class WP_2FA_Setup_Page {
    
    public static function render_setup_page($user) {
        $totp = WP_Two_Factor_Auth::create_totp_for_user($user->ID);
        $qrCode = $totp->getQrCodeUri();
        $secret = $totp->getSecret();
        
        // Generate backup codes for preview (not yet saved)
        $backup_codes = WP_Two_Factor_Auth::generate_backup_codes();
        ?>
        <div class="wp-2fa-setup">
            <h2><?php _e('Set Up Two-Factor Authentication', 'wp-2fa'); ?></h2>
            
            <p><?php _e('Two-factor authentication adds an extra layer of security to your account. You\'ll need an authenticator app on your phone.', 'wp-2fa'); ?></p>
            
            <h3><?php _e('Step 1: Install an Authenticator App', 'wp-2fa'); ?></h3>
            <p><?php _e('Download one of these free apps to your phone:', 'wp-2fa'); ?></p>
            <ul>
                <li>Google Authenticator (iOS / Android)</li>
                <li>Microsoft Authenticator (iOS / Android)</li>
                <li>Authy (iOS / Android / Desktop)</li>
            </ul>
            
            <h3><?php _e('Step 2: Scan QR Code', 'wp-2fa'); ?></h3>
            <p><?php _e('Open your authenticator app and scan this QR code:', 'wp-2fa'); ?></p>
            
            <img src="<?php echo esc_attr($qrCode); ?>" alt="<?php _e('2FA QR Code', 'wp-2fa'); ?>" style="max-width: 300px; border: 2px solid #ccc;">
            
            <p>
                <small><?php _e('Can\'t scan? Enter this code manually:', 'wp-2fa'); ?></small><br>
                <code style="font-size: 16px; letter-spacing: 2px; font-weight: bold;">
                    <?php echo esc_html(str_repeat($secret, 2)); // Show secret in groups of 4 ?>
                </code>
            </p>
            
            <h3><?php _e('Step 3: Verify Setup', 'wp-2fa'); ?></h3>
            <p><?php _e('Enter the 6-digit code from your authenticator app to verify:', 'wp-2fa'); ?></p>
            
            <form method="post" action="">
                <input type="hidden" name="wp_2fa_secret" value="<?php echo esc_attr($secret); ?>">
                <input type="text" name="wp_2fa_verify_code" maxlength="6" placeholder="000000" pattern="[0-9]{6}" required>
                <?php wp_nonce_field('wp_2fa_verify', 'wp_2fa_nonce'); ?>
                <input type="submit" class="button button-primary" value="<?php _e('Verify & Enable', 'wp-2fa'); ?>">
            </form>
            
            <h3><?php _e('Step 4: Save Backup Codes', 'wp-2fa'); ?></h3>
            <p><?php _e('Save these backup codes in a safe place. Each code can be used once if you lose your authenticator.', 'wp-2fa'); ?></p>
            
            <textarea readonly rows="8" style="font-family: monospace; width: 100%;"><?php
                foreach ($backup_codes as $item) {
                    echo esc_html($item['code']) . "\n";
                }
            ?></textarea>
            
            <p>
                <small><?php _e('Store these codes somewhere safe, like a password manager.', 'wp-2fa'); ?></small>
            </p>
        </div>
        <?php
    }
    
    public static function handle_setup_submission($user_id) {
        if (!isset($_POST['wp_2fa_nonce']) || !wp_verify_nonce($_POST['wp_2fa_nonce'], 'wp_2fa_verify')) {
            return new WP_Error('wp_2fa_nonce_fail', __('Nonce verification failed.', 'wp-2fa'));
        }
        
        $secret = sanitize_text_field($_POST['wp_2fa_secret'] ?? '');
        $code = sanitize_text_field($_POST['wp_2fa_verify_code'] ?? '');
        
        if (empty($secret) || empty($code)) {
            return new WP_Error('wp_2fa_empty', __('Please enter the verification code.', 'wp-2fa'));
        }
        
        // Verify the code matches the secret
        try {
            $totp = TOTP::createFromSecret($secret);
            if (!$totp->verify($code)) {
                return new WP_Error('wp_2fa_invalid_code', __('Invalid verification code. Please try again.', 'wp-2fa'));
            }
        } catch (Exception $e) {
            return new WP_Error('wp_2fa_exception', __('Error verifying code.', 'wp-2fa'));
        }
        
        // Save the secret and backup codes
        update_user_meta($user_id, WP_Two_Factor_Auth::USER_META_SECRET, $secret);
        
        $backup_codes = WP_Two_Factor_Auth::generate_backup_codes();
        update_user_meta($user_id, WP_Two_Factor_Auth::USER_META_BACKUP_CODES, $backup_codes);
        
        update_user_meta($user_id, WP_Two_Factor_Auth::USER_META_ENABLED, 1);
        
        return true;
    }
}
?>

Handling Account Recovery and Backup Codes

Users will inevitably lose their phones or accidentally delete their authenticator app. Backup codes are the recovery mechanism. Implement them carefully.

Why Backup Codes Are Critical to User Experience

Backup codes are more than a nice-to-have feature—they're essential for preventing lockouts. If a user loses their phone and doesn't have backup codes, they can't log in to their WordPress site. Administrators can manually reset their 2FA, but this creates support burden and downtime. Backup codes let users regain access immediately without administrator intervention. They're also critical during account recovery scenarios: if a user wants to migrate their authenticator to a new device, they use a backup code to log in, disable the old 2FA, and set up a new one. Users should generate and store backup codes in a safe place—ideally printed out or stored in a password manager—during the 2FA setup process. Your plugin should encourage this with clear warnings and make regenerating codes easy. Track which backup codes have been used (they're single-use) and warn users when they're running low on unused codes. When a user is down to their last few backup codes, prompt them to regenerate a fresh set. This balance between security and usability is what separates good 2FA implementations from frustrating ones.

<?php
/**
 * Backup code best practices:
 * 1. Generate 10+ codes per user
 * 2. Each code is single-use
 * 3. Codes are long and random (8-16 characters)
 * 4. Codes are hashed before storage (never store plain codes)
 * 5. Users can regenerate codes anytime
 */

class WP_2FA_Backup_Codes {
    
    /**
     * Generate and hash backup codes
     */
    public static function generate_hashed_codes($count = 10) {
        $codes = array();
        
        for ($i = 0; $i < $count; $i++) {
            $raw_code = bin2hex(random_bytes(4)); // 8 hex chars
            
            $codes[] = array(
                'code' => wp_hash_password($raw_code), // Hash before storage
                'created' => current_time('mysql'),
                'used' => false,
                'used_at' => null,
            );
        }
        
        return $codes;
    }
    
    /**
     * Verify a backup code against stored hash
     */
    public static function verify_backup_code($user_id, $code) {
        $stored_codes = get_user_meta($user_id, WP_Two_Factor_Auth::USER_META_BACKUP_CODES, true);
        
        if (!is_array($stored_codes)) {
            return false;
        }
        
        foreach ($stored_codes as $key => $item) {
            // Use WordPress's password verification which resists timing attacks
            if (wp_check_password($code, $item['code']) && !$item['used']) {
                // Mark code as used
                $stored_codes[$key]['used'] = true;
                $stored_codes[$key]['used_at'] = current_time('mysql');
                update_user_meta($user_id, WP_Two_Factor_Auth::USER_META_BACKUP_CODES, $stored_codes);
                
                return true;
            }
        }
        
        return false;
    }
    
    /**
     * Regenerate backup codes for user
     */
    public static function regenerate_codes($user_id) {
        $new_codes = self::generate_hashed_codes(10);
        update_user_meta($user_id, WP_Two_Factor_Auth::USER_META_BACKUP_CODES, $new_codes);
        return $new_codes;
    }
    
    /**
     * Get unused backup code count
     */
    public static function get_unused_code_count($user_id) {
        $stored_codes = get_user_meta($user_id, WP_Two_Factor_Auth::USER_META_BACKUP_CODES, true);
        
        if (!is_array($stored_codes)) {
            return 0;
        }
        
        $unused = 0;
        foreach ($stored_codes as $item) {
            if (!$item['used']) {
                $unused++;
            }
        }
        
        return $unused;
    }
    
    /**
     * Display backup codes with warning when running low
     */
    public static function display_backup_codes($user_id) {
        $unused_count = self::get_unused_code_count($user_id);
        
        if ($unused_count < 3) {
            echo '<div class="notice notice-warning"><p>';
            printf(
                __('You have %d backup codes remaining. Consider regenerating new codes.', 'wp-2fa'),
                $unused_count
            );
            echo '</p></div>';
        }
    }
}
?>

Integrating with WordPress Login: The Complete Flow

Here's how the full login flow works with 2FA:

<?php
/**
 * Complete WordPress 2FA Login Integration
 */

class WP_2FA_Login_Integration {
    
    public function __construct() {
        // First authentication attempt (username/password)
        add_filter('authenticate', array($this, 'first_factor_authenticate'), 10, 3);
        
        // Second authentication attempt (2FA code)
        add_filter('authenticate', array($this, 'second_factor_authenticate'), 20, 3);
        
        // Modify login form
        add_action('login_form', array($this, 'login_form_hook'));
        add_action('login_footer', array($this, 'login_footer_script'));
    }
    
    /**
     * First factor: username and password
     */
    public function first_factor_authenticate($user, $username, $password) {
        // If a user is already authenticated, pass through
        if (is_a($user, 'WP_User')) {
            return $user;
        }
        
        // Otherwise authenticate normally
        $user = wp_authenticate_username_password($user, $username, $password);
        
        if (is_wp_error($user)) {
            return $user; // Authentication failed
        }
        
        // If first factor succeeded, check if 2FA is enabled
        if (WP_Two_Factor_Auth::is_2fa_enabled($user->ID)) {
            // Set a transient to indicate first factor success
            $auth_token = wp_generate_password(32, false);
            set_transient(
                'wp_2fa_auth_' . $auth_token,
                $user->ID,
                5 * MINUTE_IN_SECONDS
            );
            
            // Store in session/cookie for validation
            wp_safe_remote_post(admin_url('admin-ajax.php'), array(
                'blocking' => false,
                'body' => array(
                    'action' => 'wp_2fa_session_token',
                    'token' => $auth_token,
                ),
            ));
            
            // Return error to force 2FA screen
            return new WP_Error(
                'wp_2fa_required',
                __('Please enter your 2FA code.', 'wp-2fa')
            );
        }
        
        return $user;
    }
    
    /**
     * Second factor: 2FA code verification
     */
    public function second_factor_authenticate($user, $username, $password) {
        // Check if 2FA code was submitted
        if (!isset($_POST['wp_2fa_code'])) {
            return $user;
        }
        
        $code = sanitize_text_field($_POST['wp_2fa_code']);
        $auth_token = isset($_POST['wp_2fa_token']) ? sanitize_text_field($_POST['wp_2fa_token']) : '';
        
        if (empty($auth_token)) {
            return new WP_Error('wp_2fa_invalid_token', __('Invalid 2FA session.', 'wp-2fa'));
        }
        
        // Retrieve the user from the token
        $user_id = get_transient('wp_2fa_auth_' . $auth_token);
        
        if (!$user_id) {
            return new WP_Error('wp_2fa_session_expired', __('2FA session expired. Please log in again.', 'wp-2fa'));
        }
        
        $user = get_user_by('ID', $user_id);
        
        if (!$user) {
            return new WP_Error('wp_2fa_user_not_found', __('User not found.', 'wp-2fa'));
        }
        
        // Verify the 2FA code
        if (WP_Two_Factor_Auth::verify_code($user_id, $code)) {
            // Code verified! Delete the token
            delete_transient('wp_2fa_auth_' . $auth_token);
            return $user;
        }
        
        return new WP_Error(
            'wp_2fa_invalid_code',
            __('Invalid 2FA code. Please try again.', 'wp-2fa')
        );
    }
    
    /**
     * Show 2FA input on login form
     */
    public function login_form_hook() {
        // Check if we're in the 2FA verification step
        $error = isset($_POST['log']) && isset($_POST['pwd']) && !isset($_POST['wp_2fa_code']);
        
        if ($error) {
            ?>
            <p>
                <label for="wp_2fa_code"><?php _e('Authenticator Code', 'wp-2fa'); ?></label>
                <input 
                    type="text" 
                    name="wp_2fa_code" 
                    id="wp_2fa_code" 
                    class="input" 
                    placeholder="000000"
                    maxlength="6"
                    pattern="[0-9]{6}"
                    autocomplete="off"
                    required
                >
            </p>
            <p>
                <label for="wp_2fa_token"><?php _e('Session Token', 'wp-2fa'); ?></label>
                <input type="hidden" name="wp_2fa_token" id="wp_2fa_token">
            </p>
            <?php
        }
    }
    
    /**
     * JavaScript to manage 2FA session
     */
    public function login_footer_script() {
        ?>
        <script>
        (function() {
            // Retrieve 2FA token from sessionStorage
            var token = sessionStorage.getItem('wp_2fa_token');
            if (token) {
                var field = document.getElementById('wp_2fa_token');
                if (field) {
                    field.value = token;
                }
            }
            
            // Focus on 2FA code field if present
            var codeField = document.getElementById('wp_2fa_code');
            if (codeField) {
                codeField.focus();
            }
        })();
        </script>
        <?php
    }
}

new WP_2FA_Login_Integration();
?>

Testing Your WordPress Two-Factor Authentication Implementation

Before deploying to production, thoroughly test every scenario:

Test successful 2FA flow: Enable 2FA on a test account, log out, log back in, and verify the code requirement works.

Test backup codes: Use a backup code to log in. Verify it's marked as used and can't be reused.

Test code expiration: Try entering an expired code (wait more than 30 seconds). Verify it's rejected.

Test code time skew: The OTPHP library handles this, but verify codes work for the current period and adjacent periods.

Test backup code regeneration: Regenerate codes and verify old codes stop working.

Test 2FA disabling: Disable 2FA and verify the code field no longer appears.

Test recovery flow: Delete a user's backup codes and secret, then verify they can re-enable 2FA.

Use WP HealthKit to audit your 2FA plugin implementation. Our security analysis checks for:

  • Proper nonce verification
  • Secure random number generation
  • Correct TOTP verification
  • Safe backup code storage
  • No hardcoded secrets
  • /upload your plugin for a detailed audit.

Best Practices for WordPress Two-Factor Authentication

Never Store Secrets or Codes in Plain Text

Always hash backup codes. Store TOTP secrets encrypted or with wp_options in a private setting:

<?php
// Good: Hashed backup codes
update_user_meta($user_id, 'wp_2fa_backup_codes', $hashed_codes);

// Good: Encrypted TOTP secret
update_user_meta($user_id, 'wp_2fa_secret', $encrypted_secret);

// Bad: Plain text storage
update_user_meta($user_id, 'wp_2fa_secret', $secret); // Vulnerable!
?>

Implement Rate Limiting

Prevent brute-force attacks on 2FA codes:

<?php
public function rate_limit_2fa_attempts($user_id) {
    $transient_key = 'wp_2fa_attempts_' . $user_id;
    $attempts = get_transient($transient_key) ?: 0;
    
    if ($attempts >= 5) {
        return new WP_Error('wp_2fa_rate_limit', __('Too many failed attempts. Try again in 15 minutes.', 'wp-2fa'));
    }
    
    // Increment attempts
    set_transient($transient_key, $attempts + 1, 15 * MINUTE_IN_SECONDS);
}
?>

Require 2FA for Administrators

Enforce 2FA for high-privilege accounts:

<?php
add_filter('authenticate', function($user) {
    if (!is_wp_error($user) && user_can($user, 'manage_options')) {
        if (!WP_Two_Factor_Auth::is_2fa_enabled($user->ID)) {
            return new WP_Error(
                'wp_2fa_required_admin',
                __('Two-factor authentication is required for administrators.', 'wp-2fa')
            );
        }
    }
    return $user;
}, 30);
?>

Notify Users of 2FA Changes

Send emails when users enable/disable 2FA:

<?php
public function notify_2fa_change($user_id, $enabled) {
    $user = get_user_by('ID', $user_id);
    $subject = $enabled ? 
        __('Two-Factor Authentication Enabled', 'wp-2fa') : 
        __('Two-Factor Authentication Disabled', 'wp-2fa');
    
    wp_mail(
        $user->user_email,
        $subject,
        wp_sprintf(
            __('Two-factor authentication was %s on your account.', 'wp-2fa'),
            $enabled ? 'enabled' : 'disabled'
        )
    );
}
?>

Additional Resources

Frequently Asked Questions

What if a user loses their phone and backup codes?

Administrators can reset 2FA for a user via the admin panel. Implement a recovery code that admins generate and email to the user's backup email address.

Should I use SMS-based 2FA instead of TOTP?

TOTP is more secure than SMS (which can be intercepted or spoofed via SIM swapping). Use TOTP as your primary method. SMS can be a fallback but isn't recommended as the only 2FA method.

Can users use multiple authenticator apps?

The current implementation only supports one secret per user. You could extend it to support multiple devices by storing multiple secrets and allowing any to authenticate.

What happens if the user's system clock is wrong?

TOTP has a built-in time window tolerance. The OTPHP library checks the current code and codes from ±1 period (±30 seconds). If the clock is significantly off, the code will fail.

Is TOTP compatible with all authenticator apps?

Yes. TOTP is an open standard. Any authenticator app that supports TOTP (Google Authenticator, Authy, Microsoft Authenticator, etc.) will work with your implementation.

How do I handle 2FA for API access?

For API authentication, you might use application passwords or API keys instead of TOTP. You could extend this plugin to support 2FA verification via API tokens.

Conclusion

WordPress two-factor authentication is the single most effective way to prevent account compromise. By implementing TOTP with backup codes, you give users enterprise-grade security without the complexity of SMS or hardware tokens.

The implementation uses battle-tested open-source libraries, follows WordPress security practices, and provides a smooth user experience. Backup codes ensure users aren't locked out, and rate limiting prevents brute-force attacks.

Start with report-only mode if you're enforcing 2FA organization-wide. Monitor user feedback and provide clear recovery documentation. Most users will appreciate the enhanced security once they understand the value.

Ready to audit your 2FA implementation? Upload your plugin to WP HealthKit for an automated security review that checks nonce verification, rate limiting, proper code storage, and more. Our analysis ensures your authentication system is bulletproof.


Secure your WordPress site with two-factor authentication. Upload to WP HealthKit for an instant security audit of your authentication plugin.

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 49 verification layers.

Comments

WordPress Two-Factor Authentication: Plugin Implementation | WP HealthKit