Skip to content

Add diagnostics for failed login nonce verification - #973

Open
georgestephanis wants to merge 3 commits into
masterfrom
add/login-nonce-failure-logging
Open

Add diagnostics for failed login nonce verification#973
georgestephanis wants to merge 3 commits into
masterfrom
add/login-nonce-failure-logging

Conversation

@georgestephanis

Copy link
Copy Markdown
Collaborator

Description

Failed login nonce verification is currently silent. validate_login_form_2fa() redirects to the home page and nothing is recorded anywhere, so when a user reports being bounced back to the login screen there is no way for an administrator to tell what happened.

#534 describes the user-facing half of this — the person logging in gets no explanation. This PR covers the operator-facing half: making the failures visible in the log so they can be diagnosed and, where the volume warrants it, acted on. It does not close #534, which is also asking for an error message on the login form.

A handful of these are entirely routine (a stale browser tab, the back button, two sessions on a shared account racing each other, as in #534). A sustained run of them against a single account is a different signal, and today nothing distinguishes the two.

What changed

verify_login_nonce() now reports every failure, distinguishing three cases:

Reason Meaning
no_nonce_stored No pending login for that user at all.
expired Correct value, past its ten-minute expiration.
mismatch Value did not match the stored hash.

Each failure:

  1. Fires a new two_factor_login_nonce_failed action ($user_id, $reason), for sites that want to route these into an audit log, an IDS, or a rate limiter.

  2. Writes a line to the PHP error log, including the validated remote address:

    Two-Factor: login nonce verification failed for user 5 (reason: mismatch, remote address: 203.0.113.10).
    

Logging is on by default so the failures are visible without any configuration. Sites that would rather not carry the volume, or that handle the action themselves, can opt out with the new two_factor_log_login_nonce_failures filter.

The presented value is never written to the log, only the reason it was rejected.

Notes

  • No behavior change to authentication — this is purely additive diagnostics.
  • REMOTE_ADDR is unreliable behind a proxy or load balancer; the docblock says so, and the action hook is the better place for sites that can resolve provenance properly.
  • No User-Agent in the log line, deliberately. Every field written today is an integer, a fixed-vocabulary string, or an IP that passed FILTER_VALIDATE_IP — nothing attacker-controlled. A raw UA would be the first, and a newline in one lets a caller forge additional lines that look exactly like ours. It is also trivially spoofed, roughly triples the line length, and pairs with the IP as a browser fingerprint. Sites that want it can capture it from the action hook with their own sanitization and retention rules.

Partially addresses #534.

Testing

  • New: test_failed_login_nonce_fires_action (data provider covering all three reasons), test_successful_login_nonce_does_not_fire_action, test_login_nonce_failure_logging_can_be_filtered.
  • Existing nonce tests unchanged; the test class suppresses the error log write so the suite output stays clean.

Failed login nonce verification is currently silent: the request is
redirected to the home page and nothing is recorded anywhere. An
administrator has no way to tell whether a user's report of being
bounced back to the login screen was a stale tab, two sessions racing
each other, or something that deserves a closer look. #534 covers the
user-facing half of that gap; this covers the operator-facing half.

verify_login_nonce() now reports every failure and distinguishes three
cases: no pending login for that user, a correct-but-expired value, and
a value that did not match.

Each failure fires a new `two_factor_login_nonce_failed` action, so
sites can route these into an audit log, an IDS, or a rate limiter, and
writes a line to the PHP error log by default so they are visible with
no configuration. The error log write can be turned off with the
`two_factor_log_login_nonce_failures` filter. The presented value is
never written to the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: georgestephanis <georgestephanis@git.wordpress.org>
Co-authored-by: masteradhoc <masteradhoc@git.wordpress.org>
Co-authored-by: kasparsd <kasparsd@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>
Co-authored-by: mboynes <mboynes@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@georgestephanis

Copy link
Copy Markdown
Collaborator Author

Scope note: this is about the login nonce, not the second-factor code

Worth spelling out, since "nonce" and "code" get used interchangeably in discussion and this PR only touches one of them.

Completing a second factor requires two independent secrets:

Login nonce Second-factor code
Meta key _two_factor_nonce _two_factor_email_token, TOTP secret, backup codes
What it proves This browser cleared the password step for user N The person holds the second factor
Applies to Every provider Its own provider only
Delivered via Hidden wp-auth-nonce field Email, authenticator app, printed list
Lifetime 10 minutes, single use two_factor_token_ttl (15 min default) for email; TOTP is time-window based

Despite the name it is not a wp_create_nonce() value — it is bin2hex( random_bytes( 32 ) ), stored as a hash in user meta with an explicit expiration, and deleted on use. It has to be, because during the interstitial there is no session for a WP nonce to bind to. The revalidation flow, where the user is logged in, uses a real WP nonce (two_factor_revalidate_{$user_id}) instead.

This PR adds logging to the nonce check only. It is provider-agnostic — a TOTP-only user has a login nonce too — but a wrong TOTP code never reaches the code path this PR touches.

The two failure paths

flowchart TD
    A["Request to validate_2fa"] --> B{"verify_login_nonce()"}

    B -->|fails| C["Stored nonce discarded<br/><i>(existing behavior)</i>"]
    C --> D["<b>NEW:</b> record reason<br/>+ fire two_factor_login_nonce_failed"]
    D --> E["Redirect to home_url()<br/>flow ends, no code examined"]

    B -->|passes| F{"provider->validate_authentication()"}

    F -->|fails| G["Increment USER_FAILED_LOGIN_ATTEMPTS_KEY<br/>stamp USER_RATE_LIMIT_KEY"]
    G --> H["Fire wp_login_failed"]
    H --> I["Mint a fresh nonce,<br/>re-render form — user continues"]

    F -->|passes| J["Delete nonce, clear counters,<br/>issue auth cookie"]

    style D fill:#dbf5dc,stroke:#2da44e,color:#1a1a1a
Loading

The two behave quite differently on failure:

Bad nonce Bad code
Attempt counter Incremented
Exponential backoff Applied
wp_login_failed Fired
Error log entry New in this PR — (see below)
Effect on the user's flow Ends; back to the password step Continues; fresh nonce, form re-rendered
Effect on the second factor None — an emailed code stays valid in the inbox, TOTP is unaffected, backup codes unspent Consumed only on success

That asymmetry is why the nonce side was the gap worth closing. A wrong code is already counted, throttled, and broadcast on wp_login_failed, which every security plugin consumes. A wrong nonce produced no record of any kind.

The reverse gap — no error log line for provider failures — is real but separate, and arguably already covered by wp_login_failed. Happy to open a follow-up adding a matching log line in process_provider() if reviewers want the symmetry.

Aside, for anyone adding a click-to-login email link

The stale feature/email-code-link branch puts both the code and wp-auth-nonce in a GET query string in the email body. If that idea gets revived it needs a different shape — and not only because get_login_nonce() no longer exists. Only the hash is stored now, and authentication_page() is rendered inside the form, so the provider never receives the plaintext; handing it one would deliberately widen where that value travels.

Putting the nonce in a URL has three practical problems:

  • Mail gateways prefetch links. Safe Links, Proofpoint, Mimecast and friends GET every URL in an inbound message. That request completes the flow, consumes both the nonce and the code, and issues the cookie into a throwaway scanner context — the real user then clicks a dead link with no explanation.
  • Query strings land in access logs in cleartext, plus any CDN, WAF, or proxy in front. This PR goes out of its way never to write the presented value to a log; a GET link would put it in the webserver log regardless.
  • It persists in browser history, and any external asset on the landing page leaks it via Referer.

The shape that survives all three: an opaque single-use token in the link, landing on an interstitial with a POST-only confirm button, with nothing consumed until the user actually clicks it.

@jeffpaul jeffpaul added this to the 0.17.0 milestone Sep 8, 2026
@jeffpaul
jeffpaul requested a review from kasparsd September 8, 2026 15:49

@masteradhoc masteradhoc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small early feedback about filter docs and a CI failure.

Comment thread class-two-factor-core.php
* @param int $user_id The user ID the nonce was presented for.
* @param string $reason One of 'no_nonce_stored', 'expired', or 'mismatch'.
*/
do_action( 'two_factor_login_nonce_failed', $user_id, $reason );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add this to the readme.txt - every other public hook in the plugin is listed there already.

Comment thread class-two-factor-core.php
* @param int $user_id The user ID the nonce was presented for.
* @param string $reason One of 'no_nonce_stored', 'expired', or 'mismatch'.
*/
if ( ! apply_filters( 'two_factor_log_login_nonce_failures', true, $user_id, $reason ) ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same ask for readme.txt

* @param bool $expire_nonce Whether to backdate the nonce's expiration.
* @param bool $send_valid_key Whether to present the real key or a bogus one.
*/
public function test_failed_login_nonce_fires_action( $expected_reason, $create_nonce, $expire_nonce, $send_valid_key ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently results in a Failure:

1) Test_ClassTwoFactorCore::test_failed_login_nonce_fires_action with data set "past expiration" ('expired', true, true, true)
The failure action fires once with the expected user and reason
Failed asserting that two arrays are identical.
--- Expected
+++ Actual
@@ @@
 Array &0 (
     0 => Array &1 (
         0 => 8
-        1 => 'expired'
+        1 => 'mismatch'
     )
 )

/var/www/html/wp-content/plugins/two-factor/tests/class-two-factor-core.php:762

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add error message for nonce check failures

3 participants