These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP Securityin_array Strict Mode

in_array Strict Mode

The PHP function in_array($needle, $haystack) does type juggling on the needle. This is almost never what the developer meant when they wrote it.

The default behaviour

in_array(0,   ["abc", "def", "ghi"]);   // true on PHP < 8
in_array("a", [0, 1, 2]);               // true on PHP < 8
in_array(true, ["whatever"]);           // true (any non-empty string)

Each of those is a bypass somewhere.

The strict form

in_array($needle, $haystack, true);
//                            ^^^^

true flips it to strict mode — exact type and value match required, no juggling. This is what the developer almost always meant.

A real-shape bypass

This pattern shows up in custom RBAC:

$banned_user_ids = [101, 202, 303];
 
if (in_array($_GET['user_id'], $banned_user_ids)) {
  die("forbidden");
}
 
// continue with $_GET['user_id'] as if it were safe

The attacker sends ?user_id=abc. in_array("abc", [101, 202, 303]):

  • PHP juggles "abc" to 0 (PHP < 8 behaviour),
  • 0 is not in [101, 202, 303] → returns false,
  • the “banned” check passes,
  • the rest of the code uses "abc" as the user id.

Whether downstream code crashes or silently misbehaves depends on what it does with the id. Both outcomes are bugs.

A subtler version

$allowed_roles = ["admin", "editor"];
 
if (in_array(0, $allowed_roles)) {
  // …
}

in_array(0, ["admin", "editor"]) returns true on PHP < 8 because "admin" juggles to 0. If anything ever passes 0 (an unsigned int default, a missing query param coerced to int, a deserialised JSON null), the check greenlights.

Detection during source review

Search patterns

# Non-strict in_array / array_search
rg -n 'in_array\(|array_search\(' --type=php | rg -v ', *(true|TRUE)\s*\)'

Anything in the output that compares against user input or a security-relevant allow/deny list is a flag.

Cousin functions with the same problem:

  • array_search($needle, $haystack) — same juggling, plus false vs 0 return-value confusion
  • array_keys($array, $value) — also juggles by default; pass strict: true
  • array_unique($array, SORT_REGULAR)SORT_REGULAR juggles; prefer SORT_STRING or SORT_NUMERIC

Test inputs

  • 0
  • false
  • null
  • ""
  • "0" (string zero)

Audit focus

For every non-strict in_array() / array_search() hit, decide whether attacker input can bypass an allowlist or role check by coercing to one of those values. Allowlists keyed on strings (["admin", "editor"]) collapse to 0 for any of the above; allowlists of integers ([101, 202]) collapse for any non-numeric string the attacker sends.

In code review, treat any in_array(...) with fewer than three arguments as suspicious until you can prove the needle and haystack are both fully typed. It’s faster than tracing each one.

The fix

if (in_array($needle, $haystack, true)) {  }

Or skip in_array entirely for security-critical checks and use a typed map:

$banned_ids = [101 => true, 202 => true, 303 => true];
 
if (isset($banned_ids[(int) $_GET['user_id']])) {
  die("forbidden");
}

The (int) cast neutralises the original juggling at the boundary, and the isset lookup is O(1) and type-safe.

Quick reference

CallRisk
in_array($u, $list)⚠️ Juggles. Bypass-prone.
in_array($u, $list, true)✅ Strict.
array_search($u, $list)⚠️ Juggles. Plus false/0 confusion.
array_search($u, $list, true)✅ Strict.
isset($map[$u])✅ When $map is keyed by allowed values.