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

Fix session fixation and session cookies 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

Sign users in with Auth::attempt() or Auth::login(), which give the session a new ID, or call $request->session()->regenerate(true) in your own sign-in. Set SESSION_COOKIE=__Host-session, SESSION_SECURE_COOKIE=true and SESSION_LIFETIME=30, keep HttpOnly and SameSite=Lax, and end sessions 8 hours after sign-in in middleware.

On this page

Steps

  1. Use Laravel's guard to sign users in: SessionGuard::login(), which Auth::attempt() and Auth::login() call, runs session()->regenerate(true), giving the session a new ID, deleting the old one from the store and issuing a new CSRF token. The Laravel docs still call $request->session()->regenerate() after Auth::attempt().
  2. Any sign-in you write yourself (a one-time code, an SSO callback, impersonation, a step-up check) must call $request->session()->regenerate(true) when it marks the session as signed in; flush() and regenerateToken() keep the ID.
  3. On sign-out call Auth::logout(), $request->session()->invalidate() and $request->session()->regenerateToken(), as the Laravel docs show.
  4. Set SESSION_COOKIE=__Host-session, SESSION_SECURE_COOKIE=true and leave SESSION_DOMAIN unset and SESSION_PATH=/, so browsers accept the __Host- prefix; keep SESSION_HTTP_ONLY=true and SESSION_SAME_SITE=lax, both defaults.
  5. Set SESSION_LIFETIME to the idle limit (30 minutes here): with the database, file, Redis and cache drivers Laravel treats a session with no request for that long as expired.
  6. Store the sign-in time in the session and end the session in a middleware appended to the web group once it is older than the absolute limit (8 hours here); nothing in Laravel ends a busy session.
  7. Add regression tests that replay the pre-login cookie after sign-in, read the Set-Cookie attributes, and move the test clock ($this->travel()) past both limits.

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/Sessions/VulnerableSessions.phpPHP
public function signIn(Request $request, User $user): string
{
    // Mark the visitor's session as signed in. Its ID stays the same.
    $request->session()->put('user_id', $user->id);

    return $request->session()->get('return_to', '/account');
}

With IMPL=vulnerable, 5 of 11 tests fail (5 exploit tests).

The fix: a new session ID at sign-in

