---
title: "PHP Type Juggling: How It Breaks WordPress Login Checks"
slug: "wordpress-php-type-juggling-authentication-bypass"
description: "PHP type juggling turns a loose == comparison into a WordPress authentication bypass. Here's what still works on PHP 8 and how to fix it."
date: 2026-09-14
author: "WP Vanguard Team"
category: "Education"
tags: ["php-type-juggling", "loose-comparison", "authentication-bypass", "wordpress-plugin-security", "php-security"]
featured_image: "/images/blog/wordpress-php-type-juggling-authentication-bypass.jpg"
---

In May 2026, the User Verification plugin for WordPress let an attacker log in as any verified user, including an administrator, by submitting `true` as their one-time passcode. No password guessing, no phishing, no stolen session cookie. The bug was PHP type juggling: a single comparison written with `==` where the code needed `===`. It's tracked as CVE-2026-7458, and it's one of the cleanest examples we've seen of how a two-character difference in a comparison operator becomes a full account takeover.

Type juggling isn't a WordPress bug. It's a property of PHP's loose comparison operator, and it shows up anywhere a plugin checks a secret, a token, or a permission flag with `==` instead of `===`. This post covers what still bites on current PHP, what PHP 8 actually fixed, and how to find these comparisons in your own code before someone else does.

## What PHP Type Juggling Actually Means

PHP has two equality operators. `===` requires both the type and the value to match: the string `"123456"` is strict-equal only to the string `"123456"`, never to the integer `123456` or the boolean `true`. `==` is looser. It converts both sides to a common type before comparing them, and the rules governing that conversion aren't always what a developer expects.

When a comparison decides who gets logged in, that gap matters:

```php
// Loose: type coercion decides whether the login succeeds
if ($submitted_otp == $stored_otp) {
    wp_set_auth_cookie($user_id);
}

// Strict: both sides must share type and value
if ($submitted_otp === $stored_otp) {
    wp_set_auth_cookie($user_id);
}
```

Nothing about the strict version is harder to write. It's the same comparison with one extra character, and it removes an entire class of bug.

## What PHP 8 Actually Fixed

A lot of type juggling advice online is out of date. Before PHP 8.0, comparing a number to a non-numeric string converted the string to a number first, so `0 == "foo"` evaluated to `true`. That single rule powered years of WordPress vulnerabilities where an attacker's arbitrary string matched an integer check it should never have passed.

PHP's [Saner string to number comparisons RFC](https://wiki.php.net/rfc/string_to_number_comparison) landed in PHP 8.0 and flipped that rule: PHP now converts the number to a string and runs a string comparison, unless the string is already fully numeric. We tested this directly against PHP 8.4.18 running in our own Docker stack:

```php
var_dump(0 == "");      // PHP 7: bool(true)  | PHP 8.4.18: bool(false)
var_dump(0 == "admin"); // PHP 7: bool(true)  | PHP 8.4.18: bool(false)
var_dump(1 == "1abc");  // PHP 7: bool(true)  | PHP 8.4.18: bool(false)
```

All three now return `false`. If your plugin's minimum supported PHP version is 8.0 or later, the classic "arbitrary string matches zero" pattern is closed. That's a real, meaningful fix, and it's worth knowing about so you don't waste review time chasing a bug class that no longer exists on current PHP.

## What Still Bites on PHP 8 and Later

The RFC only touched comparisons between a string and a number. Several other type juggling patterns are untouched by it and behave exactly as they did a decade ago.

Magic hashes are the classic surviving case. When two strings both look like scientific notation for zero, PHP reads them as numeric strings and compares them as numbers, so any all-zero value equals any other all-zero value regardless of what follows the "e":

```php
var_dump("0e123" == "0e456"); // bool(true) on PHP 8.4.18
```

That's not something PHP 8 changed, because both sides here are numeric strings, exactly the case the RFC leaves alone. If a plugin compares a stored password hash or a signed token with `==` and an attacker can influence either side of that comparison, a value shaped like `0e130133780...` next to another numeric-looking hash passes with no cracking involved.

Boolean comparisons are a separate, unaffected trap. Comparing anything against `true` or `false` casts the other operand to a boolean first, and that logic hasn't changed since PHP 4:

```php
var_dump(true == "anything"); // bool(true)
var_dump(true == "0");        // bool(false)
```

A check written as `if ($verified == true)` accepts any non-empty string other than `"0"`. A JSON body with `"verified": "yes"` passes it. So does the literal string `"anything"`. `=== true` only accepts the boolean `true`.

