Sessions and Cookies
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05, with the PHP 8.6 changes to the session
defaults marked as upcoming (verified with 8.6.0RC2). Every output was captured from the examples running under
php -S and under PHP-FPM behind Nginx. Laravel replaces all of this with its own session layer, which is on
Sessions, Cookies and Flash Data; the security reasoning
below applies there too.
How PHP sessions work
HTTP is stateless: each request arrives without memory of the previous one. A session adds memory on the server
side. session_start():
-
looks for the session ID in the cookie named by
session.name(PHPSESSID); -
if there is none (or, in strict mode, if the ID is unknown), generates a new random ID and sends it back in a
Set-Cookieheader; -
loads the data stored under that ID into
$_SESSION, by default from a filesess_<id>insession.save_path(the system temporary directory when it is empty), and locks that file; -
at the end of the request (or on
session_write_close()), serialises$_SESSIONback to storage and releases the lock.
The browser stores only the ID. The data never leaves the server, so a session can hold the logged-in user’s ID, a CSRF token, a shopping basket or a flash message. Anyone who presents the same ID is that session, which is why the ID must be unguessable, transported only over HTTPS and unavailable to JavaScript.
<?php
declare(strict_types=1);
session_start([
'cookie_httponly' => true,
'cookie_samesite' => 'Lax',
'cookie_secure' => false, // true behind HTTPS (always in production)
'use_strict_mode' => true,
]);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
header('Content-Type: text/plain');
switch ($path) {
case '/login': // POST in a real app, after password_verify()
session_regenerate_id(true); // new ID, old session data deleted
$_SESSION['user_id'] = 42;
$_SESSION['flash'] = 'Welcome back!';
header('Location: /books', true, 303);
break;
case '/books':
$flash = $_SESSION['flash'] ?? null; // read once ...
unset($_SESSION['flash']); // ... then forget: a flash message
printf("user %s, flash: %s, session id %s\n", $_SESSION['user_id'] ?? 'guest', $flash ?? '-', session_id());
break;
case '/logout':
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', ['expires' => 1, 'path' => '/']);
echo "logged out\n";
break;
}
$ curl -i -c jar -b jar localhost:8000/books
Set-Cookie: PHPSESSID=24f8dff77b8104328baa13c764bf6bac; path=/; HttpOnly; SameSite=Lax
user guest, flash: -, session id 24f8dff77b8104328baa13c764bf6bac
$ curl -i -c jar -b jar localhost:8000/login
HTTP/1.1 303 See Other
Set-Cookie: PHPSESSID=23e756dc93c5148c7583d4873948ce69; path=/; HttpOnly; SameSite=Lax
Location: /books
$ curl -c jar -b jar localhost:8000/books; curl -c jar -b jar localhost:8000/books
user 42, flash: Welcome back!, session id 23e756dc93c5148c7583d4873948ce69
user 42, flash: -, session id 23e756dc93c5148c7583d4873948ce69
$ ls /tmp/sess_24f8dff77b8104328baa13c764bf6bac
ls: cannot access '/tmp/sess_24f8dff77b8104328baa13c764bf6bac': No such file or directory
The run shows the whole lifecycle: an anonymous session with one ID, a new ID at login (the old session file is
gone, because session_regenerate_id(true) deletes it), a flash message shown exactly once, and the user ID
remembered across requests.
The security settings
| Setting | PHP 8.5 default | PHP 8.6 default (upcoming) | Recommended and why |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(empty) |
|
|
|
|
|
|
|
|
|
the idle timeout in seconds, for storage clean-up; enforce an application-level timeout too |
|
|
|
leave the defaults (128 bits of entropy); changing them is deprecated since 8.4 |
Source: PHP Manual — Session runtime configuration, Securing session INI settings and RFC: Session security defaults (8.6)
The defaults in the table were read with ini_get() from the official 8.5.11 and 8.6.0RC2 images. PHP 8.6 makes
three of the four recommendations the default. Until then, set them in php.ini, or pass them to session_start()
as the example does. An application that reads the session cookie from JavaScript, or needs it on cross-site POSTs
(an embedded payment flow, for example), must opt out explicitly in 8.6 (SameSite=None then also requires
Secure).
Session fixation and hijacking
In session fixation, an attacker makes the victim use a session ID the attacker already knows (through a link with the ID, a cookie set on a sibling subdomain, or a shared computer), waits for the victim to log in, and then uses the same ID. Two defences together close it:
-
Strict mode rejects IDs the server did not issue, so the attacker cannot choose one.
-
session_regenerate_id(true)on every privilege change (login, logout, switching user, gaining an admin role) issues a new ID, so even a known pre-login ID is useless after login.
The effect of strict mode, measured under PHP-FPM with a cookie the server never issued:
$ curl -i -b 'PHPSESSID=attackerchosen456' 'http://bookshelf.test/sess.php?strict=0'
page: session attackerchosen456, seen 1 # accepted: the attacker's ID is now real
$ curl -i -b 'PHPSESSID=attackerchosen789' 'http://bookshelf.test/sess.php?strict=1'
Set-Cookie: PHPSESSID=1545794364a7cc18d466a15c5db1b78a; path=/
page: session 1545794364a7cc18d466a15c5db1b78a, seen 1 # rejected: a fresh ID is issued
Session hijacking means stealing a valid ID after login. The defences are HTTPS everywhere with cookie_secure,
cookie_httponly against theft through XSS, short idle timeouts, regenerating the ID on login, and asking for the
password again before sensitive actions. Binding a session to the IP address or the User-Agent breaks legitimate
users (mobile networks change IPs) and stops little.
Session locking
The default files handler locks the session file from session_start() until the session is written. Two requests
with the same session therefore run one after the other, even with many PHP-FPM workers. Measured with a page that
sleeps for two seconds:
<?php
declare(strict_types=1);
session_start();
$a = $_GET['a'] ?? '';
if ($a === 'slow') { sleep(2); } // holds the lock for 2 s
if ($a === 'slow-unlocked') {
$_SESSION['seen'] = ($_SESSION['seen'] ?? 0) + 1;
session_write_close(); // save and release the lock first
sleep(2);
}
$_SESSION['seen'] = ($_SESSION['seen'] ?? 0) + 1;
printf("%s: session %s, seen %d\n", $a ?: 'page', session_id(), $_SESSION['seen']);
same session: the fast request finished after 2011 ms
different session: the fast request finished after 214 ms
same session, session_write_close(): the fast request finished after 213 ms
Source: session_write_close
Locking prevents lost updates (two requests overwriting each other’s changes to $_SESSION), but it serialises
parallel AJAX calls and makes one slow request (a report, an upload) block every other tab of the same user. Call
session_write_close() as soon as a request has finished changing the session; $_SESSION stays readable
afterwards, and further writes are silently lost. session_start(['read_and_close' => true]) reads the session
without keeping the lock at all. Redis and database handlers lock differently, or not at all; Laravel’s database and
Redis session drivers do not lock, which removes the waiting but brings back the lost-update risk.
Storing sessions elsewhere
Files in a local directory work for one server. With several servers behind a load balancer, every server must see
the same sessions: use Redis (the phpredis extension provides a redis save handler), Memcached or the database,
through session.save_handler or a class implementing SessionHandlerInterface. This one stores sessions in any PDO
database and supports strict mode through validateId():
<?php
declare(strict_types=1);
final class PdoSessionHandler implements SessionHandlerInterface, SessionUpdateTimestampHandlerInterface
{
public function __construct(private readonly PDO $pdo) {}
public function open(string $path, string $name): bool { return true; }
public function close(): bool { return true; }
public function read(string $id): string
{
$stmt = $this->pdo->prepare('SELECT data FROM sessions WHERE id = ? AND expires_at > ?');
$stmt->execute([$id, time()]);
return (string) $stmt->fetchColumn(); // '' for a new or expired session
}
public function write(string $id, string $data): bool
{
$stmt = $this->pdo->prepare(
'INSERT INTO sessions (id, data, expires_at) VALUES (:id, :data, :exp)
ON CONFLICT (id) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at');
return $stmt->execute(['id' => $id, 'data' => $data, 'exp' => time() + (int) ini_get('session.gc_maxlifetime')]);
}
public function destroy(string $id): bool
{
return $this->pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
}
public function gc(int $max_lifetime): int|false
{
return $this->pdo->exec('DELETE FROM sessions WHERE expires_at <= ' . time());
}
// Strict mode: only IDs that exist in storage are accepted
public function validateId(string $id): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM sessions WHERE id = ? AND expires_at > ?');
$stmt->execute([$id, time()]);
return (bool) $stmt->fetchColumn();
}
// Called instead of write() when lazy_write finds the data unchanged
public function updateTimestamp(string $id, string $data): bool
{
return $this->pdo->prepare('UPDATE sessions SET expires_at = ? WHERE id = ?')
->execute([time() + (int) ini_get('session.gc_maxlifetime'), $id]);
}
}
<?php
declare(strict_types=1);
require __DIR__ . '/PdoSessionHandler.php';
$pdo = new PDO('sqlite:/tmp/sessions.sqlite', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL)');
session_set_save_handler(new PdoSessionHandler($pdo), true); // true: write and close at shutdown
if (isset($argv[1])) { session_id($argv[1]); }
session_start(['use_strict_mode' => true, 'use_cookies' => false]);
$_SESSION['visits'] = ($_SESSION['visits'] ?? 0) + 1;
printf("id %s, visits %d\n", session_id(), $_SESSION['visits']);
$ php demo.php # first run: id 94a03ba56dc0f26d26af401e11f7871e, visits 1
$ php demo.php 94a03ba56dc0f26d26af401e11f7871e
id 94a03ba56dc0f26d26af401e11f7871e, visits 2
$ php demo.php 94a03ba56dc0f26d26af401e11f7871e
id 94a03ba56dc0f26d26af401e11f7871e, visits 3
$ php demo.php madeupid123456 # unknown ID: strict mode issues a new one
id 9f3c0f9647fe28988c289efa07d0b54e, visits 1
$ sqlite3 /tmp/sessions.sqlite 'SELECT id, data FROM sessions'
94a03ba56dc0f26d26af401e11f7871e|visits|i:3;
9f3c0f9647fe28988c289efa07d0b54e|visits|i:1;
Source: SessionHandlerInterface, SessionUpdateTimestampHandlerInterface and session_set_save_handler
The stored data (visits|i:3;) is PHP’s session serialisation format, not serialize() output; set
session.serialize_handler = php_serialize to store standard serialize() strings instead. session_encode() returns
the current $_SESSION in that format and session_decode() loads it back, which helps when inspecting a stored
session (with visits set to 3, session_encode() printed visits|i:3;). This handler does not
lock; add row locking (SELECT ... FOR UPDATE in a transaction on MySQL or PostgreSQL) if lost updates matter.
Flash messages
A flash message ("Review saved", "Wrong password") is shown once, on the page after a redirect. By hand it is a
session key that is read and removed in the same step, as /books does in the first example: the second request
shows flash: -. Laravel provides session()->flash(), with() on redirects and old input.
Cookies
A cookie is a name and value the server asks the browser to store and send back on later requests to the same site.
setcookie() (and setrawcookie(), which skips URL-encoding) takes an options array since PHP 7.3:
<?php
declare(strict_types=1);
setcookie('theme', 'dark', [
'expires' => time() + 60 * 60 * 24 * 365, // or 0 for a browser-session cookie
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // invisible to JavaScript
'samesite' => 'Lax', // Strict, Lax or None (None requires secure)
'partitioned' => true, // CHIPS: keyed by the top-level site (PHP 8.5)
]);
Set-Cookie: theme=dark; expires=Tue, 05 Oct 2027 19:05:16 GMT; Max-Age=31536000; path=/; secure; HttpOnly; SameSite=Lax; Partitioned
The attributes:
-
SameSitedecides whether the browser sends the cookie on requests that come from another site:Strictnever,Laxonly for top-level GET navigations (following a link),Nonealways (and thenSecureis mandatory). Chromium-based browsers treat a cookie without the attribute asLax; set it explicitly, because other browsers do not.Laxis the right value for most session cookies. -
Partitioned(PHP 8.5, forsetcookie(),setrawcookie(),session_set_cookie_params()andsession_start()) marks a third-party cookie that the browser stores separately for each top-level site that embeds yours, as browsers phase out unpartitioned third-party cookies. It needsSecure. -
Domain: leave it out unless subdomains must share the cookie; setting it widens the scope.
Cookies are client-side data: the user can read, change, delete and invent them. Never store authorization
decisions, prices or user IDs in a plain cookie; store them in the session, or sign the cookie with an HMAC
(hash_hmac() plus hash_equals() to verify), as Laravel does by encrypting all its cookies. A cookie set with
setcookie() appears in $_COOKIE only on the next request. Cookies are headers, so the "headers already sent"
rule applies (see HTTP Requests and Responses).
Logging out
session_destroy() deletes the stored data but leaves $_SESSION filled for the rest of the request and the cookie in
the browser. A complete logout empties $_SESSION, destroys the storage, and expires the cookie, as /logout does in
the first example; then redirect to a public page. Regenerate the ID at logout as well when the session continues for
an anonymous user.
Where older books and tutorials are outdated
-
session_regenerate_id()withouttrueleaves the old session usable; passtrueso the old data is deleted. The fixation and hijacking explanations in Beak ch. 6 and Nixon ch. 12 still hold. -
Session IDs in URLs (URL rewriting with
use_trans_sidandSID, discussed in Kromann ch. 17) are on the way out: changingsession.use_only_cookiesorsession.use_trans_sid, and theSIDconstant, are deprecated since PHP 8.4. Use cookies only. -
Hardening by hand (
use_strict_mode,cookie_httponly,SameSite) is still needed on PHP 8.5, but becomes the default in the upcoming PHP 8.6. -
setcookie()with seven positional arguments has been replaced by the options array (7.3), which is the only way to setSameSiteand, since 8.5,Partitioned. -
Binding sessions to the IP address breaks mobile users; rely on HTTPS,
HttpOnly, regeneration and timeouts instead.
References
-
PHP Manual — Session Handling: runtime configuration, session management basics, securing INI settings
-
session_start, session_regenerate_id, session_write_close, session_set_save_handler
-
SessionHandlerInterface and SessionUpdateTimestampHandlerInterface
-
RFC: Session security defaults (PHP 8.6) and RFC: CHIPS (PHP 8.5)
-
Robin Nixon — Learning PHP, MySQL & JavaScript, 5th ed. (O’Reilly, 2018) and Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018)