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¶
- The filter runs only when
did_action('login_init')is truthy, ensuring it only fires onwp-login.phpand not during REST, XML-RPC, or otherauthenticatecallers. - If
$usernameis empty (direct GET visit), bail out — nothing to do. - Verify the
wp_otp_phase1WordPress nonce injected byrender_otp_field(). If the nonce is missing or invalid, bail out — prevents CSRF triggering OTP emails. - Look up the user by login name, then by email address.
- If found: generate an OTP (
OtpStore::generate()), send the email (OtpMailer::send()), store the opaque token in anHttpOnly; SameSite=Strictcookie. - If the user is not found (or generation/mail fails): proceed without setting a cookie — no error exposed to the caller.
- Redirect to
wp-login.php?otp_pending=1in all cases.
Both code paths produce an identical URL. This is intentional — see security.md.
Why the token is in a cookie, not the URL¶
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¶
- Only runs on
POSTrequests. - Returns immediately if
$_POST['otp_code']is absent — this is a Phase 1 POST, not Phase 2. - Verifies the
wp_otp_phase2WordPress nonce. Redirects to the login page if invalid. - Reads the token from the
wp_otp_tokenHttpOnly cookie. - Reads the submitted
otp_codefrom$_POST. - If either is empty, bail out.
- Calls
OtpStore::validate($token, $code): - Success → returns the
user_id, both transients are deleted. - Failure (bad code, expired, or max attempts exceeded) → throws
OtpException. - On success: clears the cookie, calls
wp_set_auth_cookie(), fireswp_loginaction, redirects to$_GET['redirect_to'](falls back toadmin_url()). - 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 isnumeric, which triggers the numeric keyboard on mobile.autocomplete="one-time-code"enables iOS/Android SMS autofill and browser OTP autocomplete.maxlengthmirrorsOtpSettings::$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() |