WP Sudo has built-in support for the Two Factor plugin, but its 2FA architecture is designed to work with any 2FA plugin through four hooks. This document explains how the integration works and how to connect an alternative 2FA provider.
- Architecture Overview
- The Two-Step Challenge Flow
- How Built-In Two Factor Support Works
- Related Deep References
- Compatibility Hazards
- Hooks for Third-Party 2FA Plugins
- Integration Guide: Connecting Your Own 2FA Plugin
- Security Model
- Reference: Files Involved
WP Sudo's challenge page is a two-step reauthentication flow:
- Password step -- the user enters their WordPress password.
- 2FA step -- if the user has two-factor authentication configured, a second form appears for their authentication code (TOTP, email code, backup code, WebAuthn, etc.).
The sudo session is only activated after both steps succeed. A correct password alone does not grant a session when 2FA is enabled.
The 2FA step is entirely optional. If no 2FA plugin is active or the user has not configured 2FA, the session activates immediately after a successful password.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Gate intercepts action β
β (plugin activation, user deletion, etc.) β
ββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Challenge Page (Step 1) β
β Password Prompt β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β User enters WordPress password β β
β β β AJAX POST to wp_sudo_challenge_auth β β
β ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Password correct? β
βββββββββ¬ββββββββ¬ββββββββ
NO β β YES
βΌ β βΌ
(error) β ββββββββββββββββββββ
β β needs_two_factor?β
β ββββββββ¬ββββββ¬ββββββ
β NO β β YES
β βΌ β βΌ
β activateβ ββββββββββββββββββββββββββββββββββββββββ
β session β β Return '2fa_pending' β
β β β Set challenge cookie (httponly) β
β β β Store pending transient β
β β ββββββββββββββββββββ¬ββββββββββββββββββββ
β β β
β β βΌ
β β ββββββββββββββββββββββββββββββββββββββββ
β β β Challenge Page (Step 2) β
β β β 2FA Authentication β
β β β β
β β β ββββββββββββββββββββββββββββββββ β
β β β β User enters 2FA code β β
β β β β β AJAX POST to β β
β β β β wp_sudo_challenge_2fa β β
β β β ββββββββββββββββ¬ββββββββββββββββ β
β β βββββββββββββββββββΌβββββββββββββββββββββ
β β β
β β βΌ
β β βββββββββββββββββββββββββ
β β β 2FA code valid? β
β β βββββββββ¬ββββββββ¬ββββββββ
β β NO β β YES
β β βΌ β βΌ
β β (error) β clear pending state
β β β activate session
β β β replay stashed request
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Sudo session active β
β Original action replayed β
βββββββββββββββββββββββββββββββββββββββββββ
When the user submits their password, the JavaScript sends an AJAX request to wp_sudo_challenge_auth. The server calls Sudo_Session::attempt_activation(), which:
- Checks for lockout (5 failed attempts = 5-minute lockout).
- Validates the password with
wp_check_password(). - Calls
Sudo_Session::needs_two_factor( $user_id ).
If 2FA is not required, the session activates immediately and the original request is replayed.
If 2FA is required, the server:
- Generates a random 32-character challenge nonce.
- Hashes it with SHA-256.
- Stores a transient (
wp_sudo_2fa_pending_{hash}) containing the user ID and expiry. - Sets the nonce in an httponly, SameSite=Strict cookie (
wp_sudo_challenge). - Returns
{ code: '2fa_pending', expires_at: <timestamp> }.
The JavaScript hides the password form and reveals the 2FA form, starting a visible countdown timer.
When the user submits the 2FA form, the JavaScript:
- Collects the form data (including any fields the 2FA provider rendered).
- Strips any
actionand_wpnoncefields the provider may have injected. - Appends WP Sudo's own AJAX action (
wp_sudo_challenge_2fa) and nonce. - Sends the request.
The server (Challenge::handle_ajax_2fa()) then:
- Verifies the WordPress nonce.
- Reads the challenge cookie and looks up the matching transient.
- Validates the transient belongs to the current user and has not expired.
- Calls the 2FA provider's validation method.
- Applies the
wp_sudo_validate_two_factorfilter. - On success: clears the pending state, activates the session, replays the stash.
- WordPress Core Authentication Logic -- current core sign-on, cookie, and session validation flow.
- Two-Factor Plugin Authentication Flow -- current upstream Two-Factor plugin hook order and challenge lifecycle.
WP Sudo has zero-configuration support for the Two Factor plugin. The integration uses three methods from Two_Factor_Core:
| Method | Where Called | Purpose |
|---|---|---|
Two_Factor_Core::is_user_using_two_factor( $user_id ) |
Sudo_Session::needs_two_factor() |
Detect if the user has a 2FA provider configured |
Two_Factor_Core::get_primary_provider_for_user( $user ) |
Challenge::render_page() and Challenge::handle_ajax_2fa() |
Get the user's primary provider object |
$provider->authentication_page( $user ) |
Challenge::render_page() |
Render the provider's form fields (TOTP input, email code input, etc.) |
$provider->pre_process_authentication( $user ) |
Challenge::handle_ajax_2fa() |
Pre-process (e.g., resend email code) |
$provider->validate_authentication( $user ) |
Challenge::handle_ajax_2fa() |
Validate the submitted code |
All detection uses class_exists( '\\Two_Factor_Core' ) -- there is no filename-based check. This works regardless of how the Two Factor plugin is installed (standard plugin, mu-plugin, Composer).
The integration is provider-agnostic: WP Sudo does not know or care whether the user is using TOTP, email, backup codes, or WebAuthn. It delegates all rendering and validation to the provider's own API.
The built-in Two Factor support works well, but there are a few integration assumptions that matter for both upstream Two Factor providers and third-party bridges:
-
Provider fields render inside WP Sudo's form, not a provider-owned form. Provider HTML is injected into
#wp-sudo-challenge-2fa-formand submitted through WP Sudo's AJAX handler. Providers must not assume they control the outer<form>element or the request target. -
Provider
actionand_wpnoncefields are not authoritative. Some providers render hiddenactionor_wpnonceinputs for their normal login flow. WP Sudo strips those fields before submitting the 2FA AJAX request and replaces them with its own handler and nonce. If a provider depends on those hidden values surviving unchanged, it is not compatible with the challenge flow as-is. -
Resend and pre-processing flows must stay on the same 2FA step. When a provider uses
pre_process_authentication()for actions like resending an email code, WP Sudo keeps the browser on the active 2FA screen and expects the provider to continue validating a later submission in the same challenge session. -
Challenge tabs can become stale while the browser already has an active sudo session. A provider or bridge should not assume that loading the challenge page always means "user is unauthenticated." Another tab may already have completed the sudo flow, including 2FA, and WP Sudo may short-circuit back to the requested admin page instead of prompting again.
-
Throttle and lockout UX is owned by WP Sudo. Providers are responsible for rendering fields and validating codes. Countdown messaging, disabled submit states, and retry timing are handled by WP Sudo's challenge UI once the provider has returned a resend, invalid, throttled, or locked-out outcome.
These assumptions are enforced by the browser test suite, including provider-hidden-field, resend, stale-session, throttle, and lockout challenge cases.
Four hooks allow any 2FA plugin to integrate with WP Sudo's challenge flow:
When: During password verification, after wp_check_password() succeeds.
Signature:
apply_filters( 'wp_sudo_requires_two_factor', bool $needs, int $user_id ): boolPurpose: Tell WP Sudo that this user has 2FA configured and should see the second step.
Parameters:
$needs--trueif the Two Factor plugin already detected 2FA;falseotherwise.$user_id-- The user being authenticated.
Return: true to require 2FA, false to skip it.
When: While rendering the challenge page HTML, inside the #wp-sudo-challenge-2fa-form element.
Signature:
do_action( 'wp_sudo_render_two_factor_fields', WP_User $user )Purpose: Output the HTML form fields for your 2FA method (input fields, hidden fields, scripts).
Parameters:
$user-- TheWP_Userobject for the user being authenticated.
Notes:
- Fires after the built-in Two Factor provider rendering (if present).
- Your fields will be collected as
FormDataand submitted via AJAX. - Do not render your own submit button -- WP Sudo provides "Verify & Continue".
- Do not add
actionor_wpnoncehidden fields -- the JavaScript strips and replaces them.
When: During AJAX 2FA authentication, after the built-in Two Factor validation runs.
Signature:
apply_filters( 'wp_sudo_validate_two_factor', bool $valid, WP_User $user ): boolPurpose: Validate the submitted 2FA code against your plugin's logic.
Parameters:
$valid--trueif the Two Factor plugin already validated it;falseotherwise.$user-- TheWP_Userobject.
Return: true if the 2FA code is valid, false otherwise.
Notes:
- Your submitted form fields are available in
$_POST. - If the Two Factor plugin is not installed,
$validwill always arrive asfalse.
When: When creating the 2FA pending state, after a successful password.
Signature:
apply_filters( 'wp_sudo_two_factor_window', int $window ): intPurpose: Control how long (in seconds) the user has to complete the 2FA step.
Default: 300 seconds (5 minutes).
Here is a minimal, complete integration for a hypothetical 2FA plugin:
<?php
/**
* Bridge between My2FA Plugin and WP Sudo.
*
* Drop this in a separate file (e.g., mu-plugins/my2fa-wp-sudo-bridge.php)
* or add it to your 2FA plugin's initialization.
*/
// 1. Tell WP Sudo this user needs 2FA.
add_filter( 'wp_sudo_requires_two_factor', function ( bool $needs, int $user_id ): bool {
if ( my2fa_user_has_2fa( $user_id ) ) {
return true;
}
return $needs;
}, 10, 2 );
// 2. Render the 2FA input field on the challenge page.
add_action( 'wp_sudo_render_two_factor_fields', function ( WP_User $user ): void {
// Do NOT render a submit button or action/nonce fields.
?>
<p>
<label for="my2fa-code">
<?php esc_html_e( 'Enter your authentication code:', 'my2fa' ); ?>
</label>
<input type="text"
id="my2fa-code"
name="my2fa_code"
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]*"
required />
</p>
<?php
} );
// 3. Validate the submitted code.
add_filter( 'wp_sudo_validate_two_factor', function ( bool $valid, WP_User $user ): bool {
// If another plugin already validated, don't override.
if ( $valid ) {
return true;
}
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- WP Sudo handles nonce.
$code = isset( $_POST['my2fa_code'] )
? sanitize_text_field( wp_unslash( $_POST['my2fa_code'] ) )
: '';
return my2fa_verify_code( $user->ID, $code );
}, 10, 2 );| Step | Hook | What to do |
|---|---|---|
| Detect | wp_sudo_requires_two_factor |
Return true when the user has your 2FA method configured |
| Render | wp_sudo_render_two_factor_fields |
Output HTML form fields (no submit button, no action/_wpnonce fields) |
| Validate | wp_sudo_validate_two_factor |
Read your fields from $_POST and verify the code |
| (Optional) | wp_sudo_two_factor_window |
Adjust the authentication window if your method needs more time |
- Do not render a submit button. WP Sudo provides "Verify & Continue".
- Do not add
actionor_wpnoncehidden fields. The JavaScript strips them and adds WP Sudo's own values. If you add them, they will be silently removed. - Do not add your own form element. Your fields render inside WP Sudo's
<form>. - Do not handle nonce verification. WP Sudo calls
check_ajax_referer()before your filter runs. - Respect the
$validparameter. If it arrives astrue, another plugin already validated. Returntrueto let it pass. Only set it tofalseif you have a positive reason to reject.
If your 2FA method uses WebAuthn (browser-based passkey ceremonies), you'll need to:
- Enqueue your JavaScript on the challenge page. Hook
admin_enqueue_scriptsand check for thewp-sudo-challengepage. - Render a hidden input in
wp_sudo_render_two_factor_fieldsthat your JS populates with the attestation/assertion response. - Validate the response server-side in
wp_sudo_validate_two_factor.
The Two Factor plugin's WebAuthn provider already works this way through authentication_page(), so the pattern is proven.
By default, WebAuthn security key registration and deletion are not gated by WP Sudo. An attacker with a hijacked session could silently register their own security key β the same risk class as ungated Application Password creation.
To gate these operations, install the WebAuthn bridge as a mu-plugin:
cp bridges/wp-sudo-webauthn-bridge.php wp-content/mu-plugins/The bridge uses the wp_sudo_gated_actions filter to add AJAX rules for the WebAuthn Provider's webauthn_preregister, webauthn_register, and webauthn_delete_key endpoints. Key renaming (webauthn_rename_key) is intentionally not gated β it is not a security-sensitive operation.
See bridges/wp-sudo-webauthn-bridge.php for the complete implementation, or the Developer Reference for a general guide to adding custom gated actions.
The two-step flow has several security properties:
The challenge cookie (wp_sudo_challenge) is a random 32-character nonce set as httponly with SameSite=Strict. The pending transient is keyed by its SHA-256 hash. An attacker who steals the WordPress session cookie on a different machine cannot complete the 2FA step because they don't have this cookie.
The pending state expires after 5 minutes (configurable via wp_sudo_two_factor_window). Both the transient TTL and the cookie expiry enforce this. The JavaScript shows a countdown timer and an advisory warning when time runs out; the server remains the authoritative source for expiry.
The transient stores the user_id. Even if the challenge cookie were somehow obtained, get_2fa_pending() validates that the transient's user ID matches the current session's user.
After successful 2FA, clear_2fa_pending() deletes both the transient and the cookie, preventing replay.
Sudo_Session::activate() is only called after both password and 2FA succeed. A correct password alone creates no session state -- only a pending 2FA transient.
| File | 2FA Role |
|---|---|
includes/class-sudo-session.php |
Detection (needs_two_factor), pending state (get_2fa_pending, clear_2fa_pending), challenge cookie, window filter |
includes/class-challenge.php |
Rendering the 2FA form, AJAX handlers for password and 2FA steps, provider delegation |
admin/js/wp-sudo-challenge.js |
Client-side step transition, form field handling, countdown timer, code-resent handling |
admin/css/wp-sudo-challenge.css |
Timer styling, hides Two Factor plugin's own submit button |
includes/class-admin.php |
Help tab documentation of 2FA hooks |
- Two-Factor Plugin Ecosystem Guide β Comprehensive survey of how major WordPress 2FA plugins work internally, with bridge patterns for Wordfence, WP 2FA, and AIOS.
bridges/wp-sudo-wp2fa-bridge.phpβ A complete, working bridge for WP 2FA by Melapress, ready to drop intomu-plugins/.