Two-factor authentication (2FA) adds a crucial layer of security to your WordPress site by requiring users to provide a second verification factor in addition to their password. While plenty of plugins offer ready-made 2FA solutions, implementing it programmatically gives you ultimate control, allowing for custom integrations, unique user experiences, and a deeper understanding of your site’s security architecture. This guide will walk you through the practical steps to build your own 2FA system for WordPress, focusing on a code-first approach.
Before diving into code, let’s get a handle on what 2FA actually entails and how it fits into the WordPress ecosystem.
What is 2FA and Why is it Important for WordPress?
At its heart, 2FA means verifying a user’s identity based on two different “factors.” These typically fall into three categories:
- Something you know: Like a password.
- Something you have: Like a phone or physical token.
- Something you are: Like a fingerprint or facial scan (though less common for web 2FA).
For WordPress, this usually translates to a password combined with a code from an authenticator app (like Google Authenticator or Authy) or a code sent via email/SMS. This significantly reduces the risk of unauthorized access even if a hacker compromises a user’s password. Given that WordPress is a popular target, robust authentication is non-negotiable.
Key Components of a Programmatic 2FA System
Building 2FA from scratch involves managing several moving parts. You’ll need to consider:
- User Enrolment: How users enable 2FA on their account.
- Key Generation and Storage: Creating and securely storing the secret key (seed) for TOTP (Time-Based One-Time Password) or managing email/SMS tokens.
- Verification Process: How users submit their second factor and how your code validates it.
- User Interface (UI) Integration: Adding fields to the login page and profile page for 2FA setup and input.
- Error Handling and Recovery: What happens if a user loses their device or enters an incorrect code.
If you’re looking to enhance the security of your WordPress site, implementing two-factor authentication is a crucial step. For a deeper understanding of this process, you can refer to a related article that provides insights and best practices on securing your website. Check out this informative piece on how to implement two-factor authentication programmatically in WordPress by visiting this link.
Choosing Your Second Factor: TOTP vs. Email/SMS
The first big decision is which type of second factor you want to implement. Each has its pros and cons.
Time-Based One-Time Passwords (TOTP)
TOTP is the most common form of 2FA, used by apps like Google Authenticator, Authy, and Microsoft Authenticator.
- How it works: A secret key (usually a base32 encoded string) is stored securely on the server and shared with the user’s authenticator app. Both the server and the app use this key, the current time, and a hashing algorithm to generate a unique 6-digit code that changes every 30-60 seconds.
- Advantages:
- No external service dependencies for code delivery (like an SMS gateway).
- Generally considered more secure as codes are generated locally on the device.
- Works offline once set up.
- Disadvantages:
- Requires users to install and set up an authenticator app.
- Loss of device can be a challenge without recovery codes.
- Initial setup involves scanning a QR code, which needs a good UI.
Email-Based One-Time Passwords
Sending codes via email is another viable option, especially for users who prefer not to use an authenticator app.
- How it works: When a user attempts to log in, a unique, short-lived code is generated and sent to their registered email address. The user then enters this code to complete validation.
- Advantages:
- No app required; most users already have email access.
- Easier user experience for setup.
- Disadvantages:
- Relies on email delivery, which can sometimes be slow or unreliable.
- Security is tied to the security of the user’s email account.
- Can be vulnerable to phishing if not implemented carefully.
SMS-Based One-Time Passwords
Similar to email, but codes are sent via text message.
- How it works: Requires integration with an SMS gateway API (e.g., Twilio, Nexmo) to send codes to the user’s registered phone number.
- Advantages:
- Very familiar and convenient for most users.
- Good for users without smartphones or stable internet for authenticator apps.
- Disadvantages:
- Cost: SMS gateways charge per message.
- External Dependency: Relies on third-party services.
- SIM Swapping Attacks: Can be vulnerable to advanced attacks where attackers port a user’s phone number to their own SIM. This makes it generally less secure than TOTP.
For the purpose of this guide, we’ll focus on implementing TOTP as it offers a robust and generally preferred security method, and the principles can be adapted for email/SMS.
Setting Up Your Development Environment
Before writing any code, ensure you have a proper development environment.
Local WordPress Installation
Working on a local setup is crucial. Tools like Local by WP Engine, MAMP, XAMPP, or Docker can help you set up a WordPress environment quickly. This prevents issues on your live site and allows for safe experimentation.
Custom Plugin Structure
We’ll build our 2FA functionality within a custom plugin. This keeps your code organized and separate from your theme, making it easier to manage and update.
- Create a new folder in
wp-content/plugins/(e.g.,my-custom-2fa). - Inside this folder, create a PHP file with the same name as your folder (e.g.,
my-custom-2fa.php). - Add the plugin header to
my-custom-2fa.php:
“`php
/*
Plugin Name: My Custom 2FA
Plugin URI: https://example.com/my-custom-2fa
Description: Programmatic Two-Factor Authentication for WordPress.
Version: 1.0
Author: Your Name
Author URI: https://example.com
License: GPL2
*/
// Prevent direct access to the file.
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}
// Your plugin code will go here.
“`
- Activate your plugin from the WordPress admin dashboard under “Plugins.”
Implementing TOTP: Step-by-Step Code Walkthrough
Now for the core implementation. We’ll break this down into several logical steps.
Step 1: User Profile Integration and Secret Key Generation
Users need a way to enable 2FA and generate their secret key.
Custom User Meta Fields
We’ll store the 2FA secret key and its enabled status as user meta.
“`php
// Add 2FA fields to user profile
function my_custom_2fa_user_profile_fields( $user ) {
?>
|
$is_2fa_enabled = get_user_meta( $user->ID, ‘my_custom_2fa_enabled’, true ); $secret_key = get_user_meta( $user->ID, ‘my_custom_2fa_secret’, true ); if ( $is_2fa_enabled && ! empty( $secret_key ) ) { echo ‘ ‘ . __( ‘2FA is Enabled’, ‘my-custom-2fa’ ) . ‘ ‘; echo ‘ ‘; } else { echo ‘ ‘ . __( ‘2FA is Disabled’, ‘my-custom-2fa’ ) . ‘ ‘; echo ‘ ‘; } ?> |
}
add_action( ‘show_user_profile’, ‘my_custom_2fa_user_profile_fields’ );
add_action( ‘edit_user_profile’, ‘my_custom_2fa_user_profile_fields’ );
“`
This code adds a section to the user’s profile where they can enable/disable 2FA. We use get_user_meta to check existing status. The my_custom_2fa_setup_area will be dynamically shown or hidden.
Generating the Secret Key and QR Code
For TOTP, we need a library to handle the secret key generation and QR code creation. The “GoogleAuthenticator.php” library by PHPGangsta is a popular choice. You’ll need to include this file in your plugin. Create a lib folder inside your plugin and place the GoogleAuthenticator.php file there.
“`php
// Include external library for TOTP functionality
require_once plugin_dir_path( __FILE__ ) . ‘lib/GoogleAuthenticator.php’;
// Handle AJAX request for generating 2FA secret and QR code
function my_custom_2fa_generate_secret_ajax() {
if ( ! current_user_can( ‘edit_user’, get_current_user_id() ) ) {
wp_send_json_error( array( ‘message’ => __( ‘Permission denied.’, ‘my-custom-2fa’ ) ) );
}
$ga = new PHPGangsta_GoogleAuthenticator();
$secret = $ga->createSecret();
$user = wp_get_current_user();
$site_name = get_bloginfo( ‘name’ );
$qr_code_url = $ga->getQRCodeGoogleUrl( $user->user_login . ‘@’ . $site_name, $secret );
wp_send_json_success( array(
‘secret’ => $secret,
‘qr_code_url’ => $qr_code_url,
) );
}
add_action( ‘wp_ajax_my_custom_2fa_generate_secret’, ‘my_custom_2fa_generate_secret_ajax’ );
// Handle AJAX request for confirming 2FA setup
function my_custom_2fa_confirm_setup_ajax() {
if ( ! current_user_can( ‘edit_user’, get_current_user_id() ) ) {
wp_send_json_error( array( ‘message’ => __( ‘Permission denied.’, ‘my-custom-2fa’ ) ) );
}
$user_id = get_current_user_id();
$secret_key = sanitize_text_field( $_POST[‘secret_key’] );
$verification_code = sanitize_text_field( $_POST[‘verification_code’] );
if ( empty( $secret_key ) || empty( $verification_code ) ) {
wp_send_json_error( array( ‘message’ => __( ‘Secret key and verification code are required.’, ‘my-custom-2fa’ ) ) );
}
$ga = new PHPGangsta_GoogleAuthenticator();
$check_result = $ga->verifyCode( $secret_key, $verification_code, 2 ); // 2 = 2*30sec clock drift
if ( $check_result ) {
update_user_meta( $user_id, ‘my_custom_2fa_secret’, $secret_key );
update_user_meta( $user_id, ‘my_custom_2fa_enabled’, true );
wp_send_json_success( array( ‘message’ => __( ‘2FA has been successfully enabled!’, ‘my-custom-2fa’ ) ) );
} else {
wp_send_json_error( array( ‘message’ => __( ‘Incorrect 2FA code. Please try again.’, ‘my-custom-2fa’ ) ) );
}
}
add_action( ‘wp_ajax_my_custom_2fa_confirm_setup’, ‘my_custom_2fa_confirm_setup_ajax’ );
// Handle AJAX request for disabling 2FA
function my_custom_2fa_disable_ajax() {
if ( ! current_user_can( ‘edit_user’, get_current_user_id() ) ) {
wp_send_json_error( array( ‘message’ => __( ‘Permission denied.’, ‘my-custom-2fa’ ) ) );
}
$user_id = get_current_user_id();
delete_user_meta( $user_id, ‘my_custom_2fa_secret’ );
update_user_meta( $user_id, ‘my_custom_2fa_enabled’, false );
wp_send_json_success( array( ‘message’ => __( ‘2FA has been successfully disabled.’, ‘my-custom-2fa’ ) ) );
}
add_action( ‘wp_ajax_my_custom_2fa_disable’, ‘my_custom_2fa_disable_ajax’ );
“`
This section uses AJAX for dynamic profile updates:
my_custom_2fa_generate_secret_ajax: Creates a new secret key and generates a QR code URL using thePHPGangsta_GoogleAuthenticatorlibrary.my_custom_2fa_confirm_setup_ajax: Verifies the user’s initial code and saves the secret key and enabled status to user meta.my_custom_2fa_disable_ajax: Removes the secret key and disables 2FA.
JavaScript for Profile UI
We need JavaScript to handle the button clicks, AJAX calls, and display of the QR code.
“`php
// Enqueue necessary JS for profile page
function my_custom_2fa_enqueue_profile_scripts( $hook_suffix ) {
if ( ‘profile.php’ === $hook_suffix || ‘user-edit.php’ === $hook_suffix ) {
wp_enqueue_script(
‘my-custom-2fa-profile’,
plugin_dir_url( __FILE__ ) . ‘assets/profile-2fa.js’,
array( ‘jquery’ ),
‘1.0’,
true
);
wp_localize_script(
‘my-custom-2fa-profile’,
‘myCustom2FA’,
array(
‘ajax_url’ => admin_url( ‘admin-ajax.php’ ),
‘nonce’ => wp_create_nonce( ‘my-custom-2fa-nonce’ ), // You might want to generate nonces per action
)
);
}
}
add_action( ‘admin_enqueue_scripts’, ‘my_custom_2fa_enqueue_profile_scripts’ );
“`
Create an assets folder in your plugin and inside it, a file named profile-2fa.js.
assets/profile-2fa.js:
“`javascript
jQuery(document).ready(function($) {
var $setupArea = $(‘#my_custom_2fa_setup_area’);
var $qrCodeDiv = $(‘#my_custom_2fa_qr_code’);
var $qrImg = $(‘#my_custom_2fa_qr_img’);
var $manualKey = $(‘#my_custom_2fa_manual_key’);
var $verificationCodeInput = $(‘#my_custom_2fa_verification_code’);
var $setupMessage = $(‘#my_custom_2fa_setup_message’);
// Function to display messages
function displayMessage(message, type) {
$setupMessage.html(‘
‘ + message + ‘
‘);
}
// Enable 2FA button click
$(‘#my_custom_2fa_enable_button’).on(‘click’, function() {
$(this).hide(); // Hide enable button
$setupArea.slideDown();
$setupMessage.empty();
$.ajax({
url: myCustom2FA.ajax_url,
type: ‘POST’,
data: {
action: ‘my_custom_2fa_generate_secret’,
_wpnonce: myCustom2FA.nonce // Basic nonce for all AJAX, refine if needed
},
success: function(response) {
if (response.success) {
$qrImg.attr(‘src’, response.data.qr_code_url);
$manualKey.text(response.data.secret);
$qrCodeDiv.slideDown();
// Store secret temporarily for confirmation
$qrCodeDiv.data(‘2fa-secret’, response.data.secret);
} else {
displayMessage(response.data.message, ‘error’);
}
},
error: function() {
displayMessage(‘Error generating 2FA secret.’, ‘error’);
}
});
});
// Confirm 2FA setup button click
$(‘#my_custom_2fa_confirm_button’).on(‘click’, function() {
var secret = $qrCodeDiv.data(‘2fa-secret’);
var code = $verificationCodeInput.val();
if (!secret || !code) {
displayMessage(‘Please generate a secret and enter a code.’, ‘error’);
return;
}
$.ajax({
url: myCustom2FA.ajax_url,
type: ‘POST’,
data: {
action: ‘my_custom_2fa_confirm_setup’,
secret_key: secret,
verification_code: code,
_wpnonce: myCustom2FA.nonce
},
success: function(response) {
if (response.success) {
displayMessage(response.data.message, ‘success’);
// Hide setup, show enabled status
$setupArea.slideUp(function() {
location.reload(); // Reload to refresh the profile fields
});
} else {
displayMessage(response.data.message, ‘error’);
}
},
error: function() {
displayMessage(‘Error confirming 2FA setup.’, ‘error’);
}
});
});
// Disable 2FA button click
$(‘#my_custom_2fa_disable_button’).on(‘click’, function() {
if (confirm(‘Are you sure you want to disable 2FA for your account?’)) {
$.ajax({
url: myCustom2FA.ajax_url,
type: ‘POST’,
data: {
action: ‘my_custom_2fa_disable’,
_wpnonce: myCustom2FA.nonce
},
success: function(response) {
if (response.success) {
displayMessage(response.data.message, ‘success’);
location.reload(); // Reload to refresh the profile fields
} else {
displayMessage(response.data.message, ‘error’);
}
},
error: function() {
displayMessage(‘Error disabling 2FA.’, ‘error’);
}
});
}
});
});
“`
This script handles the client-side logic for the user profile, making it interactive.
Step 2: Modifying the Login Process
This is where the 2FA truly kicks in, intercepting logins for users with 2FA enabled.
Intercepting the Login Attempt
WordPress’s wp_authenticate_user filter is perfect for this. We’ll use it to check if 2FA is enabled for the user trying to log in.
“`php
// Intercept login process for 2FA
function my_custom_2fa_authenticate( $user, $password ) {
// If authentication already failed, or wp_authenticate_user received non-user object, skip.
if ( is_wp_error( $user ) || ! ( $user instanceof WP_User ) ) {
return $user;
}
// Is 2FA enabled for this user?
$is_2fa_enabled = get_user_meta( $user->ID, ‘my_custom_2fa_enabled’, true );
$secret_key = get_user_meta( $user->ID, ‘my_custom_2fa_secret’, true );
if ( $is_2fa_enabled && ! empty( $secret_key ) ) {
// Store user ID temporarily in session (or cookie) to retrieve after 2FA form submission
// IMPORTANT: For production, consider using a more robust temporary storage solution
// that’s unique per login attempt to prevent session fixation or race conditions.
// For simplicity in this guide, we use a basic session.
if ( ! session_id() ) {
session_start();
}
$_SESSION[‘my_custom_2fa_user_id’] = $user->ID;
// Redirect to a custom 2FA verification page
wp_redirect( add_query_arg( ‘my_custom_2fa’, ‘verify’, wp_login_url() ) );
exit();
}
return $user;
}
add_filter( ‘authenticate’, ‘my_custom_2fa_authenticate’, 30, 2 ); // Run after default password check (default priority is 20)
“`
The authenticate filter is powerful. We check if 2FA is enabled for the user. If it is, we store the user’s ID temporarily (a session variable is simple for this demo, but consider robust alternatives for production) and redirect them to a page where they can enter their 2FA code.
Creating the 2FA Verification Form
We need a way for users to input their 2FA code. We can hook into the standard WordPress login form.
“`php
// Display the 2FA verification form on the login page
function my_custom_2fa_login_form() {
if ( isset( $_GET[‘my_custom_2fa’] ) && $_GET[‘my_custom_2fa’] === ‘verify’ ) {
if ( ! session_id() ) {
session_start();
}
$user_id = isset( $_SESSION[‘my_custom_2fa_user_id’] ) ? (int) $_SESSION[‘my_custom_2fa_user_id’] : 0;
if ( ! $user_id || ! get_user_meta( $user_id, ‘my_custom_2fa_enabled’, true ) ) {
// No user ID or 2FA not enabled, redirect to normal login
wp_redirect( wp_login_url() );
exit();
}
?>
// We’re displaying our own form, so prevent the default login form.
remove_action( ‘login_form’, ‘wp_login_form’ );
}
}
add_action( ‘login_form’, ‘my_custom_2fa_login_form’ );
// Display error messages on the login screen
function my_custom_2fa_login_error_message( $errors, $error_code ) {
if ( $error_code === ‘my_custom_2fa_failed’ ) {
$errors->add( ‘my_custom_2fa_failed’, __( ‘ERROR: Incorrect 2FA code. Please try again.’, ‘my-custom-2fa’ ) );
}
return $errors;
}
add_filter( ‘wp_login_errors’, ‘my_custom_2fa_login_error_message’, 10, 2 );
“`
This code uses login_form action to display a custom form when the URL contains ?my_custom_2fa=verify. It retrieves the user ID from the session, ensuring it’s a valid user who just passed the password check. Another filter wp_login_errors is used to display custom error messages.
Verifying the 2FA Code
After the user submits the 2FA code, we need to validate it and log them in.
“`php
// Process the 2FA verification form submission
function my_custom_2fa_login_process() {
if ( isset( $_POST[‘my_custom_2fa_verify’] ) && $_POST[‘my_custom_2fa_verify’] === ‘1’ ) {
if ( ! session_id() ) {
session_start();
}
$user_id = isset( $_SESSION[‘my_custom_2fa_user_id’] ) ? (int) $_SESSION[‘my_custom_2fa_user_id’] : 0;
// Clear session data as early as possible after retrieval
unset( $_SESSION[‘my_custom_2fa_user_id’] );
if ( ! $user_id || ! wp_verify_nonce( $_POST[‘my_custom_2fa_nonce_field’], ‘my_custom_2fa_login_verify’ ) ) {
wp_redirect( wp_login_url() . ‘?login_error=invalid_2fa_request’ );
exit();
}
$user = get_user_by( ‘ID’, $user_id );
$secret_key = get_user_meta( $user->ID, ‘my_custom_2fa_secret’, true );
$provided_code = sanitize_text_field( $_POST[‘my_custom_2fa_code’] );
$ga = new PHPGangsta_GoogleAuthenticator();
$check_result = $ga->verifyCode( $secret_key, $provided_code, 2 ); // Allow 2*30 sec clock drift
if ( $check_result ) {
// Successful 2FA, log the user in
wp_set_current_user( $user->ID, $user->user_login );
wp_set_auth_cookie( $user->ID, true ); // ‘true’ for ‘remember me’
do_action( ‘wp_login’, $user->user_login, $user );
// Determine redirect URL
$redirect_to = admin_url();
if ( isset( $_GET[‘redirect_to’] ) && $_GET[‘redirect_to’] != ” ) {
$redirect_to = $_GET[‘redirect_to’];
}
wp_redirect( $redirect_to );
exit();
} else {
// 2FA failed, back to login with error
wp_redirect( wp_login_url() . ‘?login_error=my_custom_2fa_failed’ );
exit();
}
}
}
add_action( ‘login_form_login’, ‘my_custom_2fa_login_process’ ); // Hooks into ‘wp-login.php?action=login’ or default login
“`
This function runs right before WordPress normally processes login credentials. It retrieves the user ID from the session, verifies the 2FA code, and if successful, logs the user in using wp_set_auth_cookie. If verification fails, it redirects back to the login page with an error.
Step 3: Handling Emergency/Recovery Codes (Optional but Recommended)
What if a user loses their phone or authenticator app? Recovery codes are essential.
Generating Recovery Codes
Add an option on the user profile to generate a set of one-time recovery codes.
“`php
// Add recovery code generation to user profile
function my_custom_2fa_recovery_codes_field( $user ) {
$recovery_codes_hash = get_user_meta( $user->ID, ‘my_custom_2fa_recovery_codes_hash’, true );
?>
}
add_action( ‘show_user_profile’, ‘my_custom_2fa_recovery_codes_field’ );
add_action( ‘edit_user_profile’, ‘my_custom_2fa_recovery_codes_field’ );
// AJAX handler to generate/store recovery codes
function my_custom_2fa_generate_recovery_codes_ajax() {
if ( ! current_user_can( ‘edit_user’, get_current_user_id() ) ) {
wp_send_json_error( array( ‘message’ => __( ‘Permission denied.’, ‘my-custom-2fa’ ) ) );
}
$user_id = get_current_user_id();
$codes = array();
for ( $i = 0; $i < 10; $i++ ) { // Generate 10 codes
$codes[] = wp_generate_password( 10, false ); // 10-char, no special chars
}
// Store a hash of each code, NOT the codes themselves, for security
// We store an array of hashes. When a code is used, its hash is removed.
$hashed_codes = array_map( ‘wp_hash_password’, $codes );
update_user_meta( $user_id, ‘my_custom_2fa_recovery_codes_hash’, $hashed_codes );
wp_send_json_success( array(
‘message’ => __( ‘Recovery codes generated successfully. Please save them!’, ‘my-custom-2fa’ ),
‘codes’ => $codes,
) );
}
add_action( ‘wp_ajax_my_custom_2fa_generate_recovery_codes’, ‘my_custom_2fa_generate_recovery_codes_ajax’ );
“`
This adds a section for recovery codes. When generated, 10 unique codes are created, and their hashes are stored in user meta. The actual codes are displayed to the user once.
Using Recovery Codes at Login
Modify the 2FA login form to include an option for recovery codes.
“`php
// Extend the login form for recovery codes
function my_custom_2fa_login_form_recovery() {
if ( isset( $_GET[‘my_custom_2fa’] ) && $_GET[‘my_custom_2fa’] === ‘verify’ ) {
// Display recovery code option
?>
}
}
add_action( ‘login_form’, ‘my_custom_2fa_login_form_recovery’, 100 ); // After the main 2FA form
// Modify the login processing to handle recovery codes
function my_custom_2fa_login_process_recovery() {
if ( isset( $_POST[‘my_custom_2fa_verify’] ) && $_POST[‘my_custom_2fa_verify’] === ‘1’ ) {
if ( ! session_id() ) {
session_start();
}
$user_id = isset( $_SESSION[‘my_custom_2fa_user_id’] ) ? (int) $_SESSION[‘my_custom_2fa_user_id’] : 0;
$user = get_user_by( ‘ID’, $user_id );
// If recovery code provided
if ( ! empty( $_POST[‘my_custom_2fa_recovery_code’] ) ) {
$provided_recovery_code = sanitize_text_field( $_POST[‘my_custom_2fa_recovery_code’] );
$hashed_recovery_codes = get_user_meta( $user_id, ‘my_custom_2fa_recovery_codes_hash’, true );
if ( is_array( $hashed_recovery_codes ) && ! empty( $hashed_recovery_codes ) ) {
$matched_index = -1;
foreach ( $hashed_recovery_codes as $index => $hashed_code ) {
if ( wp_check_password( $provided_recovery_code, $hashed_code ) ) {
$matched_index = $index;
break;
}
}
if ( $matched_index !== -1 ) {
// Valid recovery code, remove it and log in
unset( $hashed_recovery_codes[ $matched_index ] );
update_user_meta( $user_id, ‘my_custom_2fa_recovery_codes_hash’, array_values( $hashed_recovery_codes ) ); // Reset array keys
// Proceed to login
wp_set_current_user( $user->ID, $user->user_login );
wp_set_auth_cookie( $user->ID, true );
do_action( ‘wp_login’, $user->user_login, $user );
unset( $_SESSION[‘my_custom_2fa_user_id’] ); // Clear session
$redirect_to = admin_url();
if ( isset( $_GET[‘redirect_to’] ) && $_GET[‘redirect_to’] != ” ) {
$redirect_to = $_GET[‘redirect_to’];
}
wp_redirect( $redirect_to );
exit();
}
}
// If recovery code failed or no codes exist
wp_redirect( wp_login_url() . ‘?login_error=my_custom_2fa_failed’ );
exit();
}
// If it’s not a recovery code, let the main 2FA process handle it normally
}
}
add_action( ‘login_form_login’, ‘my_custom_2fa_login_process_recovery’, 5 ); // Run before the main 2FA processing to prioritize recovery codes
“`
This adds a link to toggle a recovery code input field on the login page. The my_custom_2fa_login_process_recovery function, when called earlier, checks for a recovery code first. If valid, it removes the used code’s hash from user meta and logs the user in.
JavaScript for Recovery Codes UI
Update profile-2fa.js to handle recovery code generation:
“`javascript
// Add to profile-2fa.js
// Generate Recovery Codes button click
$(‘#my_custom_2fa_generate_recovery’).on(‘click’, function() {
$setupMessage.empty(); // Clear any existing messages
$.ajax({
url: myCustom2FA.ajax_url,
type: ‘POST’,
data: {
action: ‘my_custom_2fa_generate_recovery_codes’,
_wpnonce: myCustom2FA.nonce
},
success: function(response) {
if (response.success) {
displayMessage(response.data.message, ‘success’);
var $recoveryList = $(‘#my_custom_2fa_recovery_list’);
$recoveryList.empty(); // Clear previous codes
$.each(response.data.codes, function(index, code) {
$recoveryList.append(‘
‘);
});
$(‘#my_custom_2fa_recovery_display’).slideDown();
// Optionally disable the generate button until page reload or ask for confirmation
} else {
displayMessage(response.data.message, ‘error’);
}
},
error: function() {
displayMessage(‘Error generating recovery codes.’, ‘error’);
}
});
});
// Add to login form (independent JS for login screen, not profile-2fa.js)
// You’d need a separate login.js and enqueue it for the login screen.
// Example for a separate assets/login-2fa.js file:
/*
jQuery(document).ready(function($) {
$(‘#my_custom_2fa_show_recovery_form’).on(‘click’, function(e) {
e.preventDefault();
$(‘#my_custom_2fa_recovery_section’).slideToggle();
$(‘#my_custom_2fa_code’).val(”); // Clear TOTP field
$(‘#my_custom_2fa_recovery_code’).focus();
});
});
*/
// Enqueue this separate script on login page
// function my_custom_2fa_enqueue_login_scripts() {
// wp_enqueue_script(
// ‘my-custom-2fa-login’,
// plugin_dir_url( __FILE__ ) . ‘assets/login-2fa.js’,
// array( ‘jquery’ ),
// ‘1.0’,
// true
// );
// }
// add_action( ‘login_enqueue_scripts’, ‘my_custom_2fa_enqueue_login_scripts’ );
“`
This updates the profile JS to handle the display of recovery codes. For the login page, you’d need a separate JS file enqueued via login_enqueue_scripts to manage the toggle of the recovery code input field.
Implementing two-factor authentication programmatically in WordPress is a crucial step towards enhancing the security of your website. For those looking to deepen their understanding of server management and security, a related article discusses the process of migrating from one CyberPanel to another, which can be beneficial for maintaining a secure hosting environment. You can read more about this topic in the article available here. This knowledge can complement your efforts in securing your WordPress site effectively.
Security Considerations and Best Practices
Implementing 2FA programmatically gives you power but also responsibility.
Secure Storage of Secret Keys and Recovery Hashes
- User Meta is OK, but encryption is better: While storing secrets in
user_metais common, sensitive data should ideally be encrypted at rest, especially if your database is compromised. Consider using the WordPresswp_encryptandwp_decryptfunctions if available from a security plugin, or implement your own encryption if you’re confident. - Never store plain recovery codes: Only store a hashed version of recovery codes, similar to how passwords are stored. Each time a recovery code is used, its hash should be removed from the database.
Nonces and CSRF Protection
- Always use WordPress Nonces for all AJAX actions and form submissions to protect against Cross-Site Request Forgery (CSRF). While the examples use a single nonce
myCustom2FA.nonce, it’s best practice to generate unique nonces for each specific action if you have multiple AJAX endpoints.
Brute Force Protection
- Limit 2FA attempts: Implement rate limiting for 2FA code submissions to prevent brute-force attacks on the 2FA code itself. If a user enters X incorrect codes within Y minutes, lock their account or significantly increase the delay between attempts.
- IP-based limiting: Block IPs after excessive failed attempts for both password and 2FA.
Error Handling and User Experience
- Clear error messages: Provide helpful, but not overly revealing, error messages. Instead of “Incorrect secret key”, use “Incorrect 2FA code.”
- What if a user is locked out? Implement an “account recovery” workflow beyond just recovery codes, such as manual admin intervention or a support process. This is crucial for user experience and preventing legitimate users from being permanently locked out.
- Grace period for new 2FA setup: Consider a short grace period (e.g., 5-10 minutes) after a user enables 2FA during which they can still log in with just their password, in case their initial setup fails.
Code Review
- Have experienced developers review your 2FA code, as security vulnerabilities can be subtle and have significant impact.
Implementing two-factor authentication in WordPress is a crucial step towards enhancing your site’s security. For those looking to dive deeper into related security measures, you might find it helpful to explore how to integrate payment systems securely. A great resource for this is an article that discusses various payment implementation strategies, which can be found here. By combining two-factor authentication with secure payment methods, you can significantly bolster your website’s protection against unauthorized access and fraud.
Wrapping Up Your Custom 2FA Solution
You’ve now built a foundational programmatic 2FA system for your WordPress site.
Testing Thoroughly
- Test all scenarios:
- Enable 2FA.
- Disable 2FA.
- Log in with a correct 2FA code.
- Log in with an incorrect 2FA code.
- Log in if 2FA is enabled but you provide just a password.
- Generate and use recovery codes.
- Attempt to use a recovery code twice.
- Attempt to log in when locked out by 2FA.
- Test with different user roles and browsers.
Documentation
- Document your code thoroughly so future developers (or your future self) can understand and maintain it.
- Provide clear instructions for your users on how to enable, use, and recover 2FA.
Implementing 2FA programmatically is a powerful way to enhance your WordPress security. While more complex than using a plugin, it offers unparalleled flexibility and a deeper understanding of your site’s security posture. By following these steps and prioritizing security best practices, you can build a robust 2FA solution tailored to your specific needs.