Skip to main content
Being Idea Innovations
PHP Sessions Done Properly: Security, Locking and Correct Logout
Back to Blog

PHP Sessions Done Properly: Security, Locking and Correct Logout

30 June 20186 min readPHP
Share:

HTTP is stateless — each request arrives with no memory of the last. Sessions bridge that gap by storing data on the server and giving the browser a single ID to present on each request.

The basics take five minutes. The parts that matter — fixation, hijacking, locking and correct logout — take a little longer and are where the real bugs live.

How it actually works

  1. session_start() looks for a session ID in the PHPSESSID cookie.
  2. No ID, or an unknown one? PHP generates a new one and sends it as a cookie.
  3. Session data is loaded from storage — by default a file in /tmp — into $_SESSION.
  4. At script end, $_SESSION is serialised back to storage.

The important consequence: only the ID travels to the browser. The data stays on your server, which is the main advantage over cookies. But anyone holding that ID is that session — which is what the security measures below protect.

Configure before you start

Most tutorials open with a bare session_start(). Set the cookie parameters first — afterwards is too late, the cookie has already been sent:

<?php
declare(strict_types=1);

session_set_cookie_params([
    'lifetime' => 0,          // expires when the browser closes
    'path'     => '/',
    'domain'   => '',         // current host only
    'secure'   => true,       // HTTPS only
    'httponly' => true,       // invisible to JavaScript — blunts XSS theft
    'samesite' => 'Lax',      // blocks most CSRF
]);

// Only accept IDs we issued. Without this, an attacker can set an arbitrary
// PHPSESSID via a URL or a subdomain cookie and have PHP adopt it.
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');

session_start();

session.use_strict_mode is the single most valuable line here and is off by default. Without it PHP will happily accept a session ID it never generated, which is exactly what a fixation attack needs.

Reading and writing

$_SESSION['user_id']  = 2007;
$_SESSION['username'] = 'beingidea';

// Always default — a key may be absent on a fresh session.
$userId = $_SESSION['user_id'] ?? null;

if (isset($_SESSION['username'])) {
    echo 'Hello, ' . htmlspecialchars($_SESSION['username'], ENT_QUOTES, 'UTF-8');
}

Escape on output even though the data came from your server — it usually originated with a user at some point.

Session fixation

The attack: an attacker obtains a session ID, gets a victim to use it, then waits for them to log in. Since the ID never changed, the attacker's copy is now authenticated.

The fix is one line, at the moment privileges change:

function login(int $userId): void
{
    // Issue a brand-new ID and delete the old file.
    session_regenerate_id(true);

    $_SESSION['user_id']    = $userId;
    $_SESSION['logged_in']  = true;
    $_SESSION['created_at'] = time();
    $_SESSION['ua_hash']    = hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
}

The true argument matters — without it the old session file lingers and remains usable.

Regenerate periodically too, so a stolen ID has a short useful life:

if (!isset($_SESSION['last_regen'])) {
    $_SESSION['last_regen'] = time();
} elseif (time() - $_SESSION['last_regen'] > 900) {   // every 15 minutes
    session_regenerate_id(true);
    $_SESSION['last_regen'] = time();
}

Detecting hijacking

If an ID is stolen — over an insecure network, through XSS, from a shared machine — the thief has full access. You cannot prevent this entirely, but you can make it harder:

function sessionLooksValid(): bool
{
    // User agent rarely changes mid-session; a change is suspicious.
    $ua = hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
    if (($_SESSION['ua_hash'] ?? '') !== $ua) {
        return false;
    }

    // Absolute lifetime — even an active session eventually expires.
    if (time() - ($_SESSION['created_at'] ?? 0) > 8 * 3600) {
        return false;
    }

    // Idle timeout.
    if (time() - ($_SESSION['last_seen'] ?? time()) > 30 * 60) {
        return false;
    }

    $_SESSION['last_seen'] = time();
    return true;
}

