Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

Laravel Token Guard Authentication Bypass (CVE-2026-114509)

Laravel rates CVE-2026-114509 as Critical, CVSS v4.0 9.2: an unauthenticated attacker can sign in as an existing user of the legacy token API guard on MySQL or MariaDB by sending a JSON body like {"api_token": [0]}, without knowing any valid API token. Fixed in 12.68.0 and 13.29.0, with the fix also present in the 11.56.1 tag. A public proof of concept has existed since October 22, 2026, and no exploitation in the wild is known.

TL;DR

  • Laravel's built-in token guard, the token auth driver, authenticates a request by looking up the api_token value in the users table. Before the fix, the guard never checked that the token is a string, so a crafted JSON body turned the lookup into a WHERE api_token IN (...) query with attacker-chosen values.
  • On MySQL and MariaDB, a body such as {"api_token": [0]} makes the database cast every stored text token to a number, so any token starting with a letter compares equal to 0. The query returns that user and the request is authenticated as them, with no valid token and no brute force.
  • Laravel rates the flaw Critical, CVSS v4.0 9.2, and a public proof of concept was published on October 22, 2026. The flaw is not in the CISA KEV catalog and no exploitation in the wild is known as of October 24, 2026.
  • Upgrade to 12.68.0, 13.29.0 or the 11.56.1 tag now, or add a middleware that rejects non-string api_token values. The fix shipped on August 25, 2026, so any dependency update since then may already have protected you.

Am I affected?

You are affected if all of these hold:

  • config/auth.php defines a guard with 'driver' => 'token' and an endpoint uses it (routes with auth:api or auth:<guard> when that guard is the token guard).
  • Your framework version is below the fixed version for its branch: below 12.68.0 on Laravel 12, below 13.29.0 on Laravel 13, below the 11.56.1 tag on Laravel 11, or any EOL branch (10.x and older).
  • Your users table lives on MySQL or MariaDB. PostgreSQL rejects the mismatched comparison and SQLite returns no rows, as demonstrated in the public PoC.
  • The guard does not set 'hash' => true on PHP 8. Laravel's guard creator defaults hash to false when the key is absent, and skeleton configs that ship the token guard set it to false explicitly.

You are not affected if you use Laravel Sanctum or Passport, or any guard other than the token driver. Sanctum reads a Bearer header, which is always a string, hashes with SHA-256 and stores tokens in a separate table.

Branch Affected versions Fixed version
13.x >= 13.0.0, < 13.29.0 13.29.0
12.x < 12.68.0 12.68.0
11.x < 11.56.1 11.56.1 tag
10.x and older all versions with the token guard none (end of life)

Check what runs in your deployments:

# Is the token guard configured at all?
grep -n "'driver' => 'token'" config/auth.php

# Which framework version is installed?
composer show laravel/framework | grep versions

# Which database backend?
grep DB_CONNECTION .env

# Does composer already flag it?
composer audit

In a container image:

docker run --rm <image> php -r "echo shell_exec('composer show laravel/framework 2>/dev/null | grep versions') ?: 'composer not in image';"

The guard history matters: the Laravel 6.x and 7.x skeletons shipped the token guard by default with 'hash' => false, and the 8.x and 9.x skeletons contain no API guard at all. config/auth.php is never overwritten by composer update, so an application upgraded from an old skeleton may still carry the token guard silently. The public PoC documents this configuration history, and the 6.x and 7.x skeleton files confirm it. Packagist logged 19,679,151 downloads of laravel/framework in the last month (checked October 24, 2026), so the population that needs this check is large.

How do I fix it?

Upgrade the framework to the first patched release of your branch:

# Laravel 12
composer require "laravel/framework:^12.68.0" --update-with-dependencies

# Laravel 13
composer require "laravel/framework:^13.29.0" --update-with-dependencies

The fix also exists in the v11.56.1 tag of laravel/framework, verified by reading src/Illuminate/Auth/TokenGuard.php at that tag. The advisory itself lists only the 12.x and 13.x ranges, so confirm your result in code if you pin 11.56.1.

If you cannot upgrade, add a middleware that rejects non-string tokens before they reach the guard, as the PoC recommends:

public function handle($request, Closure $next)
{
    $token = $request->input('api_token');
    if ($token !== null && !is_string($token)) {
        abort(400, 'Invalid token format');
    }
    return $next($request);
}

The middleware blocks this attack class. Its limits: it must be registered on every route or group that can reach the token guard, and it does nothing about the underlying type confusion. A web application firewall rule that rejects JSON or query values where api_token is an array achieves the same, and the PoC lists query and JSON patterns for that. The durable fix is to upgrade, or to migrate the API to Laravel Sanctum, which does not use this lookup at all.

What is the root cause?

The token guard treated any non-empty credential as a string, and Eloquent turns an array credential into an IN list, so a JSON array became a SQL comparison against a list of attacker-chosen values.

