Skip to content

Security

A summary of the security decisions baked into WP OTP and the reasoning behind each.


User enumeration prevention

Risk: An attacker submits email addresses and uses differing responses to discover which are registered.

Mitigations:

  1. requestOtp (GraphQL) always returns { success: true } regardless of whether the email matches a real user. A null token is indistinguishable from a valid-but-useless one on the client side.

  2. WpLoginProvider (WP login) always redirects to ?otp_pending=1 regardless of whether the email was found. The token (when one exists) is stored in an HttpOnly cookie — it never appears in the URL. Both code paths produce an identical URL and an identical-looking page.

  3. The GraphQL handler adds a random usleep(50–150ms) delay for non-existent users to equalise timing with the real code path (which generates an OTP, hashes it, and writes two transients):

    usleep( random_int( 50000, 150000 ) ); // timing stub
    

  4. Error messages in all flows are generic: "Invalid or expired login code." They reveal nothing about which part of the validation failed.


Timing-safe code comparison

Risk: An attacker iterates over possible OTP codes and uses response-time differences (timing oracle) to narrow down the correct value.

Mitigation: OtpStore::validate() compares SHA-256 hashes using hash_equals() — a constant-time comparison function that takes the same amount of time regardless of how many characters match:

if ( ! hash_equals( $data['code_hash'] ?? '', hash( 'sha256', $code ) ) ) {
    throw new OtpException( 'Invalid or expired login link.' );
}

The code itself is never stored or compared in plain text — only its SHA-256 hash is persisted.


One-time use

Risk: A code that has already been used to log in could be replayed (e.g. intercepted in transit or visible in email history).

Mitigation: Both transients are deleted atomically on the first successful validation:

delete_transient( $transient_key );                          // wp_otp_{token}
delete_transient( self::USER_TRANSIENT_PREFIX . $user_id );  // wp_otp_user_{uid}

After login succeeds, the token is gone. Attempting to reuse it results in an immediate "invalid or expired" error.


Short-lived tokens

Risk: A code intercepted or guessed could be used long after it was issued.

Mitigation: Both transients share the same TTL, derived from OtpSettings::$duration_minutes (default 10, configurable 1–60). The transient expires automatically via WordPress Transients / Redis, after which get_transient() returns false and validation fails.


Risk: Placing session tokens in URLs exposes them in server access logs, browser history, and Referer headers sent to third-party resources.

Mitigation: After generating an OTP, WpLoginProvider stores the opaque 64-character hex token in a Set-Cookie header rather than a query parameter. The cookie is configured with:

HttpOnly   — JavaScript cannot read it; immune to XSS exfiltration
SameSite=Strict — not sent on any cross-site request; blocks CSRF
Secure     — sent only over HTTPS (when is_ssl() returns true)
Path       — scoped to /wp-login.php only, not the entire site
expires    — matches the OTP TTL so the browser clears it automatically

The URL after Phase 1 is always ?otp_pending=1 — no token, no user identifier, nothing sensitive.


Cryptographically secure randomness

Risk: A predictable PRNG allows an attacker to pre-compute possible tokens or codes.

Mitigation: Both the token and the code are generated using random_bytes() and random_int() respectively — PHP's CSPRNG (backed by /dev/urandom or CryptGenRandom on Windows). Any entropy failure throws OtpException immediately:

// Token
$token = bin2hex( random_bytes( 32 ) );

// Code
$code .= $chars[ random_int( 0, $max ) ];

Token uniqueness

Risk: A token collision (two users getting the same token) could redirect one user's authentication to another's session.

Mitigation: OtpStore::generate_unique_token() checks for an existing transient before accepting a token. It retries up to 10 times and throws OtpException if it cannot find an unused one (practically impossible with 32-byte tokens, but guarded defensively):

do {
    $token  = bin2hex( random_bytes( 32 ) );
    $exists = get_transient( self::TRANSIENT_PREFIX . $token );
} while ( false !== $exists );

Mail delivery failure

Risk: A token is generated and stored but the email never reaches the user. The token is now orphaned and could theoretically be brute-forced before it expires.

Mitigation: When OtpMailer::send() returns false, the caller immediately calls OtpStore::revoke() to delete both transients. A code that was never delivered cannot be used:

