PHP Type Juggling
Date: 2026-09-27
==in PHP converts both sides to a common type before comparing, and the rules for strings that look like numbers have caused real authentication bypasses. PHP 8 fixed the worst case — a number against a non-numeric string — and left the rest. Use===, and strict mode on the functions that default to loose.
Type juggling is PHP converting a value’s type automatically to fit its context — in arithmetic, in if, and above all in ==, which compares after conversion. === compares type and value with no conversion.
The comparison table worth knowing once
PHP 7 PHP 8 why
0 == "foo" true false ← the PHP 8 change
0 == "" true false ←
42 == "42foo" true false ←
42 == " 42" true true leading whitespace is still numeric
"1" == "01" true true both numeric strings → compared as numbers
"10" == "1e1" true true "1e1" is scientific notation for 10
100 == "1e2" true true
"0e1234" == "0e5678" true true both are 0 × 10^n = 0
null == false true true
[] == false true true
"0" == false true true "0" is falsy
"abc" == null false false
The PHP 8 rule (from the 8.0 “saner string to number comparisons” change): comparing a number with a string, PHP compares numerically only if the string is a well-formed number; otherwise it converts the number to a string and compares as strings. Before PHP 8, the string was always converted to a number, and "foo" became 0.
What PHP 8 didn’t change: two numeric strings are still compared as numbers. "1e1" == "10" is true on every version, which is the root of the hash problem below.
Why it’s a security topic
Magic hashes. A hash written as hex that happens to match 0e followed only by digits is a numeric string — scientific notation for zero. Two such hashes compare equal with ==, whatever the input.
md5('240610708'); // "0e462097431906509019562988736854"
md5('QNKCDZO'); // "0e830400451993494058024219903391"
// password check with loose comparison
if (md5($input) == $storedHash) { login(); }
// $storedHash for some user happens to be a "0e…digits" hash
// → ANY input whose hash is also "0e…digits" logs in
// '240610708' == 'QNKCDZO' as far as this line is concerned
// fix: strict, and constant-time for secrets
if (hash_equals($storedHash, md5($input))) { login(); }=== would fix the equality; hash_equals() also compares in constant time, so timing doesn’t leak how much of the hash matched. The deeper fix is that MD5 was never a password hash: password_hash() and password_verify() do the whole job — Hashing.
The PHP 7 bypass that PHP 8 closed: a JSON body of {"token": 0} decoded to integer 0, and 0 == "any-secret-string" was true. On PHP 8 it’s false — but code that has to run on old versions, or libraries written for them, still carries the pattern.
strcmp with an array. strcmp($_GET['pw'], $secret) == 0 — pass pw[]=x and PHP 7 returned null with a warning, and null == 0 is true. PHP 8 throws a TypeError instead. Same moral: loose comparison turned an error into a yes — Common Vulnerabilities.
Where loose comparison hides
Not only ==. These default to loose and need a flag or a replacement:
in_array($x, $list) loose by default → in_array($x, $list, true)
array_search($x, $list) loose by default → array_search($x, $list, true)
array_keys($a, $search) loose by default → third argument true
switch ($x) loose → match ($x), which uses ===
in_array('abc', [0]) was true before PHP 8 — a whitelist check that let anything through when the list held a zero. match is the drop-in answer for switch — Reference - PHP.
Array keys juggle too. The string key "1" becomes integer 1, so $a["1"] and $a[1] are the same slot, and "01" stays a string. Floats and booleans used as keys are truncated to integers. Not a bug in practice, occasionally a surprise when merging arrays keyed by product IDs.
declare(strict_types=1) is a different thing
declare(strict_types=1);
function total(int $pence): int { return $pence; }
total("500"); // TypeError under strict_types; silently 500 without itstrict_types governs function argument and return coercion, not comparisons. It stops "500" being accepted where an int is declared; it does nothing to ==. It’s also per file, applying to calls made from that file. Worth having on everywhere; not a substitute for ===.
vs JS: the same trap with a different table — Equality and Coercion. 0 == "" is true in JavaScript to this day, and "1" == "01" is false there (two strings compare as strings). The advice is identical — strict equality by default — Equality and Coercion.
Rules
===everywhere,==never unless the conversion is the point, and then comment it. Static analysis can enforce it — PHPStan and PHP_CodeSniffer both have rules for loose comparison — Static Analysis- Third argument
trueonin_array,array_search,array_keys matchoverswitchhash_equals()for any secret, andpassword_verify()for passwords- Validate type at the boundary. Request data is always strings (or arrays, if someone adds
[]); convert explicitly —filter_var($x, FILTER_VALIDATE_INT)— before anything compares it — Type Safety Boundaries