`in_array()` and `array_search()` carry the same risk by default. Both take an optional strict flag as their last argument, and per the [PHP manual](https://www.php.net/manual/en/function.in-array.php), both default to loose comparison when it's left off:

```php
var_dump(in_array("0", [false, null])); // bool(true), because "0" is falsy
```

Our [Divi Form Builder writeup](/blog/divi-form-builder-file-upload-rce-cve-2026-5524) covers a real case where a missing strict flag on `in_array()` let a disallowed file extension slip past an upload check.

## Two Real CVEs Behind This Pattern

CVE-2026-7458, the OTP bypass we opened with, lives in the User Verification plugin's `user_verification_form_wrap_process_otpLogin()` function. Per the [NVD entry](https://nvd.nist.gov/vuln/detail/CVE-2026-7458), sourced from Wordfence, the flaw is "the use of a loose PHP comparison operator to validate OTP codes," letting an unauthenticated attacker log in as any user with a verified email by "submitting a 'true' OTP value." It carries a CVSS 3.1 score of 9.8.

The advisory doesn't say how that value reaches the comparison, and the type matters: on PHP 8.4, the string `"true"` loosely compared with `"123456"` is `false`, but the boolean `true` (which is what a JSON-decoded `true` becomes) compared the same way is `true`. This has nothing to do with CSRF protection; our [explainer on what WordPress nonces actually protect](/blog/wordpress-nonces-what-they-protect-and-what-they-dont) covers why a correct nonce check on that same endpoint wouldn't have stopped it. A nonce verifies where a request came from. It says nothing about whether the OTP value inside it is real.

A second CVE from the same week shows a sibling failure mode: type confusion rather than a loose `==`. The Temporary Login plugin's `maybe_login_temporary_user()` function read a `temp-login-token` GET parameter without checking it was a string. Send it as an array instead (`?temp-login-token[]=x`) and two things happen at once, per the [NVD description](https://nvd.nist.gov/vuln/detail/CVE-2026-7567): PHP's `empty()` treats an empty array as falsy, so the "no token" guard passes, and `sanitize_key()` coerces the array down to an empty string.

WordPress then matches any temporary-login user whose stored token is empty and logs the first one in. Same CVSS 9.8, same root cause category as CVE-2026-7458 (CWE-288, authentication bypass via an alternate path): a value's type went unverified before it reached a check that assumed one.

## Auditing Your Own Plugin

Start with a grep for comparisons on anything that sounds like a secret:

```bash
grep -rnE '(token|password|otp|secret|hash|key)[A-Za-z_]*\s*==[^=]' --include='*.php' .
```

This won't catch every instance; a comparison split across two oddly-named variables slips past a regex. But run it against any plugin handling logins, license keys, or API tokens and read every hit. Most will be harmless comparisons between two hardcoded values. Stop on anything where either side comes from `$_GET`, `$_POST`, `$_REQUEST`, or a decoded JSON body.

Check `in_array()` and `array_search()` calls the same way:

```bash
grep -rn 'in_array(\|array_search(' --include='*.php' . | grep -v ', *true *)'
```

Anything that comes back without a trailing `true` is running loose comparison by default, which is fine for a list of known-safe integers and risky for a list of anything user-controlled.

## Fixing It

For secret comparison, use `hash_equals()` instead of `===` where the timing of the comparison itself could leak information, such as verifying an API signature or a webhook token. Per the [PHP manual](https://www.php.net/manual/en/function.hash-equals.php), it runs in constant time regardless of where the strings first differ:

```php
if (hash_equals($stored_token, $submitted_token)) {
    // authenticated
}
```

For values that must be a specific type before you compare them, check the type first, the way the Temporary Login patch does:

```php
$raw = $_GET['temp-login-token'] ?? '';
if (!is_string($raw)) {
    return;
}
$token = sanitize_key($raw);
```

`wp_verify_nonce()` deserves its own warning here, because reaching for strict comparison without knowing its return type creates a different bug. Per the [WordPress developer reference](https://developer.wordpress.org/reference/functions/wp_verify_nonce/), it returns `1` for a fresh nonce, `2` for one in its second 12-hour window, and `false` when the nonce is invalid. It never returns the literal boolean `true`:

```php
// Never passes: wp_verify_nonce() returns 1, 2, or false, never true
if (wp_verify_nonce($nonce, 'my_action') === true) { ... }

// Correct: 1 and 2 are both truthy
if (wp_verify_nonce($nonce, 'my_action')) { ... }
```

Our [miniOrange SAML writeup](/blog/miniorange-saml-sso-bypass-exploited-cve-2026-15981) covers the mirror image of this mistake, where a tri-state return value got treated as a plain boolean and let a malformed signature through as if it were valid. And not every bug in this family is a typo in a comparison operator at all. [UpdraftPlus's CVE-2026-10795](/blog/updraftplus-auth-bypass-rce-cve-2026-10795) came from an RSA decryption call returning `false` on failure, with nothing downstream checking for it before that value became an encryption key. Same family, different shape: PHP hands back a value, the calling code assumes a type it never verified, and coercion fills the gap silently.

## Running a Scan Instead of a Line-by-Line Review

Reading through every comparison in a plugin you didn't write is slow, and it's easy to miss the one line that matters in a file with a few thousand others. If you run WordPress plugins you didn't audit yourself, which is most sites, a [free scan on WP Vanguard](/#scanner) checks your installed plugins against known CVEs, including CVE-2026-7458 and CVE-2026-7567, without requiring you to read a single line of PHP.

If you maintain plugin code yourself, the fix here doesn't require a rewrite. Find every `==` on a value that decides identity or permission, change it to `===` or `hash_equals()`, add the strict flag to `in_array()` and `array_search()`, and check the type of anything coming from `$_GET` or `$_POST` before it reaches a comparison. None of that is difficult work. It's just easy to skip when the feature ships and the comparison quietly works on the one input someone happened to test with. Add a test that feeds each comparison a string, a number and an empty value, so a type mistake fails in your tests and not in production.

## References

- [PHP RFC: Saner string to number comparisons](https://wiki.php.net/rfc/string_to_number_comparison)
- [PHP Manual: Comparison Operators](https://www.php.net/manual/en/language.operators.comparison.php)
- [PHP Manual: hash_equals()](https://www.php.net/manual/en/function.hash-equals.php)
- [PHP Manual: in_array()](https://www.php.net/manual/en/function.in-array.php)
- [WordPress Developer Reference: wp_verify_nonce()](https://developer.wordpress.org/reference/functions/wp_verify_nonce/)
- [NVD: CVE-2026-7458 Detail](https://nvd.nist.gov/vuln/detail/CVE-2026-7458)
- [NVD: CVE-2026-7567 Detail](https://nvd.nist.gov/vuln/detail/CVE-2026-7567)