Fixedapp/Sessions/FixedSessions.phpPHP
public function signIn(Request $request, User $user): string
{
    $returnTo = $request->session()->pull('return_to', '/account');
    // A new session ID, the old one deleted from the store, and a new CSRF token. Auth::login()
    // and Auth::attempt() do this for you; a sign-in you write yourself must call it.
    $request->session()->regenerate(destroy: true);
    $request->session()->put(['user_id' => $user->id, 'signed_in_at' => now()->getTimestamp()]);

    return $returnTo;
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: cookie attributes and timeouts

Fixedapp/Sessions/FixedSessions.phpPHP
private const ABSOLUTE_TIMEOUT_MINUTES = 8 * 60;

public function config(): array
{
    // config/session.php; in an app, set them in .env: SESSION_COOKIE=__Host-session,
    // SESSION_SECURE_COOKIE=true, SESSION_LIFETIME=30.
    return [
        'session.cookie' => '__Host-session', // browsers accept it only with Secure, Path=/ and no Domain
        'session.secure' => true,
        'session.http_only' => true, // the default
        'session.same_site' => 'lax', // the default
        'session.path' => '/',
        'session.domain' => null,
        'session.lifetime' => 30, // minutes without a request: the idle timeout
        'session.expire_on_close' => false,
    ];
}

public function endExpiredSession(Request $request): void
{
    // Laravel ends idle sessions, but nothing in it ends a busy one: end it 8 hours after sign-in.
    $signedInAt = $request->session()->get('signed_in_at');
    if ($signedInAt !== null && now()->getTimestamp() - $signedInAt >= self::ABSOLUTE_TIMEOUT_MINUTES * 60) {
        $request->session()->invalidate(); // empties the session, deletes it from the store, new ID
    }
}

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fixapp/Sessions/WrongSessions.phpPHP
public function signIn(Request $request, User $user): string
{
    $returnTo = $request->session()->pull('return_to', '/account');
    // Start the signed-in session clean: nothing from the anonymous session carries over.
    $request->session()->flush();
    $request->session()->put(['user_id' => $user->id, 'signed_in_at' => now()->getTimestamp()]);

    return $returnTo;
}

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

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/SessionTest.phpPHP
// Exploit: a session ID that outlives sign-in, cookie attributes that expose the ID, and
// sessions that never end.

public function test_exploit_session_id_issued_before_sign_in_does_not_work_after_the_victim_signs_in(): void
{
    // The attacker opens the site and is given a session ID without signing in...
    $attacker = $this->browser();
    $attacker->get('/account', $this->typedUrl());
    $attacker->get('/login', $this->sameOriginPage());
    // ...and the victim signs in on a browser that already holds it: a shared computer the
    // attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
    $victim = $this->signIn($attacker->clone(), 'alice');
    $this->assertSame('alice', $this->signedInAs($victim));
    $this->assertNotSame('alice', $this->signedInAs($attacker), "the pre-login session ID opens Alice's account");
}

public function test_exploit_attackers_own_session_planted_in_the_victims_browser_does_not_become_the_victims(): void
{
    $bob = $this->signedIn('bob');
    $victim = $this->signIn($bob->clone(), 'alice'); // Bob plants his signed-in session cookie in Alice's browser
    $this->assertSame('alice', $this->signedInAs($victim));
    $this->assertNotSame('alice', $this->signedInAs($bob), "Bob's session now opens Alice's account");
}

public function test_exploit_session_cookie_is_httponly_so_page_scripts_cannot_read_it(): void
{
    [, $line, $attributes] = $this->sessionCookie($this->signedIn('alice'));
    $this->assertArrayHasKey('httponly', $attributes, "no HttpOnly in Set-Cookie: $line");
}

public function test_exploit_session_cookie_is_secure_so_the_browser_never_sends_it_over_plain_http(): void
{
    [, $line, $attributes] = $this->sessionCookie($this->signedIn('alice'));
    $this->assertArrayHasKey('secure', $attributes, "no Secure in Set-Cookie: $line");
}

public function test_exploit_session_cookie_is_samesite_lax_or_strict(): void
{
    [, $line, $attributes] = $this->sessionCookie($this->signedIn('alice'));
    $this->assertMatchesRegularExpression('/\A(lax|strict)\z/i', $attributes['samesite'] ?? '', "Set-Cookie: $line");
}

public function test_exploit_session_cookie_has_the_host_prefix_path_slash_and_no_domain(): void
{
    [$name, $line, $attributes] = $this->sessionCookie($this->signedIn('alice'));
    $this->assertStringStartsWith('__Host-', $name, "the session cookie is called $name");
    $this->assertTrue(isset($attributes['secure']) && ($attributes['path'] ?? null) === '/' && ! isset($attributes['domain']), "Set-Cookie: $line");
}

public function test_exploit_session_left_idle_for_31_minutes_no_longer_works(): void
{
    $alice = $this->signedIn('alice');
    $this->travel($this->timeline['idleFor'])->minutes();
    $this->assertNull($this->signedInAs($alice), "the session still works after {$this->timeline['idleFor']} idle minutes");
}

public function test_exploit_session_kept_busy_stops_working_8_hours_after_sign_in(): void
{
    $alice = $this->signedIn('alice');
    for ($t = $this->timeline['pollEvery']; $t <= $this->timeline['busyUntil']; $t += $this->timeline['pollEvery']) {
        $this->travel($this->timeline['pollEvery'])->minutes();
        $this->assertSame('alice', $this->signedInAs($alice), "signed out after $t busy minutes");
    }
    $this->travel($this->timeline['pastAbsoluteLimit'] - $this->timeline['busyUntil'])->minutes();
    $this->assertNull($this->signedInAs($alice), "the session still works {$this->timeline['pastAbsoluteLimit']} minutes after sign-in");
}

Pitfalls

  • $request->session()->flush() empties the session but keeps its ID, so whoever held the ID before sign-in holds the signed-in session after it. That is this card's wrong implementation. regenerateToken() is no better: it rotates the CSRF token only.
  • Laravel's session cookie Secure flag defaults to null, which Symfony turns into Secure only when the request itself arrived over HTTPS. Behind a load balancer that terminates TLS, without trusted proxies configured, the cookie goes out without Secure; set SESSION_SECURE_COOKIE=true.
  • SESSION_LIFETIME=120, the .env.example value, keeps a session alive as long as it gets a request every two hours, and SESSION_EXPIRE_ON_CLOSE only drops the cookie's expiry, not the stored session.
  • Every visitor gets a session and a session cookie from the first request to a web route, before signing in; that is the ID a fixation attack plants.
  • The cookie session driver (SESSION_DRIVER=cookie) keeps the whole session in an encrypted cookie: the server cannot revoke a copied cookie before it expires, even after sign-out.
  • In feature tests Laravel shares one application, and with it the session in memory, between requests, so two browsers in one test see each other's session; this card sends each request to a fresh application with database sessions to keep them apart.

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 session-cookies/php-laravel does this for you in the pinned composer image, so it needs Docker).
  2. Against your own app, save the cookie a visitor gets before signing in (curl -c jar -b jar https://your.app/account), sign in with the same jar, and check that the sign-in response sets a new session cookie and that the old value no longer opens the account page; check Secure, HttpOnly, SameSite and the name in that Set-Cookie line.
  3. grep for session()->put( in sign-in code without regenerate(, for flush( and regenerateToken( in sign-in code, and check SESSION_SECURE_COOKIE and SESSION_LIFETIME in every environment's .env; Semgrep's laravel-cookie-* rules read config/session.php only.

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 session fixation and session cookies in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.laravel.security.laravel-cookie-secure-set.laravel-cookie-secure-setNo findings on this card: like the other laravel-cookie-* rules it reads only files named *session.php, and this card sets the same keys from code. Run on Laravel 13.34.0's own config/session.php it flags the 'cookie' key, because 'secure' => env('SESSION_SECURE_COOKIE') has no default.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app config
Semgrepphp.laravel.security.laravel-cookie-null-domain.laravel-cookie-null-domainNo findings on this card (it reads *session.php only); it also flags Laravel's own config/session.php, where 'domain' => env('SESSION_DOMAIN') has no default.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3)
Semgrepphp.laravel.security.laravel-cookie-long-timeout.laravel-cookie-long-timeoutNo findings, here or on Laravel's config/session.php: it fires only on a literal 'lifetime' above 30, and the default config reads SESSION_LIFETIME from the environment.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3)
Semgrepr/php (every PHP rule in the Semgrep registry, 47 rules)No findings on any implementation: no rule reads a sign-in that keeps the session ID.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes resources config bootstrap
Psalmtaint analysis (all taint kinds)Psalm taint analysis with the Laravel pluginNo issues: Psalm does not model session IDs or cookie attributes.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/

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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (sessions on SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the session-cookies/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 session-cookies/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, 58 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: 11 of 11 tests pass; vulnerable: 5 of 11 tests fail (5 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
Test results
fixed 11/11 pass; vulnerable 5/11 fail (5 exploit tests); wrong 2/11 fail (2 exploit tests), each test run in a container with --network=none after composer install
Dependency freshness
Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. 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. OWASP Session Management Cheat Sheet (session ID renewal, cookie attributes, timeouts)
  2. Laravel 13.x: Regenerating the session ID
  3. Laravel 13.x: Session configuration
  4. Laravel 13.x: Manually authenticating users (regenerate after attempt)
  5. Laravel 13.x: Logging out
  6. Laravel 13.x: Configuring trusted proxies
  7. Laravel 13.x: framework config/session.php (defaults)
  8. MDN: Set-Cookie (Secure, HttpOnly, SameSite and the __Host- prefix)
  9. CWE-384: Session Fixation

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.