Previous Article
OAuth Social Login with Google, Facebook and X: A Secure Implementation Guide
27 June 2018
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.
session_start() looks for a session ID in the PHPSESSID cookie./tmp — into $_SESSION.$_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.
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.
$_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.
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();
}
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.
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.
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.
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.
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.
use_strict_mode before session_start().Secure, HttpOnly, SameSite=Lax on the session cookie.session_regenerate_id(true) on login and periodically.session_write_close() early on slow requests.Advertisement
Written by
Amit VermaFounder / 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 AmitYou might also like
We ship software that scales. Let's work together.