The vulnerable check, in src/Illuminate/Auth/TokenGuard.php at v12.67.0, line 83:

$token = $this->getTokenForRequest();

if (! empty($token)) {
    $user = $this->provider->retrieveByCredentials([
        $this->storageKey => $this->hash ? hash('sha256', $token) : $token,
    ]);
}

empty() on a non-empty array is false, so ! empty([0]) passes and the array [0] flows onward. Two more facts complete the chain:

  • The guard creator in src/Illuminate/Auth/AuthManager.php (line 166) passes $config['hash'] ?? false, so a guard config without a hash key keeps the token raw. With 'hash' => true on PHP 8, hash('sha256', [0]) throws a TypeError and the request dies with an error rather than a bypass. The PoC notes that PHP 7 only warns and continues, so old stacks stay exposed even with hashing on.
  • EloquentUserProvider::retrieveByCredentials (line 129) treats array credentials as a list, by design, so other auth flows can look up several candidates:
foreach ($credentials as $key => $value) {
    if (is_array($value) || $value instanceof Arrayable) {
        $query->whereIn($key, $value);
    }

The guard's credential therefore stops being "the token to compare" and becomes "the list of tokens the user is allowed to match". The patch commits, both titled "fix token retrieval" and dated August 24, 2026, close the type confusion by requiring a string before the lookup:

-        if (! empty($token)) {
+        if (is_string($token) && ! empty($token)) {

and the same check in validate(), the second entry point that passes a credential to retrieveByCredentials without any hashing.

How is it exploited?

The attack needs one request with a JSON body, as the public PoC demonstrates:

  1. The attacker sends POST /api/user with Content-Type: application/json and body {"api_token": [0]}. Laravel's request->input() decodes the JSON, so the credential is the PHP array [0], an integer in a list.
  2. TokenGuard::getTokenForRequest() returns [0] from the request input, and ! empty([0]) passes.
  3. With hash false, the raw array reaches retrieveByCredentials, which builds WHERE api_token IN (0) because the credential is an array.
  4. MySQL and MariaDB compare the text column against the number 0 by casting each stored token to a number. A token that starts with a letter, the common case for API tokens, casts to 0 and compares equal.
  5. ->first() returns the first matching row. The PoC's lab seeds four users and the request comes back authenticated as the admin, the first user created, with no valid token ever sent.
  6. Every subsequent request in that session runs with that user's permissions, so the bypass becomes an account takeover on every route the guard protects.

The exact attacker request from the PoC, reproducible against its lab:

curl -s http://target/api/user \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -X POST -d '{"api_token": [0]}'

A query string such as ?api_token[]=0 also delivers an array, but of the string "0", which the database compares as text, so the JSON body with a bare number is the path that works.

Attack flow

flowchart LR A["POST with JSON body
{"api_token": [0]}"] --> B["getTokenForRequest()
returns array [0]"] B --> C["! empty([0]) passes,
no is_string check"] C --> D["Array credential
becomes WHERE api_token IN (0)"] D --> E["MySQL casts text tokens
to numbers, letter tokens cast to 0"] E --> F["first() returns the
first matching user"] F --> G["Request authenticated
as that user"]

What can an attacker do?

The attacker authenticates as one existing user of the token guard and gets everything that user's API access allows: read, write and delete through API routes, and any data that route layer can reach. The conditions and limits, from the advisory and the PoC:

  • No credentials, no user interaction and no network position beyond reachability of the API.
  • The guard must be the token driver on MySQL or MariaDB with hash false. PostgreSQL and SQLite deployments are not affected by the integer comparison.
  • The attacker does not choose the account. The query returns the first matching row, which the PoC found to be the admin in its lab because that user was created first.
  • Sanctum and Passport deployments are not affected. Their token lookups differ.

Why does the fix work?

The patch adds is_string() in front of the emptiness check at both entry points, user() and validate():

-        if (! empty($token)) {
+        if (is_string($token) && ! empty($token)) {
             $user = $this->provider->retrieveByCredentials([
-        if (empty($credentials[$this->inputKey])) {
+        if (! is_string($credentials[$this->inputKey] ?? null) || empty($credentials[$this->inputKey])) {

A JSON array now fails the string check and authentication returns null, the outcome the guard always intended. The fix closes this issue completely at the guard level: no non-string credential can reach retrieveByCredentials through the token guard anymore. It does not change retrieveByCredentials itself, which still maps array credentials to IN lists for the providers that use them deliberately, so application code that passes arrays into auth lookups remains its own risk.

How do I check if I were compromised?

No indicators of compromise are published by Laravel. The PoC documents what exploitation looks like, and the patterns are unambiguous because real clients send a string token:

  • Query strings in access logs: api_token[]=0 and api_token[0]=0.
  • JSON bodies in application logs or WAF logs: "api_token":[0] and "api_token":[false].
  • Anomalies worth checking regardless of pattern: successful responses on token-guard routes from sessions with no matching token issue, and admin accounts acting from new source addresses.

Timeline

  • 2026-08-24: Taylor Otwell commits the fix, "fix token retrieval", on both supported branches.
  • 2026-08-25: Laravel releases v12.68.0 and v13.29.0 with the fix.
  • 2026-10-19: Laravel publishes advisory GHSA-f9x3-7vq9-cph6, rating it Critical, CVSS v4.0 9.2, and credits KIKOmanasijev.
  • 2026-10-21: The CVE record for CVE-2026-114509 is published by the GitHub CNA.
  • 2026-10-22: A public proof of concept with a Docker lab is published on GitHub.
  • 2026-10-23: The GitHub Advisory Database publishes and reviews the advisory, adding CWE-287 and CWE-1287.
  • 2026-10-24: This post.

FAQ

Is Laravel Sanctum or Passport affected?

No. Sanctum reads the Bearer header, which is always a string, and compares a SHA-256 hash against a separate personal access tokens table, so the array never reaches a lookup. Passport authenticates through OAuth2 flows with a different storage and lookup. The advisory covers only the built-in token guard.

Why does the attack work only on MySQL or MariaDB?

The array credential ends up compared as the number 0, and MySQL and MariaDB cast the text column to a number for that comparison, which turns most real tokens into 0. PostgreSQL raises a type error for the same query and SQLite's type affinity returns no rows, both demonstrated in the PoC's lab. The guard bug exists everywhere; the exploitable comparison needs the implicit cast.

Does 'hash' => true protect me?

On PHP 8 it degrades the attack into an error: hash('sha256', [0]) throws a TypeError and the request fails instead of authenticating anyone. On PHP 7 the hash function only warns and returns null, so the PoC marks that combination as still exposed. If you rely on hashing as your only defense, upgrade anyway, and note that the 6.x and 7.x skeleton configs shipped the token guard with 'hash' => false.

Can the attacker pick a specific account?

Not directly. The lookup returns the first row the database matches, so the attacker gets whichever matching user comes first, which the PoC's lab showed is the first user created, often an administrator. An attacker can also send several numbers in the JSON array to widen the cast matches, but the selection stays whatever the database returns first.

My application is on an end-of-life Laravel version. What now?

Add the middleware that rejects non-string api_token values, or a WAF rule with the patterns above, and plan a move to Sanctum, which the PoC recommends as the migration path. End-of-life branches will not receive the fix, so the guard itself stays vulnerable for as long as it is configured.

Scan your web applications with Ostorlab to surface the Laravel framework versions and token guard configurations they expose, including legacy deployments still configured with the token driver.

References

  • Laravel advisory GHSA-f9x3-7vq9-cph6, published October 19, 2026: https://github.com/laravel/framework/security/advisories/GHSA-f9x3-7vq9-cph6 (checked October 24, 2026)
  • CVE-2026-114509 record with the CVSS v4.0 9.2 vector: https://cveawg.mitre.org/api/cve/CVE-2026-114509 (checked October 24, 2026)
  • GitHub Advisory Database entry with the CWE-287 and CWE-1287 classification: https://github.com/advisories/GHSA-f9x3-7vq9-cph6 (checked October 24, 2026)
  • Fix commit on the 12.x branch, dated August 24, 2026: https://github.com/laravel/framework/commit/9b21ce0a9bb2b2599978bc0b0df4abea952ad31c (checked October 24, 2026)
  • Fix commit on the 13.x branch, dated August 24, 2026: https://github.com/laravel/framework/commit/8dbb08bccac1be095dba4a9d259a419620f1e8a5 (checked October 24, 2026)
  • Release v12.68.0, published August 25, 2026: https://github.com/laravel/framework/releases/tag/v12.68.0 (checked October 24, 2026)
  • Release v13.29.0, published August 25, 2026: https://github.com/laravel/framework/releases/tag/v13.29.0 (checked October 24, 2026)
  • Fixed TokenGuard source at the v11.56.1 tag: https://raw.githubusercontent.com/laravel/framework/v11.56.1/src/Illuminate/Auth/TokenGuard.php (checked October 24, 2026)
  • Vulnerable TokenGuard source at v12.67.0: https://raw.githubusercontent.com/laravel/framework/v12.67.0/src/Illuminate/Auth/TokenGuard.php (checked October 24, 2026)
  • Public proof of concept with a Docker lab, published October 22, 2026: https://github.com/tajulasri/-CVE-2026-114509-POC (checked October 24, 2026)
  • Laravel 7.x skeleton auth config with the token guard: https://raw.githubusercontent.com/laravel/laravel/7.x/config/auth.php (checked October 24, 2026)
  • Laravel 6.x skeleton auth config with the token guard: https://raw.githubusercontent.com/laravel/laravel/6.x/config/auth.php (checked October 24, 2026)
  • Packagist download statistics for laravel/framework: https://packagist.org/packages/laravel/framework.json (checked October 24, 2026)