PHP Sessions: State, Storage, and Security
Understand how PHP sessions work end to end — session_start(), how the session ID is generated and transmitted, where session data is stored, how to regenerate the session ID to prevent fixation, how to mitigate hijacking, how to log a user out and expire sessions properly, and how to move session storage to a distributed store like Redis for horizontally scaled applications.
HTTP is stateless — every request arrives with no memory of the last one — so any 'logged in' feeling a web app gives a user is an illusion PHP maintains for it, and sessions are the standard mechanism behind that illusion. A session ties a random, unguessable identifier stored in the visitor's browser (almost always as a cookie) to a bucket of server-side data the visitor never sees directly. Getting sessions right is as much a security topic as a feature topic: a session is effectively a bearer token for someone's identity, and every subsection here — regeneration, fixation, hijacking, expiration — exists because sessions are one of the most commonly attacked parts of a web application.
session_start() and how the session ID travels
Calling session_start() at the top of a script does several things at once: it checks whether the incoming request already carries a session identifier (by default in a cookie named PHPSESSID), and if so loads that session's existing data into the superglobal $_SESSION array; if not, it generates a brand-new random session ID, creates an empty session, and arranges for that ID to be sent back to the browser as a Set-Cookie header. session_start() must run before any output is sent, because it needs to be able to send that cookie header — calling it after echoing HTML (or with output buffering off) triggers a 'headers already sent' warning and the cookie silently fails to be set.
The session ID itself is just a long, cryptographically random string — PHP's default session ID generation is unguessable when session.sid_length and session.sid_bits_per_character are left at their modern secure defaults. That ID is the only thing that ever leaves the server; the actual session data ($_SESSION contents — user ID, cart contents, CSRF token, whatever the app stashes there) stays server-side and is looked up by that ID on every request. This separation is exactly what makes sessions more trustworthy than, say, storing a user ID directly in a plain cookie: the client can't read or forge the data, only present the key to it.
Where session data actually lives
By default PHP stores session data as flat files on local disk, in the directory named by session.save_path (often /tmp or a dedicated tmp folder), one file per session ID, named sess_<id>. This works fine for a single server but breaks down the moment an app runs behind a load balancer across multiple servers, because a session file written on server A is invisible to server B — a user could get logged in on one request and appear logged out on the next simply because it landed on a different machine.
PHP's session mechanism is pluggable via session save handlers: session_set_save_handler() lets you swap the storage backend entirely — a database table, Memcached, or (most commonly for modern deployments) Redis — while every other part of the session API ($_SESSION, session_start(), session_destroy()) stays exactly the same from the application code's point of view. Choosing the right backend is purely an infrastructure decision layered underneath an otherwise unchanged programming model.
• Default: files on local disk under session.save_path — simple, but not shareable across servers • Database: a sessions table keyed by session ID, with a TEXT/BLOB data column and an expiry timestamp • Memcached/Redis: in-memory, shared, and typically already deployed for caching — the standard choice for horizontally scaled apps • session_set_save_handler() swaps the backend without changing any $_SESSION-using application code
session_regenerate_id(): preventing session fixation
Session fixation is an attack where the attacker gets a victim to use a session ID the attacker already knows — for example by sending a link containing a pre-set PHPSESSID — and then waits for the victim to log in under that ID, at which point the attacker's browser, still holding the same ID, is now also logged in as the victim. The fix is procedural, not cryptographic: call session_regenerate_id(true) immediately after any privilege change, most importantly right after a successful login, so the ID a user was assigned before authenticating is thrown away and replaced with a fresh one the attacker never saw.
session_regenerate_id(true) generates a new session ID, updates the session cookie the browser will use going forward, and — because the boolean argument is true — deletes the old session's data on the server, so even if the old ID leaked, it now points at nothing. Regeneration should also happen on any other privilege boundary: switching from a regular user to an admin view, or any point where the value of impersonating 'this session' materially increases.
Session hijacking and mitigations
Session hijacking is the broader problem of an attacker obtaining a valid session ID that already belongs to a logged-in victim (via a network sniff on an unencrypted connection, an XSS payload reading document.cookie, or a leaked server log) and using it directly, no fixation trick required. Several cookie attributes and habits substantially reduce this risk: Secure (never send the cookie over plain HTTP), HttpOnly (JavaScript cannot read the cookie via document.cookie, which blocks the most common XSS-driven theft), and SameSite=Lax or Strict (the cookie is withheld on most cross-site requests, closing off a class of CSRF-adjacent attacks that ride on an ambient session cookie).
Beyond cookie flags, defense in depth matters: always serve the whole site over HTTPS so the session cookie can never be sniffed in transit, keep session lifetimes short and re-authenticate for sensitive actions, and consider binding a session loosely to the client's User-Agent string (not IP — IPs legitimately change on mobile networks) as a soft signal that something changed, logging or forcing re-authentication rather than silently trusting a session whose fingerprint suddenly looks different.
• session.cookie_secure = 1 — cookie is only ever sent over HTTPS • session.cookie_httponly = 1 — JavaScript cannot read the session cookie, blunting XSS-based theft • session.cookie_samesite = 'Lax' (or 'Strict') — withholds the cookie on most cross-site navigations/requests • session_regenerate_id(true) on login and on every privilege escalation, not just once at app startup
Logout, expiration, and distributed session stores
A correct logout does three things, not just one: unset the $_SESSION array's contents, destroy the server-side session record with session_destroy(), and explicitly expire the session cookie in the browser by resending it with a past expiration time — skipping that last step leaves a now-useless cookie sitting in the browser that could still confuse client code, and skipping session_destroy() leaves the server-side data sitting around even though the app considers the user logged out.
Expiration has two independent knobs: session.gc_maxlifetime controls how long an idle session's data is eligible for server-side garbage collection (an absolute idle timeout), while the session cookie's own lifetime (set via session_set_cookie_params() before session_start(), or left at 0 for a 'session cookie' that dies when the browser closes) controls how long the browser holds onto the ID at all. For anything security-sensitive, apps typically also track a 'last activity' timestamp inside $_SESSION itself and manually force logout past some threshold, since PHP's built-in garbage collection is probabilistic (session.gc_probability / session.gc_divisor) and not guaranteed to run exactly on schedule for any one session.
Once an app runs on more than one web server, file-based sessions stop working correctly, which is exactly the scenario distributed session stores solve. Redis is the standard choice: it's fast, already commonly deployed for caching, supports a native TTL on each key (a natural fit for session expiration — set the key's TTL to session.gc_maxlifetime and let Redis itself expire it), and PHP ships a Redis session save handler out of the box via the redis extension, requiring only a configuration change (session.save_handler = redis, session.save_path pointing at the Redis server) rather than any application code change.
Core session functions and secure configuration
session_set_cookie_params() must run before session_start() — cookie attributes can't be changed after the session (and its cookie) has already been created. session_regenerate_id(true) is the single most important call for preventing fixation — always pass true so the old session's data is deleted, not just the ID rotated.
<?php
// Configure secure cookie params BEFORE session_start()
session_set_cookie_params([
'lifetime' => 0, // 0 = expires when browser closes
'path' => '/',
'domain' => '',
'secure' => true, // only sent over HTTPS
'httponly' => true, // not readable from JavaScript
'samesite' => 'Lax',
]);
session_start(); // load or create the session
$_SESSION['user_id'] = 42; // write session data
session_regenerate_id(true); // fresh ID + destroy old data (call on login!)
$uid = $_SESSION['user_id'] ?? null; // read session data
// Proper logout
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', time() - 3600, '/');
// Redis-backed sessions (php.ini or ini_set before session_start)
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://127.0.0.1:6379');Code Examples
The first request creates a new session and initializes 'views' to 0; every subsequent request from the same browser (carrying the same PHPSESSID cookie) finds the existing value and increments it, demonstrating that $_SESSION persists across separate HTTP requests even though PHP itself has no memory between them. Expected result: You have visited this page 4 times.
attemptLogin() regenerates the session ID the instant authentication succeeds, closing the fixation window. requireLogin() implements an explicit idle timeout on top of PHP's own probabilistic garbage collection, forcing a hard logout — including a real session_destroy() — once too much time has passed since login, rather than trusting the cookie's lifetime alone.
Only the session.save_handler and session.save_path directives change; every $_SESSION read/write in application code is unaffected. Because Redis natively expires keys by TTL, session expiration becomes a property of the storage layer itself rather than something PHP's cron-like garbage collector has to sweep for, and any of N web servers behind a load balancer can serve any request for a given user since the session data no longer lives on local disk. In production this typically pairs with a Redis Sentinel or cluster setup for high availability, so a single Redis node failing doesn't log out every active user at once.
<?php
session_start();
if (!isset($_SESSION['views'])) {
$_SESSION['views'] = 0;
}
$_SESSION['views']++;
echo "You have visited this page {$_SESSION['views']} times.";<?php
session_start();
function attemptLogin(string $username, string $password): bool
{
$user = findUserByUsername($username); // looks up in your DB
if ($user && password_verify($password, $user['password_hash'])) {
// Prevent session fixation: never keep the pre-login session ID
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['login_time'] = time();
return true;
}
return false;
}
function requireLogin(): void
{
$idleLimit = 1800; // 30 minutes
if (
empty($_SESSION['user_id']) ||
(time() - ($_SESSION['login_time'] ?? 0)) > $idleLimit
) {
session_unset();
session_destroy();
header('Location: /login.php');
exit;
}
}<?php
// Redis-backed session save handler for horizontally scaled apps.
// Requires the phpredis extension.
ini_set('session.save_handler', 'redis');
ini_set('session.save_path', 'tcp://redis.internal:6379?auth=secret&timeout=2.5');
ini_set('session.gc_maxlifetime', 1800); // 30 min idle expiry, matches Redis TTL
session_set_cookie_params([
'lifetime' => 0,
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
// From here, $_SESSION works exactly as before -- but the data now lives
// in Redis, shared across every web server behind the load balancer, and
// Redis will auto-expire each session key after gc_maxlifetime seconds
// of inactivity, no cron-based garbage collection required.
$_SESSION['cart'] = $_SESSION['cart'] ?? [];Session lifecycle
A fresh ID is issued on first contact, rotated on login to defeat fixation, reused across authenticated requests, and explicitly torn down on logout — with a time-based backstop for sessions nobody ever logs out of.
Browser Server
------- ------
1. GET / (no cookie) --------> session_start() creates new session
<-------- Set-Cookie: PHPSESSID=abc123
2. POST /login (id=abc123) ----> validate credentials
session_regenerate_id(true)
<-------- Set-Cookie: PHPSESSID=xyz789 (old data destroyed)
3. GET /dashboard (id=xyz789) --> session_start() loads existing $_SESSION
<-------- authenticated response
4. POST /logout (id=xyz789) ----> $_SESSION=[]; session_destroy();
<-------- Set-Cookie: PHPSESSID=; expires in the past
5. idle > gc_maxlifetime -------> server GC / Redis TTL removes stale data
old id now maps to nothingImportant Rules
Call session_start() before any output, and call session_set_cookie_params() before session_start(), never after.
Always call session_regenerate_id(true) immediately after a successful login and on any privilege escalation, to defeat session fixation.
Set cookie_secure, cookie_httponly, and cookie_samesite so the session cookie can't be sniffed over HTTP, read by injected JavaScript, or ridden along on most cross-site requests.
A real logout does three things: clear $_SESSION, call session_destroy(), and expire the cookie client-side — not just one of the three.
Beyond a single server, move session storage to a shared backend (Redis is standard) via session_set_save_handler()/session.save_handler so sessions survive load balancing across machines.
Where this shows up
Every framework's authentication layer (Symfony Security, Laravel's session guard) calls the equivalent of session_regenerate_id() internally on login for exactly this reason, and production deployments behind a load balancer or in a container orchestrator like Kubernetes almost universally move sessions to Redis or a database precisely because 'sticky sessions' pinning a user to one server are fragile and defeat the point of horizontal scaling.
<?php session_start(); if (password_verify($password, $hash)) { $_SESSION['user_id'] = $user['id']; // ID never rotated }
<?php session_start(); if (password_verify($password, $hash)) { session_regenerate_id(true); // rotate BEFORE trusting the session $_SESSION['user_id'] = $user['id']; }
Without regenerating the session ID at login, any attacker who got the victim to use a known session ID before login (session fixation) is now also logged in as that user once login succeeds, because the pre-login and post-login session are literally the same ID with the same server-side data bucket.
[multiple-choice] Why must session_regenerate_id(true) be called right after a successful login rather than relying on the session ID issued before login? A. It makes the session data encrypted B. It prevents session fixation by discarding any session ID an attacker may have set on the victim before authentication C. It is required syntax with no security purpose D. It changes $_SESSION into a database-backed array automatically
B. It prevents session fixation by discarding any session ID an attacker may have set on the victim before authentication — In a fixation attack, the attacker gets the victim to authenticate under a session ID the attacker already knows. Regenerating the ID (with the old data destroyed, via the true argument) right after login means the ID that existed before authentication is no longer valid, closing that window.
[multiple-choice] Why do file-based PHP sessions break down once an application runs behind a load balancer across multiple servers? A. PHP sessions don't support cookies over HTTPS B. Session files are written to local disk on whichever server handled the request, so a different server in the pool can't see that user's session data C. session_start() only works on one server per PHP installation license D. $_SESSION arrays cannot exceed a fixed size
B. Session files are written to local disk on whichever server handled the request, so a different server in the pool can't see that user's session data — The default save handler stores each session as a file under session.save_path on local disk. If a load balancer routes a user's next request to a different server, that server's disk doesn't have the file, so the user effectively loses their session — the fix is a shared backend like Redis or a database that every server can reach.
Write a logoutUser() function that fully logs a user out: it should clear all session data, destroy the server-side session, and expire the session cookie in the browser, then redirect to /login.php.
function logoutUser(): void { $_SESSION = []; session_destroy(); setcookie(session_name(), '', time() - 3600, '/'); header('Location: /login.php'); exit; }
You need three separate steps before the redirect: emptying $_SESSION, session_destroy(), and a setcookie() call with a past expiration time using session_name() as the cookie name.