This document explains how the upstream WordPress Two-Factor plugin extends the WordPress core authentication flow.
Verified against the current upstream
WordPress/two-factorsource, current WordPress core source, and official WordPress Developer Documentation on March 31, 2026.
The plugin inserts itself at these key points:
add_action( 'login_form_validate_2fa', array( __CLASS__, 'login_form_validate_2fa' ) );
add_action( 'login_form_revalidate_2fa', array( __CLASS__, 'login_form_revalidate_2fa' ) );
add_filter( 'authenticate', array( __CLASS__, 'filter_authenticate' ), 31 );
add_action( 'wp_login', array( __CLASS__, 'wp_login' ), PHP_INT_MAX, 2 );
add_action( 'set_auth_cookie', array( __CLASS__, 'collect_auth_cookie_tokens' ) );
add_action( 'set_logged_in_cookie', array( __CLASS__, 'collect_auth_cookie_tokens' ) );
add_filter( 'attach_session_information', array( __CLASS__, 'filter_session_information' ), 10, 2 );These registrations are the backbone of the plugin’s browser-login, second-factor, and revalidation flows.
At a high level, browser login looks like this:
wp_signon()
-> core authenticate callbacks
-> [20] wp_authenticate_username_password()
-> [20] wp_authenticate_email_password()
-> [20] wp_authenticate_application_password()
-> [30] wp_authenticate_cookie() [added by wp_signon()]
-> [31] Two_Factor_Core::filter_authenticate()
-> wp_set_auth_cookie()
-> set_auth_cookie / set_logged_in_cookie hooks capture token(s)
-> wp_login
-> Two_Factor_Core::wp_login() at PHP_INT_MAX
-> destroy current password-auth session token(s)
-> wp_clear_auth_cookie()
-> show_two_factor_login()
-> exit
For users who have Two-Factor enabled, Two_Factor_Core::filter_authenticate():
- adds
send_auth_cookies => __return_falseatPHP_INT_MAX - allows the password-authenticated
WP_Userobject to continue for browser login - blocks API login with
WP_Errorwhen the request is specifically an XML-RPC or REST request and the user is not allowed to log in that way
That means the plugin does not replace WordPress password authentication. It suppresses cookie delivery until the second factor succeeds.
By default, API login remains allowed only when application_password_did_authenticate already fired or a site overrides two_factor_user_api_login_enable.
Even while cookie sending is suppressed, core still calls wp_set_auth_cookie(), and Two-Factor captures the generated token(s) via:
set_auth_cookieset_logged_in_cookie
collect_auth_cookie_tokens() parses those cookie values and stores their session tokens in an internal array so they can be destroyed before the second factor completes.
At wp_login, the plugin checks whether the user is actually using Two-Factor.
If yes, it:
- destroys the just-created password-auth session token(s)
- calls
wp_clear_auth_cookie()defensively - calls
show_two_factor_login( $user ) - exits
The important guarantee here is server-side token destruction. In this specific flow, send_auth_cookies is still filtered to false, so wp_clear_auth_cookie() is not the step that actually clears already-sent browser cookies; the password-authenticated session never becomes usable because its session token is destroyed before the second-factor challenge is completed.
show_two_factor_login():
- creates a login nonce via
create_login_nonce( $user_id ) - stores the hashed nonce in user meta under
_two_factor_nonce - sets a 10-minute expiration on that nonce
- calls
login_html()to render the challenge form
login_html() renders a form posting to wp-login.php?action=validate_2fa with fields including:
providerwp-auth-idwp-auth-nonceredirect_torememberme
The selected provider renders its own UI through:
$provider->authentication_page( $user );If multiple providers are available, login_html() also renders backup-provider links using get_alternative_provider_label().
login_form_validate_2fa() collects request state and delegates into _login_form_validate_2fa().
That flow is:
verify_login_nonce()
-> get_provider_for_user()
-> process_provider()
-> provider->pre_process_authentication()
-> if GET: no validation yet
-> rate limit check
-> provider->validate_authentication()
-> on failure: update counters, maybe reset password, return WP_Error
-> on success:
-> delete_login_nonce()
-> clear rate-limit meta
-> attach two-factor session metadata
-> remove send_auth_cookies suppression
-> wp_set_auth_cookie()
-> do_action( 'two_factor_user_authenticated', ... )
-> apply_filters( 'login_redirect', ... )
-> redirect
- Nonce verification uses a dedicated login nonce, not a standard
wp_nonce. verify_login_nonce()returnsfalseand deletes the stored nonce when verification fails; the actual redirect tohome_url()happens in_login_form_validate_2fa(), not inverify_login_nonce()itself.get_provider_for_user()can prefer the currently active two-factor provider already recorded in the current session.process_provider()runspre_process_authentication()before POST validation. This is how providers like Email can handle resend actions.- The current built-in upstream providers are Email, TOTP, Backup Codes, and Dummy. WebAuthn/passkey support is typically provided by third-party extensions, not by upstream core Two-Factor itself.
The plugin uses two user-meta keys together:
_two_factor_last_login_failure_two_factor_failed_login_attempts
Behavior:
_two_factor_last_login_failurestores the timestamp of the most recent failure, not the computed backoff duration.- backoff starts from the filtered base rate (
two_factor_rate_limit, default 1 second) - delay becomes
2^n * base, capped bytwo_factor_max_rate_limit(default 15 minutes) - after the failed-attempt limit (
two_factor_failed_attempt_limit, default 30), the plugin can reset the password and send notifications when the user is not already logged in
This is a separate path used when a logged-in user needs a fresh second-factor confirmation to change Two-Factor settings.
current_user_can_update_two_factor_options():
- returns
trueimmediately for users not using Two-Factor - requires the current session to already be a Two-Factor-authenticated session
- uses
two_factor_revalidate_timewith these defaults:- 10 minutes for display
- 20 minutes for save (
display * 2)
That method only returns a boolean; it does not redirect by itself. The calling request handlers decide whether to render, block, or redirect.
login_form_revalidate_2fa() delegates into _login_form_revalidate_2fa().
Key differences from login validation:
- GET requests can render the form without a nonce check
- POST requests use a standard WordPress nonce:
two_factor_revalidate_{$user_id} - on success, the plugin updates the current session in place via
update_current_user_session() - it then fires
two_factor_user_revalidated - it applies
login_redirectbefore redirecting - it does not mint a brand-new login cookie the way the initial 2FA login flow does
The plugin uses attach_session_information in two different ways:
-
Temporary success-path closure during initial 2FA completion
_login_form_validate_2fa()adds a one-off closure that stamps the newly created session with:two-factor-logintwo-factor-provider
- it removes that closure immediately after
wp_set_auth_cookie()returns
-
Always-on continuity filter
filter_session_information()copies any existing current-session keys beginning withtwo-factor-into replacement sessions
In practice, that preserves values like:
two-factor-logintwo-factor-provider
This matters when core or other code paths create a replacement session while a user is already authenticated.
The abstract provider base class defines these required provider responsibilities:
| Method | Required? | Purpose |
|---|---|---|
get_label() |
Yes | Human-readable provider name |
authentication_page( $user ) |
Yes | Render the challenge UI |
validate_authentication( $user ) |
Yes | Validate the second factor |
is_available_for_user( $user ) |
Yes | Whether the provider is configured/usable |
pre_process_authentication( $user ) |
No | Optional pre-processing, e.g. resend code |
get_key() |
Provided by base class | Provider key (defaults to class name) |
get_alternative_provider_label() |
Provided by base class | “Use X” label for backup-provider links |
| Mechanism | Purpose |
|---|---|
send_auth_cookies => __return_false |
Prevents browser cookies from being sent before second-factor success |
collect_auth_cookie_tokens() + destroy_current_session_for_user() |
Removes the pre-2FA session created by core |
wp_clear_auth_cookie() |
Defensive cleanup call before challenge rendering |
| Login nonce stored hashed in user meta | Binds the 2FA challenge to a user and time window |
| User-meta rate limiting | Slows repeated invalid second-factor attempts |
| Password reset threshold | Stops effectively unbounded brute force on weaker second factors |
| Revalidation grace period | Limits stale-session settings changes |
filter_session_information() |
Preserves Two-Factor metadata on replacement sessions |
WP Sudo does not reuse the upstream Two-Factor login flow. Instead, it borrows the same general model:
- password verification first
- second factor before granting a higher-trust session
- provider-rendered fields plus provider-owned validation
- explicit replay or continuation only after the second factor succeeds
That is why this reference belongs alongside:
Official WordPress Developer Documentation:
- wp_signon()
- wp_authenticate_cookie()
- wp_set_auth_cookie()
- wp_clear_auth_cookie()
- wp_validate_application_password()
- Application Passwords
Upstream Two-Factor permalinks checked on 2026-03-31:
- Hook registration (
add_hooks()) - Browser handoff (
wp_login()) and authenticate filter - Login nonce creation and verification
- Rate limiting and revalidation policy
- Initial validation completion flow
- Revalidation completion flow
- Provider processing and reset logic
- Session continuity filter
- Provider base contract
WordPress core permalinks checked on 2026-03-31: