DEV Community

Cover image for PHP Type Juggling — When 0 Equals "admin"

PHP Type Juggling — When 0 Equals "admin"

This is the eighteenth article in a series on PHP and Laravel application security.

So far we have covered:

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app before exploiting it
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: Broken access control in Laravel — why being logged in is not enough
  • Article 11: Secrets in Laravel — why .env is only the beginning
  • Article 12: Session security in PHP — what most developers get wrong
  • Article 13: Rate limiting in Laravel and PHP — how to stop brute force before it starts
  • Article 14: Security headers in PHP and Laravel — the lines that harden every response
  • Article 15: IDOR in PHP and Laravel — when changing one number exposes someone else's data
  • Article 16: Mass assignment in PHP and Laravel — when user input becomes more than it should
  • Article 17: Open redirect vulnerabilities in PHP and Laravel

Every article in this series follows the same principle understand the attack before you try to stop it.

Type juggling is PHP's most surprising security vulnerability. It does not require a misconfigured server. It does not require a forgotten input validation check. It requires only that you used == instead of === in the wrong place and that you did not know why that mattered.

The Two Comparison Operators

PHP has two equality operators and the difference between them is the core of this vulnerability.

== loose comparison converts types before comparing. If you compare an integer to a string PHP decides what type to use and converts accordingly.

=== strict comparison compares both value and type. An integer is never equal to a string regardless of value.

One character difference. Completely different security implications.

The Surprising Truth About PHP 7

Run this code on PHP 7:

var_dump(0 == "admin");    // bool(true)
var_dump(0 == "password"); // bool(true)
var_dump(0 == "anything"); // bool(true)
var_dump(0 == "");         // bool(true)
var_dump(0 == null);       // bool(true)
Enter fullscreen mode Exit fullscreen mode

Every one of those comparisons returns true.

When PHP 7 compares an integer to a non-numeric string it converts the string to an integer. Non-numeric strings like "admin" convert to 0. So 0 == "admin" becomes 0 == 0 which is true.

This was a deliberate design decision from PHP's early days when loose typing was considered a feature. It became a security problem as PHP was used in authentication systems that nobody anticipated when those decisions were made.

PHP 8 changed this behavior:

var_dump(0 == "admin");    // bool(false) — PHP 8
var_dump(0 == "password"); // bool(false) — PHP 8
var_dump(0 == "");         // bool(false) — PHP 8
var_dump(0 == null);       // bool(true)  — still true in PHP 8
var_dump("1" == "01");     // bool(true)  — still true in PHP 8
var_dump("10" == "1e1");   // bool(true)  — scientific notation, still true
Enter fullscreen mode Exit fullscreen mode

PHP 8 fixed the integer-to-non-numeric-string comparison. But many behaviors remain unchanged. And many production applications still run PHP 7 including shared hosting environments that have not been upgraded. Always use === regardless of PHP version.

The Magic Hash Vulnerability

Here is a real authentication bypass using type juggling. This is not hypothetical — it has appeared in production authentication systems:

// Dangerous — loose comparison in token validation
$token = $_GET['token'];
$validToken = getTokenFromDatabase(); // returns '0e123456789'

if ($token == $validToken) {
    grantAccess();
}
Enter fullscreen mode Exit fullscreen mode

An attacker sends token=0.

The stored token '0e123456789' resembles scientific notation: 0 × 10^123456789. PHP 7's loose comparison converts it to float 0.0. The attacker's 0 also converts to 0.0. PHP compares 0.0 == 0.0 which is true. The attacker is authenticated without knowing the token.

Important context on magic hashes:

This specific attack requires that the stored hash begins with 0e followed only by digits. In practice this means the attack is probabilistic — not every hash will have this property. However applications that do not control what hash values are stored, or that generate tokens using algorithms where magic hashes are possible, are at meaningful risk. The fix is simple and the cost of not applying it is high.

Known hash values that evaluate to 0 under PHP 7 loose comparison:

MD5("240610708")  = 0e462097431906509019562988736854
MD5("QNKCDZO")   = 0e830400451993494058024219903391
SHA1("aaroZmOk") = 0e66507019969427134894567494305185566735
Enter fullscreen mode Exit fullscreen mode

Any of these compared with == to 0 returns true in PHP 7.

Type Juggling in switch Statements

switch in PHP uses loose comparison internally — the same vulnerability appears in role and permission checks:

$role = $_GET['role'];

switch ($role) {
    case 'admin':
        grantAdminAccess();
        break;
    case 0:
        denyAccess();
        break;
    default:
        grantBasicAccess();
}
Enter fullscreen mode Exit fullscreen mode

An attacker sends role=0. PHP 7 evaluates switch cases in order — first it checks 0 == 'admin' which is true in PHP 7 and grants admin access before ever reaching the deny case.

This specific behavior changed in PHP 8 where 0 == 'admin' is false. However the general principle that switch uses loose comparison remains true in PHP 8 for other type combinations. The safe pattern is to avoid mixing types in switch cases and validate input types before reaching any switch statement:

$role = (string) ($_GET['role'] ?? '');

if ($role === 'admin') {
    grantAdminAccess();
} elseif ($role === 'editor') {
    grantEditorAccess();
} else {
    denyAccess();
}
Enter fullscreen mode Exit fullscreen mode

Type Juggling in in_array()

in_array() uses loose comparison by default — a frequently missed vulnerability:

$allowedRoles = ['admin', 'editor', 'viewer'];
$role = 0; // attacker supplied as integer

if (in_array($role, $allowedRoles)) {
    // In PHP 7: 0 == 'admin' → true
    // Attacker passes the check
    grantAccess();
}
Enter fullscreen mode Exit fullscreen mode

The fix is the third parameter — strict mode:

if (in_array($role, $allowedRoles, true)) { // strict: true
    // Now uses === so 0 === 'admin' → false
    grantAccess();
}
Enter fullscreen mode Exit fullscreen mode

Always pass true as the third argument to in_array() when comparing values where type matters. This single parameter change prevents the entire category of in_array() type juggling vulnerabilities.


Type Juggling in JSON API Endpoints

APIs that accept JSON are particularly vulnerable because JSON has native boolean and null types that interact unexpectedly with PHP's ==:

$data = json_decode(file_get_contents('php://input'), true);
$token = $data['token'];

if ($token == $storedToken) {
    // authenticated
}
Enter fullscreen mode Exit fullscreen mode

An attacker sends:

{"token": true}
Enter fullscreen mode Exit fullscreen mode

In PHP true == "any_non_empty_string" is true in both PHP 7 and PHP 8. The attacker is authenticated by sending boolean true as their token without knowing the actual token value.

Or they send:

{"token": 0}
Enter fullscreen mode Exit fullscreen mode

Which triggers the integer comparison bypass in PHP 7.

The fix — validate type before comparing:

$data = json_decode(file_get_contents('php://input'), true);
$token = $data['token'] ?? null;

if (!is_string($token) || strlen($token) === 0) {
    http_response_code(401);
    die('Invalid token.');
}

if (!hash_equals($storedToken, $token)) {
    http_response_code(401);
    die('Invalid token.');
}
Enter fullscreen mode Exit fullscreen mode

hash_equals() — The Correct Token Comparison

For comparing tokens, hashes, and secrets PHP provides hash_equals():

// Wrong — loose comparison
if ($token == $storedToken) { }

// Better but incomplete — strict but timing-attack vulnerable
if ($token === $storedToken) { }

// Correct — strict, type-safe, timing-attack resistant
if (hash_equals($storedToken, $token)) { }
Enter fullscreen mode Exit fullscreen mode

hash_equals() does two important things.

Constant-time comparison — it always takes the same amount of time regardless of how many characters match. This prevents timing attacks where an attacker measures response time to determine how many characters of a token they guessed correctly.

Type enforcement — if either argument is not a string hash_equals() returns false, preventing boolean and integer bypass attacks.

Always use hash_equals() for token and hash comparisons. Always use password_verify() for password comparisons. Never use == or === directly for these comparisons.

The Safe Authentication Pattern

Passwords:

function authenticate(PDO $pdo, string $username, string $password): bool
{
    $stmt = $pdo->prepare('
        SELECT password_hash FROM users WHERE username = ?
    ');
    $stmt->execute([$username]);
    $user = $stmt->fetch();

    if (!$user) {
        // Call password_verify anyway to prevent timing-based
        // username enumeration — takes the same time as a real check
        password_verify($password, '$2y$10$invaliddummyhashfortiming000000000000000000000000000000');
        return false;
    }

    return password_verify($password, $user['password_hash']);
}
Enter fullscreen mode Exit fullscreen mode

Tokens and API keys:

function verifyToken(string $storedToken, mixed $suppliedToken): bool
{
    if (!is_string($suppliedToken) || strlen($suppliedToken) === 0) {
        return false;
    }

    return hash_equals($storedToken, $suppliedToken);
}
Enter fullscreen mode Exit fullscreen mode

Role checks:

// Wrong — loose comparison
if ($user->role == 'admin') { }

// Correct — strict comparison always
if ($user->role === 'admin') { }
if ((int) $user->role === 1) { }
Enter fullscreen mode Exit fullscreen mode

Type Juggling in Laravel

Laravel's default authentication system uses password_verify() internally — the Auth facade and Eloquent-based authentication are not vulnerable to type juggling in password comparison.

But type juggling appears in custom Laravel code.

Custom token validation:

// Dangerous
if ($request->input('token') == $user->api_token) {
    // vulnerable to 0/true bypass
}

// Safe
$token = $request->input('token');
if (!is_string($token) || !hash_equals($user->api_token, $token)) {
    abort(401);
}
Enter fullscreen mode Exit fullscreen mode

Custom middleware:

// Dangerous
if ($request->header('X-API-Key') == config('app.api_key')) {
    // vulnerable
}

// Safe
$key = $request->header('X-API-Key', '');
if (!hash_equals(config('app.api_key'), (string) $key)) {
    abort(401);
}
Enter fullscreen mode Exit fullscreen mode

JSON API validation:

// Dangerous — boolean true bypasses this
$token = $request->json('token');
if ($token == $storedToken) { }

// Safe
$token = $request->json('token');
if (!is_string($token) || !hash_equals($storedToken, $token)) {
    abort(401);
}
Enter fullscreen mode Exit fullscreen mode

in_array() in Laravel:

// Dangerous
if (in_array($request->input('role'), ['admin', 'editor'])) {
    // type juggling bypass possible
}

// Safe
if (in_array($request->input('role'), ['admin', 'editor'], true)) {
    // strict comparison
}
Enter fullscreen mode Exit fullscreen mode

Auditing Your Codebase

# Find loose comparisons in security-sensitive contexts
grep -rn " == " app/ | grep -i "token\|password\|hash\|key\|auth\|role\|admin"

# Find in_array without strict mode
grep -rn "in_array(" app/ | grep -v ", true)"

# Find switch statements with mixed-type cases
grep -rn "case 0\b\|case 1\b\|case true\b\|case false\b" app/

# Find JSON handling followed by comparison
grep -rn "json_decode\|->json(" app/
Enter fullscreen mode Exit fullscreen mode

Not every loose comparison is exploitable — context matters. But every result in an authentication or authorization context deserves careful review.


The Type Juggling Checklist

For plain PHP:

  • Never use == to compare tokens, passwords, hashes, roles, or permission values
  • Always use === for equality checks where type matters
  • Always pass true as the third argument to in_array() in security contexts
  • Always use password_verify() for password comparison
  • Always use hash_equals() for token and hash comparison
  • Validate JSON input types with is_string() or is_int() before any comparison
  • Never compare authentication values against null, 0, or false using ==
  • Test your authentication code on both PHP 7 and PHP 8 if your hosting environment may run either

For Laravel:

  • Trust Laravel's built-in Auth system for password comparison
  • Use hash_equals() for any custom token or API key comparison
  • Use strict in_array() with true as the third argument
  • Validate JSON request types before comparison — $request->json('token') returns mixed
  • Use === not == in all custom middleware authorization checks
  • Cast types explicitly before comparison when working with database values

Where Kriosa Fits

Type juggling vulnerabilities are exploited through authentication parameters — tokens, API keys, role values — that carry unexpected types or magic values designed to trigger loose comparison bypasses.

A request that sends token=0 or {"token": true} where a string token is expected is behaviorally unusual. Systematic probing of authentication endpoints with numeric values, boolean values, and zero-like strings is a behavioral pattern that differs from legitimate user authentication.

Kriosa monitors incoming requests for these behavioral signals — unusual type values in authentication parameters, systematic testing of token endpoints with non-string values, and request patterns that differ from normal application usage. These signals appear in the XAI dashboard with an explanation of what was detected and why it was flagged.

Prevention through ===, hash_equals(), and type validation stops the bypass at the application layer. Detection through behavioral monitoring tells you when someone is probing for loose comparisons to exploit.

In Prooflify all token comparisons use hash_equals(). In Belle-Full all role checks use strict comparison. Neither application uses == in authentication or authorization flows.

Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php

Documentation : kriosa Docs
Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.


The Series So Far

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app before exploiting it
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: Broken access control in Laravel — why being logged in is not enough
  • Article 11: Secrets in Laravel — why .env is only the beginning
  • Article 12: Session security in PHP — what most developers get wrong
  • Article 13: Rate limiting in Laravel and PHP — how to stop brute force before it starts
  • Article 14: Security headers in PHP and Laravel — the lines that harden every response
  • Article 15: IDOR in PHP and Laravel — when changing one number exposes someone else's data
  • Article 16: Mass assignment in PHP and Laravel — when user input becomes more than it should
  • Article 17: Open redirect vulnerabilities in PHP and Laravel
  • Article 18: This article — PHP type juggling and when 0 equals admin

Top comments (0)