Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix missing authentication in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Put every critical route behind one middleware that verifies a server-issued credential: here a Bearer token whose HMAC-SHA256 is checked with hash_equals. Take the user from the verified token and the users table with $request->user(), never from an X-User-Id header or a body field, and answer 401 when the credential is missing or invalid.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (route middleware, hash_hmac, hash_equals)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 53 KB
On this page

Steps

  1. Mark the public routes (a health probe) and treat everything else as needing authentication. Group the protected routes under the middleware so a route cannot be added outside it.
  2. Write one middleware that verifies a credential and sets the user with $request->setUserResolver(); register it with an alias in bootstrap/app.php. It must verify, not merely decode, the token.
  3. Verify the token: recompute hash_hmac('sha256', $username, $secret) and compare with hash_equals (constant time), then take the username from the verified token. Reject a missing or malformed token with 401.
  4. Load the user from the users table by the verified username and expose it with $request->user(). Actions read the user from there and never from $request->header('X-User-Id') or the body.
  5. Test with Laravel's HTTP test helpers: a legitimately signed token works, a forged X-User-Id header is ignored, and an unsigned token is refused, with nothing changed.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/Auth/VulnerableAuthenticator.phpPHP
public function authenticate(Request $request): ?User
{
    // The client simply says who they are. We believe them.
    $id = $request->header('X-User-Id');

    return $id ? User::where('username', $id)->first() : null;
}

With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).

The fix

Fixedapp/Auth/FixedAuthenticator.phpPHP
public function authenticate(Request $request): ?User
{
    $token = $request->bearerToken();
    if (! $token || substr_count($token, '.') !== 1) {
        return null; // no credential: the action never runs
    }
    [$username, $signature] = explode('.', $token, 2);
    $secret = (string) config('fixcard.secret');
    $expected = hash_hmac('sha256', $username, $secret);
    // hash_equals is constant time, so a token the server never signed is refused and the
    // username cannot be forged or swapped.
    if (! hash_equals($expected, $signature)) {
        return null;
    }

    // The identity is whatever the verified token says, never the X-User-Id header.
    return User::where('username', $username)->first();
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixapp/Auth/WrongAuthenticator.phpPHP
public function authenticate(Request $request): ?User
{
    $token = $request->bearerToken();
    if (! $token) {
        return null;
    }
    // Decode the claimed username. There is no signature to check, so any token is accepted.
    $id = base64_decode(strtr($token, '-_', '+/'), true);

    return $id !== false && $id !== '' ? User::where('username', $id)->first() : null;
}

With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).

The authentication middleware

app/Http/Middleware/AuthenticateToken.phpPHP
public function handle(Request $request, Closure $next): Response
{
    $user = $this->authenticator->authenticate($request);
    if ($user === null) {
        return response()->json(['error' => 'sign in first'], 401);
    }
    $request->setUserResolver(fn () => $user);

    return $next($request);
}

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Feature/AccountTest.phpPHP
// Exploit: each payload claims an identity the caller cannot prove. The secure outcome is 401
// with nothing changed; the forged identity must never be honoured.

public function test_exploit_forged_user_id_header_is_not_trusted(): void
{
    // "I am bob" with no credential.
    $this->withHeaders(['X-User-Id' => 'bob'])->getJson('/account')
        ->assertStatus(401);
}

public function test_exploit_unsigned_bearer_token_is_rejected(): void
{
    // A token the attacker made: base64url of the victim's name, with no signature.
    $forged = rtrim(strtr(base64_encode('bob'), '+/', '-_'), '=');
    $this->withHeaders(['Authorization' => "Bearer $forged"])->getJson('/account')
        ->assertStatus(401);
}

public function test_exploit_email_takeover_via_forged_header(): void
{
    $before = $this->emailOf('bob');
    $this->withHeaders(['X-User-Id' => 'bob'])
        ->postJson('/account/email', ['email' => 'attacker@evil.example']);
    $this->assertSame($before, $this->emailOf('bob'), 'critical function ran for a forged identity');
}

