Tags: web-dev concept

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 it

strict_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 true on in_array, array_search, array_keys
  • match over switch
  • hash_equals() for any secret, and password_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