web php

Session Fixation Prevention

Session fixation is an attack where an attacker tries to make a victim use a session ID the attacker already knows. If the victim logs in while keeping that same session ID, the attacker may be able to use the fixed ID as an authenticated session.

The core defence is simple: when the user's privilege level changes, issue a fresh session ID. The most common moment is successful login.

Why login is the important boundary

A session can exist before login. For example, an anonymous visitor may already have session data for a cart, CSRF token, or flash message.

That anonymous session must not simply become an authenticated session under the same ID. After the password is verified, regenerate the ID and then store the authenticated identity.

PHP example
<?php

declare(strict_types=1);

session_start();

$userId = 42;

session_regenerate_id(true);
$_SESSION['user_id'] = $userId;

echo 'Logged in user: ' . $_SESSION['user_id'] . PHP_EOL;

// Prints:
// Logged in user: 42

The true argument tells PHP to delete the old session data file where supported. That reduces the chance that the old ID remains useful.

Keep the order deliberate

Authenticate first, regenerate the session ID, then write login state.

PHP example
<?php

declare(strict_types=1);

/**
 * @param array<string, string> $users
 */
function checkPassword(array $users, string $email, string $password): ?int
{
    if (($users[$email] ?? null) !== $password) {
        return null;
    }

    return 42;
}

$users = ['dev@example.com' => 'correct-password'];
$userId = checkPassword($users, 'dev@example.com', 'correct-password');

if ($userId !== null) {
    echo 'Password accepted; regenerate the session ID before saving user_id.' . PHP_EOL;
}

// Prints:
// Password accepted; regenerate the session ID before saving user_id.

In a real login controller, the message line would be the place where you call session_regenerate_id(true) and then set $_SESSION['user_id'].

Do not regenerate the ID before checking credentials. A failed login should not create an authenticated session. Do not write user_id first and regenerate later in scattered code, because other logic in the same request may observe the old authenticated session ID.

Regenerate on privilege changes, not constantly

Regenerating on login is the normal rule. You may also regenerate when a user changes role, enables an admin mode, or completes a sensitive re-authentication step.

Regenerating on every request is usually unnecessary and can cause practical problems with concurrent requests, AJAX calls, and browser tabs. It also makes debugging session issues harder.

Strict mode helps too

session.use_strict_mode=1 tells PHP to reject session IDs that it did not create. This supports fixation prevention because PHP is less willing to accept an arbitrary ID supplied by a browser.

Strict mode does not replace regeneration after login. Use both: strict mode to reject unknown IDs, and regeneration to move from anonymous to authenticated state under a fresh ID.

PHP example
<?php

declare(strict_types=1);

$sessionConfig = [
    'use_strict_mode' => true,
    'cookie_httponly' => true,
    'cookie_secure' => true,
    'cookie_samesite' => 'Lax',
];

echo $sessionConfig['use_strict_mode'] ? 'Strict mode enabled' : 'Strict mode missing';

// Prints:
// Strict mode enabled

Logout is a separate reset

Fixation prevention protects the login transition. Logout should still clear all session data, destroy the session, and expire the cookie. Do not assume regeneration on login makes logout safe.

What to check in a project

Find the successful login path. There should be an obvious session_regenerate_id(true) or framework equivalent after credential verification and before storing the authenticated user ID.

Check that failed login attempts do not set login state. They may update counters or flash messages, but they should not create an authenticated identity.

Check special privilege changes. Admin impersonation, role elevation, password confirmation, and two-factor completion may also need a fresh session ID.

Check session configuration. Strict mode and secure cookie settings should support the login flow.

What you should be able to do

After this lesson, you should be able to explain session fixation in plain English, identify login as the key privilege boundary, place session_regenerate_id(true) in the correct part of the flow, and avoid unnecessary regeneration on every request.

Practice

Task: Model A Safe Login Transition

Write a small PHP script that models the order of a safe login transition.

Requirements

  • Use declare(strict_types=1);.
  • Represent session data with an array so the script can run from the command line.
  • Create a function that verifies credentials.
  • Create a function that records the steps taken after a successful login.
  • The successful path must include a "regenerate session ID" step before saving user_id.
  • The failed path must not save user_id.
  • Print both paths.

Check Your Work

Run the script and confirm that the successful path regenerates before writing login state, while the failed path leaves the session unauthenticated.

Show solution

This solution records the session-related actions so the order is easy to inspect.

PHP example
<?php

declare(strict_types=1);

/**
 * @param array<string, string> $users
 */
function verifyCredentials(array $users, string $email, string $password): ?int
{
    if (($users[$email] ?? null) !== $password) {
        return null;
    }

    return 42;
}

/**
 * @param array<string, mixed> $session
 * @return list<string>
 */
function attemptLogin(array &$session, string $email, string $password): array
{
    $users = ['dev@example.com' => 'correct-password'];
    $actions = [];

    $userId = verifyCredentials($users, $email, $password);
    if ($userId === null) {
        $actions[] = 'credentials rejected';
        return $actions;
    }

    $actions[] = 'regenerate session ID';
    $session['user_id'] = $userId;
    $actions[] = 'store user_id';

    return $actions;
}

$successfulSession = [];
$successfulActions = attemptLogin($successfulSession, 'dev@example.com', 'correct-password');

$failedSession = [];
$failedActions = attemptLogin($failedSession, 'dev@example.com', 'wrong-password');

echo 'Successful actions: ' . implode(' -> ', $successfulActions) . PHP_EOL;
echo 'Successful user id: ' . ($successfulSession['user_id'] ?? 'none') . PHP_EOL;
echo 'Failed actions: ' . implode(' -> ', $failedActions) . PHP_EOL;
echo 'Failed user id: ' . ($failedSession['user_id'] ?? 'none') . PHP_EOL;

// Prints:
// Successful actions: regenerate session ID -> store user_id
// Successful user id: 42
// Failed actions: credentials rejected
// Failed user id: none

In real request code, the recorded action would be session_regenerate_id(true). The important behaviour is the order: credentials pass first, the session ID changes next, and authenticated state is saved last.

Why This Works

The successful path proves the privilege transition is protected by a fresh session ID. The failed path proves bad credentials do not create user_id in the session.