Skip to content

WP Login Flow

The traditional WordPress login (wp-login.php) is replaced with a two-phase OTP flow. Users never enter a password — they enter their email (or username), receive a code, and submit that code to complete login.


Overview

Phase 1                         Phase 2
──────────────────────          ──────────────────────────────
User visits /wp-login.php       User revisits with ?otp_pending=1
Enters username / email         Enters the code from email
Clicks "Log In"                 Clicks "Verify code"
       │                                  │
       ▼                                  ▼
authenticate filter (prio 15)    login_init action
  → verify nonce                   → detect otp_code in POST
  → generate OTP                   → verify nonce
  → send email                     → read token from cookie
  → set HttpOnly cookie             → validate token + code
  → redirect to ?otp_pending=1     → clear cookie
                                   → set auth cookie
                                   → redirect to wp-admin

Phase 1 — Email submission

Hook: authenticate at priority 15 (before WordPress's own password check at priority 20).

Handler: WpLoginProvider::handle_step_one()

What happens

  1. The filter runs only when did_action('login_init') is truthy, ensuring it only fires on wp-login.php and not during REST, XML-RPC, or other authenticate callers.
  2. If $username is empty (direct GET visit), bail out — nothing to do.
  3. Verify the wp_otp_phase1 WordPress nonce injected by render_otp_field(). If the nonce is missing or invalid, bail out — prevents CSRF triggering OTP emails.
  4. Look up the user by login name, then by email address.
  5. If found: generate an OTP (OtpStore::generate()), send the email (OtpMailer::send()), store the opaque token in an HttpOnly; SameSite=Strict cookie.
  6. If the user is not found (or generation/mail fails): proceed without setting a cookie — no error exposed to the caller.
  7. Redirect to wp-login.php?otp_pending=1 in all cases.

Both code paths produce an identical URL. This is intentional — see security.md.

The opaque token is a 64-character cryptographically random hex string. Placing it in the URL would expose it in server access logs, browser history, and Referer headers. An HttpOnly; SameSite=Strict cookie scoped to /wp-login.php is never readable by JavaScript, is never sent cross-site, and does not appear in logs.


Phase 2 — Code submission

Hook: login_init action (fires early in wp-login.php, before wp_signon() is called).

Handler: WpLoginProvider::process_otp_submission()

What happens

  1. Only runs on POST requests.
  2. Returns immediately if $_POST['otp_code'] is absent — this is a Phase 1 POST, not Phase 2.
  3. Verifies the wp_otp_phase2 WordPress nonce. Redirects to the login page if invalid.
  4. Reads the token from the wp_otp_token HttpOnly cookie.
  5. Reads the submitted otp_code from $_POST.
  6. If either is empty, bail out.
  7. Calls OtpStore::validate($token, $code):
  8. Success → returns the user_id, both transients are deleted.
  9. Failure (bad code, expired, or max attempts exceeded) → throws OtpException.
  10. On success: clears the cookie, calls wp_set_auth_cookie(), fires wp_login action, redirects to $_GET['redirect_to'] (falls back to admin_url()).
  11. On failure: redirects back to ?otp_pending=1&otp_error=1. The cookie persists so the user can retry until max attempts is reached.

Login form UI

login_form — nonce and OTP input injection

Handler: WpLoginProvider::render_otp_field()

Phase 1 (no otp_pending in URL): outputs only a hidden nonce field for the Phase 1 form submission. No OTP input is shown yet.

Phase 2 (?otp_pending=1 in URL): outputs the OTP code input and a Phase 2 nonce field. No hidden token field is rendered — the token lives in the cookie.

<!-- Phase 2 form additions -->
<p>
  <label for="otp_code">Login code</label>
  <input
    type="text"
    name="otp_code"
    id="otp_code"
    class="input"
    inputmode="numeric"        <!-- or "text" for alpha/alphanumeric -->
    autocomplete="one-time-code"
    maxlength="6"              <!-- matches OtpSettings::$code_length -->
    autofocus
    required
  >
</p>
<input type="hidden" name="wp_otp_nonce" value="<nonce>">
  • inputmode="numeric" is set when the charset is numeric, which triggers the numeric keyboard on mobile.
  • autocomplete="one-time-code" enables iOS/Android SMS autofill and browser OTP autocomplete.
  • maxlength mirrors OtpSettings::$code_length.

login_head — CSS/JS adaptations

Handler: WpLoginProvider::output_login_styles()

Always hidden (all phases): - .user-pass-wrap — password field wrapper - p.forgetmenot — "Remember me" checkbox - #nav — "Lost your password?" link

Phase 2 only — additional hidden elements: - .user-login-wrap — username field wrapper - #loginform > p:first-of-type — legacy username paragraph (fallback selector)

Phase 2 JS (runs on DOMContentLoaded):

// Change the submit button label
document.getElementById('wp-submit').value = 'Verify code';

// The hidden username + password inputs carry required="required".
// Browsers refuse to submit a form when a required field isn't visible,
// so we strip the attribute and disable the fields.
['user_login', 'user_pass'].forEach(function(id) {
    var el = document.getElementById(id);
    if (el) {
        el.removeAttribute('required');
        el.disabled = true;
    }
});

Without this JS, Chrome/Firefox block form submission with the error:

An invalid form control with name='log' is not focusable.

login_errors — generic error message

Handler: WpLoginProvider::filter_login_errors()

When ?otp_error=1 is present in the URL, replaces whatever WordPress would normally display with:

ERROR: Invalid or expired login code. Please try again.

The message is intentionally vague — it does not say whether the token or the code was wrong.


Full request sequence

GET  /wp-login.php
     ← Login form shown (password hidden, username visible)
        Phase 1 nonce injected as hidden field

POST /wp-login.php
     body: log=user@example.com&wp-submit=Log+In&wp_otp_nonce=<phase1_nonce>
     → login_init fires → no otp_code in POST → returns
     → authenticate filter prio 15 fires
       → nonce verified
       → user found, OTP generated, email sent
       → Set-Cookie: wp_otp_token=<token>; HttpOnly; SameSite=Strict; Path=/wp-login.php
       → wp_safe_redirect( /wp-login.php?otp_pending=1 )

GET  /wp-login.php?otp_pending=1
     ← OTP form shown (username+password hidden, code input visible)
        Phase 2 nonce injected as hidden field
        Token travels via cookie — not visible in URL or page source

POST /wp-login.php?otp_pending=1
     body: otp_code=483920&wp-submit=Verify+code&wp_otp_nonce=<phase2_nonce>
     Cookie: wp_otp_token=<token>
     → login_init fires
       → otp_code present → nonce verified
       → token read from cookie
       → OtpStore::validate('<token>', '483920') → user_id 7
       → Set-Cookie: wp_otp_token=; expires=<past>  (cookie cleared)
       → wp_set_auth_cookie(7)
       → wp_safe_redirect( /wp-admin/ )

GET  /wp-admin/
     ← User is logged in

Error paths

Scenario Redirect
Unknown email entered ?otp_pending=1 (identical UI, no OTP sent, no cookie)
Wrong code submitted ?otp_pending=1&otp_error=1 (cookie preserved, retry allowed)
Expired token ?otp_pending=1&otp_error=1 (cookie expired too — next submit fails gracefully)
Max attempts exceeded ?otp_pending=1&otp_error=1 (transients deleted — new code required)
User deleted between phases Redirect to wp_login_url()
Invalid Phase 1 nonce (CSRF) authenticate filter returns $user unchanged — WP handles normally
Invalid Phase 2 nonce Redirect to wp_login_url()