if ( ! $sent ) {
    $this->store->revoke( $generated['token'] );
    // ...
}

Failed attempt lockout

Risk: An attacker submits many guesses for the OTP code within the token's TTL, eventually hitting the correct one.

Mitigation: OtpStore tracks failed validation attempts inside the transient data. When the attempt counter reaches max_attempts (default 5, configurable 1–10), both transients are deleted immediately — the token is dead. The user must request a completely new code.

Attempt 1 — wrong code → attempts = 1, transient updated
Attempt 2 — wrong code → attempts = 2, transient updated
Attempt 3 — wrong code → attempts = 3, transient updated
Attempt 4 — wrong code → attempts = 4, transient updated
Attempt 5 — wrong code → attempts = 5 ≥ max_attempts → both transients deleted
Attempt 6 — any code  → transient gone → "Invalid or expired"

max_attempts is snapshotted into the transient at generation time. Changing the setting in WP Admin does not affect tokens that are already in flight.

The error message on lockout is identical to a wrong-code or expired-token error — the attacker cannot distinguish lockout from a normal failure.


CSRF protection

Risk: A malicious page tricks a logged-out user's browser into submitting the login form, triggering OTP emails or (with a stolen token) completing authentication.

Mitigations:

  1. WordPress noncesrender_otp_field() injects a nonce into every login form. handle_step_one() verifies wp_otp_phase1 before doing any work; process_otp_submission() verifies wp_otp_phase2 before reading POST data. A cross-site form submission without a valid nonce is rejected immediately.

  2. SameSite=Strict cookie — the OTP token cookie is never sent on cross-site requests, so even if a CSRF attacker somehow bypassed the nonce they still could not supply the token.


Rate limiting

Risk: An attacker floods requestOtp or the Phase 1 WP login form for a target email, generating a stream of OTP emails (harassment) or keeping the user's valid code constantly invalidated.

Mitigation: OtpStore::generate() enforces a fixed-window rate limit per user before doing any work:

  • Limit: 10 requests per 10-minute window
  • Window: fixed from the first request — subsequent requests within the window do not slide it forward, preventing the limit from being gamed
  • Storage: wp_otp_rate_{user_id} transient stores { count, expires_at }
  • Ordering: the rate limit check runs before the revocation of any existing token, so a rejected request cannot also knock out the user's current valid code

OTP revocation on re-request

Risk: A user requests a new OTP (e.g. "I didn't receive it"), but the old code remains valid. An attacker who intercepted the first email could still use it.

Mitigation: At the start of OtpStore::generate(), after the rate-limit check, any existing wp_otp_user_{uid} transient is looked up and revoke() is called on it before the new token is created. This deletes both the old wp_otp_{token} and wp_otp_user_{uid} transients — the previous code is immediately invalidated in both the WP and GraphQL flows.


Brute-force surface

With the default settings (6-digit numeric, 5-minute TTL, 5 max attempts):

Vector Code space TTL Max attempts Max guessable codes
WP login 1,000,000 5 min 5 5
GraphQL 1,000,000 5 min 5 5

An attacker gets exactly 5 guesses before the token is revoked. The probability of a correct guess within 5 attempts is 0.0005% (5 / 1,000,000). For practical purposes this eliminates online brute-force entirely without requiring a WAF.

Increasing code_length or switching charset further reduces the already negligible probability:

Length Charset Code space P(hit in 5)
6 numeric 1,000,000 0.0005%
8 numeric 100,000,000 0.000005%
6 alphanumeric ~2.2 billion ~0.0000002%

Recommendations for production:

  • Keep max_attempts at 5 or lower — this is the primary line of defence against code guessing.
  • The built-in rate limiter (10 requests / 10 min per user) covers targeted email harassment. For broader protection against distributed volumetric attacks across many accounts, a WAF or Cloudflare rate-limiting rule on /wp-login.php and the GraphQL endpoint adds a network-layer backstop.
  • Enable Redis object cache — Transients backed by Redis are more consistent under load than database transients, and TTL expiry is precise rather than cron-based.

Redis / object cache compatibility

WordPress Transients automatically use the active object cache (Redis, Memcached, etc.) when one is configured. No code changes are needed. When an object cache is active, transients are stored in memory and expiry is handled by the cache backend rather than a cron-based garbage collection job — this improves both performance and TTL precision.