public function test_exploit_email_takeover_via_unsigned_token(): void
{
    $before = $this->emailOf('bob');
    $forged = rtrim(strtr(base64_encode('bob'), '+/', '-_'), '=');
    $this->withHeaders(['Authorization' => "Bearer $forged"])
        ->postJson('/account/email', ['email' => 'attacker@evil.example']);
    $this->assertSame($before, $this->emailOf('bob'), 'critical function ran for an unverified token');
}

Pitfalls

  • An X-User-Id header, a user field in the body, or a ?user= query parameter is a claim, not a credential. The vulnerable authenticator trusts the header, so the caller names whoever they like.
  • A token the server never signed is not a credential. The wrong authenticator base64-decodes the username out of the token and trusts it; an attacker base64url-encodes the victim's name and is the victim. Decoding is not verifying.
  • Compare the MAC with hash_equals, not ===: a timing-safe comparison is required, and === on the raw strings can leak the correct prefix.
  • Register the middleware on a route group, not inside each controller action, or a new route added later is reachable with no credential. bootstrap/app.php aliases it here.
  • Laravel's built-in auth guards are not used in this card so the three authenticators stay visible side by side; a real app would use a vetted guard or Sanctum rather than hand-rolled token checking.
  • Authentication is not authorization: a verified user still needs an object- and function-level check before acting (see the missing-authorization card).

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/php-laravel does this for you, in Docker if you do not have PHP).
  2. Against your own API, send X-User-Id set to another user with no token, then a Bearer token you built without the server's key; both must answer 401 and change nothing.
  3. Scanners do not help here: Semgrep's PHP registry rules and Psalm's taint analysis report nothing about missing authentication on any implementation.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix missing authentication in PHP and Laravel
ToolRuleWhat it found here
Semgrepr/php (every PHP rule in the Semgrep registry)No findings on any implementation. No PHP rule in the registry distinguishes an authenticator that verifies a server-issued credential from one that trusts a header or an unsigned token; the three authenticators look the same to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --metrics=off --config r/php app routes config bootstrap
Psalmtaint analysis (all taint kinds)Psalm taint analysis with the Laravel pluginNo issues. A username read from a header or a Bearer token and passed to User::where() is not a taint sink Psalm models, and Psalm has no rule for a missing or unverified authentication step, so it cannot tell the vulnerable and wrong authenticators from the fixed one.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

No rule checked here targets missing authentication in Laravel. CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript. The regression tests are the control.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (route middleware, hash_hmac, hash_equals)
laravel/framework13.34.0
phpunit/phpunit13.4.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/php-laravelfolder.

Shell
composer install --no-interaction --no-progress
Shell
# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml

# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xml

Without PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'composer install --no-interaction --no-progress'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=fixed \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=vulnerable \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-laravel-composer-cache:/tmp/cache \
  -e IMPL=wrong \
  composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
  sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authentication/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 53 KB

Verified

Machine
Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
Toolchain
  • php 8.5.11 (CLI)
  • laravel/framework 13.34.0
  • phpunit/phpunit 13.4.0
  • composer 2.10.3
  • semgrep 1.180.0
  • psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
Matrix
fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
Test results
fixed 9/9 pass; vulnerable 2/9 fail (2 exploit tests: the forged X-User-Id header, the email takeover via that header); wrong 2/9 fail (2 exploit tests: the unsigned bearer token, the email takeover via that token), each test run in a container with --network=none after composer install
Dependency freshness
Same pins as the other Laravel cards: laravel/framework 13.34.0 (2026-09-29) with laravel/prompts held at 0.3.24 and phpunit/phpunit 13.4.0 (2026-10-02), because the 2026-10-06 releases were under seven days old and are not security releases. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-306: Missing Authentication for Critical Function
  2. OWASP Top 10:2025 A07 Authentication Failures
  3. OWASP API Security Top 10 2023: API2 Broken Authentication
  4. OWASP Authentication Cheat Sheet
  5. Laravel 13: Middleware
  6. Laravel 13: HTTP requests (bearerToken, setUserResolver)
  7. PHP manual: hash_equals (timing-safe comparison)
  8. PHP manual: hash_hmac

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.