if (!empty($_SESSION['logged_in']) && !sessionLooksValid()) {
    logout();
    header('Location: /login?expired=1');
    exit;
}

A deliberate omission: do not bind sessions to IP address. Mobile users change IP constantly moving between networks, and some ISPs rotate addresses mid-session. You will log out legitimate users all day for very little security gain.

Logging out correctly

The original version of this article had this:

// Wrong — destroys the session on every page load, then again inside the if
session_destroy();

if (array_key_exists("logout", $_GET)) {
    unset($_SESSION['user_id']);
    session_destroy();
}

The unconditional session_destroy() runs on every request, so nobody stays logged in. Logout also takes three steps, not one — session_destroy() alone leaves the cookie in the browser and $_SESSION populated for the rest of the request:

function logout(): void
{
    // 1. Clear the data in memory.
    $_SESSION = [];

    // 2. Expire the cookie, matching the parameters it was set with.
    if (ini_get('session.use_cookies')) {
        $params = session_get_cookie_params();
        setcookie(session_name(), '', [
            'expires'  => time() - 42000,
            'path'     => $params['path'],
            'domain'   => $params['domain'],
            'secure'   => $params['secure'],
            'httponly' => $params['httponly'],
            'samesite' => $params['samesite'] ?? 'Lax',
        ]);
    }

    // 3. Destroy the server-side storage.
    session_destroy();
}

Trigger logout from a POST with a CSRF token, never a GET link. A <img src="/logout"> on any site would otherwise log your users out — minor as attacks go, but trivially avoidable.

Session locking: the performance trap

This one surprises people. PHP's default file handler holds an exclusive lock on the session file for the whole request. Two concurrent requests from the same user run one after the other, not in parallel.

On a page firing several AJAX calls at once, they queue. Five requests taking 200ms each take one second in total rather than 200ms.

The fix is to release the lock as soon as you have finished writing:

session_start();

$userId = $_SESSION['user_id'] ?? null;
$_SESSION['last_seen'] = time();

// Release the lock. $_SESSION stays readable; further writes are just ignored.
session_write_close();

// Slow work now runs without blocking the user's other requests.
$report = generateExpensiveReport($userId);

For read-only endpoints, open the session read-only from the start:

session_start(['read_and_close' => true]);

If an application feels inexplicably serialised under load, session locking is the first thing to check.

Storage

The default file handler is fine for a single server. Behind a load balancer it breaks — request one hits server A and writes the file there, request two hits server B and finds nothing, so users appear randomly logged out.

Use shared storage:

; Redis
session.save_handler = redis
session.save_path = "tcp://127.0.0.1:6379?auth=secret"

; Or Memcached
session.save_handler = memcached
session.save_path = "127.0.0.1:11211"

Or implement SessionHandlerInterface to store sessions in your database, which gives you the ability to list and revoke a user's active sessions — worth having for any account with a security page.

Garbage collection

Expired session files are cleaned probabilistically, not on a schedule:

session.gc_maxlifetime = 1440   ; seconds of inactivity before eligible
session.gc_probability = 1
session.gc_divisor = 100        ; 1% chance per request

Two consequences. Sessions can survive well past gc_maxlifetime on a quiet site, so enforce your own idle timeout in code rather than relying on GC. And on Debian and Ubuntu, GC is disabled in PHP's config and handled by a cron job instead — if you set a custom session.save_path there, nothing ever cleans it and the directory fills up.

Checklist

  • Set cookie params and use_strict_mode before session_start().
  • Secure, HttpOnly, SameSite=Lax on the session cookie.
  • session_regenerate_id(true) on login and periodically.
  • Enforce idle and absolute timeouts in your own code.
  • Do not bind sessions to IP addresses.
  • Logout is three steps: clear, expire the cookie, destroy.
  • Call session_write_close() early on slow requests.
  • Use shared storage behind a load balancer.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers