Contents
Overview
This article shows a complete, practical tutorial to add a basic two-factor authentication (2FA) by email for WordPress logins using PHP. The approach is implemented as a self-contained plugin (drop-in) and works by intercepting a successful username/password authentication, sending a one-time numeric code to the users registered email, and requiring that code to complete the login. The tutorial includes full plugin source code, installation steps, security considerations, optional improvements, troubleshooting, and testing tips.
What this solution does (brief)
- Intercepts a normal WP login after password verification.
- Generates a time-limited one-time code (OTP) and emails it to the users account email address.
- Stores a hashed OTP and expiry in the users metadata and marks the login as pending.
- Shows an extra 2FA code input on the WP login screen when a code is pending.
- Verifies the submitted code, enforces basic rate-limiting and expiry, then allows the login.
- Is intentionally minimal so it can be adapted to other channels (SMS, authenticator apps) later.
Prerequisites and assumptions
- WordPress site with ability to install a plugin or edit theme files.
- wp_mail() is configured and able to send emails from the site (SMTP plugin or host mail).
- PHP 5.5 is recommended for password_hash/password_verify. If your PHP is older, adapt hashing accordingly.
- This tutorial provides a simple, secure-enough baseline for small/medium sites. For high-security needs, use proven 2FA plugins or multi-channel solutions.
Design summary and flow
- User submits username and password at wp-login.php.
- WordPress authenticates username/password (normal process).
- Our plugin sees a successful authentication and:
- Generates an OTP, stores its hash expiry in user meta, sets a pending flag, and sends the OTP to the users email.
- Returns a WP_Error to halt the final login and display a friendly message to check email.
- The login screen shows an extra field 2FA code because the pending flag exists for the username typed.
- User enters the code and re-submits the form.
- Plugin verifies the OTP:
- If valid and not expired, clears pending meta and allows the login to complete.
- If invalid, increments attempt counters and rejects until attempts are exhausted or expired.
Security considerations (important)
- b Store only a hashed version of the OTP. This example uses password_hash/password_verify (PHP built-in) for secure hashing.
- b OTPs must be short-lived. The example uses a default expiry of 10 minutes.
- b Add rate-limiting for OTP verification attempts per user to protect against brute force.
- b Do not store OTPs in plain text, logs, or send them via unencrypted channels other than the users account email. (Email security depends on provider.)
- b Use HTTPS on your site — required for transport-layer protection of credentials and OTP submission.
- b Provide a fallback or recovery option for users who cannot access their email. This tutorial doesnt implement recovery flows.
Installation options
Two options are provided:
- Create a small plugin file and drop it in wp-content/plugins/ then activate it.
- Paste the code into your themes functions.php (not recommended for portability and maintainability).
Complete plugin code
Save the following as a file named, for example, wp-email-2fa.php in wp-content/plugins/wp-email-2fa/ and activate through the WP admin Plugins screen.
get_error_code() ) { return user } // If no username provided, nothing to do here if ( empty( username ) ) { return user } // Find user by login (username) if needed if ( ! user ! ( user instanceof WP_User ) ) { maybe_user = get_user_by( login, username ) if ( ! maybe_user ) { // Let core handle non-existing users / password errors return user } user = maybe_user } // If the login request includes a 2FA code, verify it if ( isset( _POST[wp_email_2fa_code] ) strlen( trim( _POST[wp_email_2fa_code] ) ) ) { return wp_email_2fa_verify_code_step( user, trim( wp_unslash( _POST[wp_email_2fa_code] ) ) ) } // If user is already marked as having a pending 2FA (rare), require the code if ( get_user_meta( user->ID, wp_email_2fa_pending, true ) ) { // Message will be displayed by WP return an error so login halts and the 2FA input is shown return new WP_Error( wp_email_2fa_required, __( Error: Two-factor authentication code required. Check your email and enter the code below. ) ) } // At this point, the core authentication succeeded earlier (were called after core). // Initiate 2FA: generate OTP, store it, email it, set pending flag, then stop the login flow. sent = wp_email_2fa_send_code_to_user( user ) if ( is_wp_error( sent ) ) { // Could not send code (e.g., no email) surface a useful message and halt return sent } // Halt login and prompt for the code (the login page will reload and show the extra input) return new WP_Error( wp_email_2fa_sent, __( Check your email for a login code. Enter it below to complete login. ) ) } / Generate an OTP numeric code of specified length. / function wp_email_2fa_generate_code( length = WP_EMAIL_2FA_CODE_LENGTH ) { digits = 0123456789 code = max = strlen( digits ) - 1 if ( function_exists( random_int ) ) { for ( i = 0 i < length i ) { code .= digits[ random_int( 0, max ) ] } } else { for ( i = 0 i < length i ) { code .= digits[ mt_rand( 0, max ) ] } } return code } / Send code to users email, hash and store meta. Returns true on success or WP_Error on failure. / function wp_email_2fa_send_code_to_user( user ) { if ( ! user ! user instanceof WP_User ) { return new WP_Error( invalid_user, __( Invalid user for 2FA. ) ) } email = user->user_email if ( ! is_email( email ) ) { return new WP_Error( no_email, __( No valid email address for user. Contact the site admin. ) ) } // Generate OTP code = wp_email_2fa_generate_code() // Hash the OTP (use password_hash for one-time codes) if ( function_exists( password_hash ) ) { hash = password_hash( code, PASSWORD_DEFAULT ) } else { // Fallback: use wp_hash_password if password_hash is not available hash = wp_hash_password( code ) } expires = time() WP_EMAIL_2FA_EXPIRES // Store hash, expiry, pending flag, reset attempts update_user_meta( user->ID, wp_email_2fa_hash, hash ) update_user_meta( user->ID, wp_email_2fa_expires, expires ) update_user_meta( user->ID, wp_email_2fa_pending, 1 ) delete_user_meta( user->ID, wp_email_2fa_attempts ) delete_user_meta( user->ID, wp_email_2fa_locked_until ) // Build email subject = sprintf( [%s] Login code, wp_specialchars_decode( get_bloginfo( name ), ENT_QUOTES ) ) message = sprintf( Hello %s,nn, user->display_name ? user->display_name : user->user_login ) message .= A login to your account was just requested. Use the code below to complete the login. The code expires in . (WP_EMAIL_2FA_EXPIRES / 60) . minutes.nn message .= Your login code: . code . nn message .= If you did not request this, you can ignore this message or contact the site administrator.nn message .= get_bloginfo( url ) . n headers = array( Content-Type: text/plain charset=UTF-8 ) // Send email ok = wp_mail( email, subject, message, headers ) if ( ! ok ) { // On failure, clear the stored meta to avoid locking user out delete_user_meta( user->ID, wp_email_2fa_hash ) delete_user_meta( user->ID, wp_email_2fa_expires ) delete_user_meta( user->ID, wp_email_2fa_pending ) return new WP_Error( mail_failed, __( Could not send 2FA code by email. Check server mail configuration. ) ) } return true } / Verify submitted code step. Returns WP_User on success or WP_Error on failure. / function wp_email_2fa_verify_code_step( user, submitted_code ) { if ( ! user ! user instanceof WP_User ) { return new WP_Error( invalid_user, __( Invalid user for verification. ) ) } // Check for lockout locked_until = (int) get_user_meta( user->ID, wp_email_2fa_locked_until, true ) if ( locked_until time() < locked_until ) { return new WP_Error( 2fa_locked, sprintf( __( Too many failed attempts. Try again after %s. ), date_i18n( get_option( date_format ) . . get_option( time_format ), locked_until ) ) ) } hash = get_user_meta( user->ID, wp_email_2fa_hash, true ) expires = (int) get_user_meta( user->ID, wp_email_2fa_expires, true ) if ( empty( hash ) empty( expires ) time() > expires ) { // Clear stale meta delete_user_meta( user->ID, wp_email_2fa_hash ) delete_user_meta( user->ID, wp_email_2fa_expires ) delete_user_meta( user->ID, wp_email_2fa_pending ) return new WP_Error( 2fa_expired, __( Error: Two-factor code expired. Please log in again to request a new code. ) ) } valid = false if ( function_exists( password_verify ) ) { valid = password_verify( submitted_code, hash ) } else { // Fallback: compare hashed versions (less optimal) valid = wp_check_password( submitted_code, hash, user->ID ) } if ( valid ) { // Successful: clear metadata and allow login delete_user_meta( user->ID, wp_email_2fa_hash ) delete_user_meta( user->ID, wp_email_2fa_expires ) delete_user_meta( user->ID, wp_email_2fa_pending ) delete_user_meta( user->ID, wp_email_2fa_attempts ) delete_user_meta( user->ID, wp_email_2fa_locked_until ) return user } // Invalid code: increment attempts attempts = (int) get_user_meta( user->ID, wp_email_2fa_attempts, true ) attempts update_user_meta( user->ID, wp_email_2fa_attempts, attempts ) if ( attempts >= WP_EMAIL_2FA_MAX_ATTEMPTS ) { lock_until = time() WP_EMAIL_2FA_LOCKOUT_TIME update_user_meta( user->ID, wp_email_2fa_locked_until, lock_until ) return new WP_Error( 2fa_locked, __( Error: Too many failed attempts. Temporary lockout in effect. ) ) } return new WP_Error( 2fa_invalid, __( Error: Invalid two-factor code. Please try again. ) ) } / Show additional input in the login form when a 2FA code is pending for the provided username. / add_action( login_form, wp_email_2fa_add_login_field ) function wp_email_2fa_add_login_field() { // Determine username WPs login field is log for username input username = if ( ! empty( _POST[log] ) ) { username = sanitize_user( wp_unslash( _POST[log] ) ) } elseif ( ! empty( _REQUEST[log] ) ) { username = sanitize_user( wp_unslash( _REQUEST[log] ) ) } if ( empty( username ) ) { return } user = get_user_by( login, username ) if ( ! user ) { return } pending = get_user_meta( user->ID, wp_email_2fa_pending, true ) if ( ! pending ) { return } // Output an extra input field for the OTP code // Use echoing with proper escaping for the wp-login form context echoecho echo
// Optionally, display a short note echo } / Clean up pending 2FA states on user logout to avoid stale flags. / add_action( wp_logout, wp_email_2fa_cleanup_on_logout ) function wp_email_2fa_cleanup_on_logout() { user = wp_get_current_user() if ( user user->ID ) { delete_user_meta( user->ID, wp_email_2fa_pending ) delete_user_meta( user->ID, wp_email_2fa_hash ) delete_user_meta( user->ID, wp_email_2fa_expires ) } } / Optional: Admin helpers (not required) Add code to clear pending states on user profile edits or provide controls as needed. / ?>
Notes about the plugin
- The code uses user meta keys prefixed with wp_email_2fa_ to avoid collisions.
- Hashing uses PHPs password_hash/password_verify when available fallback to WordPress helpers if necessary (less ideal).
- The plugin halts login by returning a WP_Error after sending the OTP so that WordPress redisplays the login form and the extra OTP input appears.
- The login form extra input is only shown when there is a pending flag for the submitted username. This prevents showing a code field for other users.
- All input is sanitized using basic WordPress sanitization and escaping where appropriate. Expand sanitization to fit your localization and needs.
How to test the setup
- Activate the plugin in the WP admin panel (Plugins → Activate).
- Open an incognito/private browser window to make sure you are not logged in.
- Go to /wp-login.php and enter a valid username and password.
- After successful password verification, the plugin will send an email with the one-time code and return you to the login page with a message.
- Enter the code you received into the Two-Factor Code field and submit. You should be logged in on success.
- Try entering a wrong code repeatedly until the lockout triggers to confirm rate limiting.
Troubleshooting
- No OTP email received: Verify wp_mail() works (send a test email). Many hosts require SMTP plugins for reliable delivery.
- OTP expired: By default the code expires after 10 minutes. Increase WP_EMAIL_2FA_EXPIRES if you prefer a longer window.
- OTP doesnt verify: Ensure you pasted the exact numeric code, and check attempts/lockout. Clearing user meta (via code or database) removes pending state.
- Username not prefilled: The login form may or may not retain the username depending on WP core or other plugins type the username again if needed and the form will show the 2FA field once pending is set.
- Compatibility with other authentication plugins: This code expects username/password authentication to be processed by WordPress first if you use a custom auth flow, you may need to adapt the hooks and priority.
Possible improvements and extensions
- Add a remember this device option using a secure persistent cookie to skip 2FA for trusted devices for a configurable period.
- Use transactional email service (SendGrid, Mailgun) for better deliverability and logging.
- Replace numeric OTP with time-based one-time passwords (TOTP) for compatibility with authenticator apps (requires a QR code provisioning flow).
- Allow admins to disable 2FA for specific roles or users or add a per-user opt-in setting.
- Log 2FA events (send, attempts, lockouts) to a custom database table for auditing.
- Provide UI in user profile screen to manage 2FA status and view last OTP request time.
- Use AJAX to request a new OTP (rate-limited) without re-submitting passwords.
Notes on privacy and user experience
- Inform users their account email will be used for login codes and how long codes last.
- Provide clear messaging for when codes are sent, expired, or locked out.
- Consider displaying partial email address (e.g., j@example.com) instead of the full address when prompting to check email.
Alternative: add to theme functions.php
If you prefer not to use a plugin, you can copy the functions (everything inside the plugin file) into your themes functions.php. Be cautious: theme updates can overwrite changes. A plugin is the recommended approach.
Final words
This implementation is intentionally simple and focuses on clarity and safety basics: hashed storage, expiry, attempts limit, and email delivery. It is suitable for many sites that want a straightforward email-based 2FA step without complex external dependencies. For production-critical or compliance-required environments, evaluate and prefer mature 2FA/SSO solutions or consult a security professional.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |