Advanced Sessions
Reviewed & published by Brayan K
By the end of this lesson you'll move sessions out of single-server files and into shared storage with a custom handler, tune and secure the session cookie, avoid the locking trap that slows AJAX pages, defend against fixation and hijacking, and weigh stateless JWTs against server-side sessions.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Explain why file-based sessions break behind a load balancer
- Write a custom SessionHandlerInterface storing sessions in a database or Redis
- Configure sessions with session.cookie_* and garbage-collection settings
- Release the session lock early with the read-then-write-close pattern
- Stop session fixation and hijacking with session_regenerate_id() and fingerprints
- Decide when stateless JWTs beat server-side sessions — and the trade-offs
1️⃣ Custom Session Handlers (scaling across servers)
PHP's default sessions are files saved on the one server that handled the request. The moment you run a second server behind a load balancer — a router that spreads traffic across machines — the next request can hit a different box where that file doesn't exist, and the user looks logged out. The fix is to store sessions somewhere every server can reach. You do that by implementing SessionHandlerInterface: a class with six methods PHP calls for you (open, close, read, write, destroy, gc), then registering it with session_set_save_handler() before session_start().
<?php
// PHP's default sessions are plain files in /tmp on ONE server. Add a second
// server behind a load balancer and the file is missing — the user looks logged
// out. The fix: store sessions in a place EVERY server can reach (a database or
// Redis). You teach PHP to do that with a custom "save handler".
// A handler is just a class implementing SessionHandlerInterface. PHP calls
// these six methods for you at the right moments — you never call them directly.
class DatabaseSessionHandler implements SessionHandlerInterface
{
public function __construct(private PDO $pdo) {}
// open(): called by session_start(). Return true if storage is ready.
public function open(string $path, string $name): bool { return true; }
// close(): called when the request ends. Free resources, return true.
public function close(): bool { return true; }
// read(): PHP asks for the saved data for this session id.
public function read(string $id): string
{
$stmt = $this->pdo->prepare(
'SELECT payload FROM sessions WHERE id = ? AND expires_at > NOW()'
);
$stmt->execute([$id]);
return (string) $stmt->fetchColumn(); // '' means "no session yet"
}
// write(): PHP hands back the data to save (an opaque serialized string).
public function write(string $id, string $data): bool
{
// "Upsert" = insert a new row, or update it if the id already exists.
$stmt = $this->pdo->prepare(
'INSERT INTO sessions (id, payload, expires_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 MINUTE))
ON DUPLICATE KEY UPDATE
payload = VALUES(payload),
expires_at = VALUES(expires_at)'
);
return $stmt->execute([$id, $data]);
}
// destroy(): called by session_destroy(). Delete this session's row.
public function destroy(string $id): bool
{
return $this->pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
}
// gc(): garbage collection — PHP runs this occasionally to bin old rows.
public function gc(int $maxLifetime): int|false
{
$stmt = $this->pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
$stmt->execute();
return $stmt->rowCount(); // how many expired rows were removed
}
}
$pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
// Register the handler, THEN start the session. The 'true' registers a
// shutdown function so write()/close() always run at the end of the request.
session_set_save_handler(new DatabaseSessionHandler($pdo), true);
session_start(); // from here, $_SESSION reads/writes go through the database
$_SESSION['user_id'] = 42;
$_SESSION['role'] = 'admin';
echo "Stored session for user {$_SESSION['user_id']} in the database.\n";From your code's point of view nothing changes — you still read and write $_SESSION exactly as before. PHP simply routes the storage through your handler. The gc() method is your cleanup: PHP calls it occasionally to delete expired rows so the table doesn't grow forever.
2️⃣ Redis Sessions & Configuration
Redis is an in-memory data store, which makes it the fastest session backend. The best part: PHP ships with a Redis session handler, so you usually write no code at all — you just point session.save_handler and session.save_path at your Redis server. Beyond the backend, a handful of session.* settings control how long data lives (gc_maxlifetime) and how the cookie behaves (cookie_lifetime, cookie_httponly, cookie_secure, cookie_samesite). Set them before session_start().
<?php
// For Redis you usually write NO handler at all — it ships with PHP. You point
// PHP at Redis in php.ini (or with ini_set before session_start()):
//
// session.save_handler = redis
// session.save_path = "tcp://127.0.0.1:6379?auth=secret"
//
// Redis is in-memory (so it's the fastest option), shared across all your
// servers, and it expires keys automatically — perfect for sessions.
// Tune session behaviour with ini settings. Set these BEFORE session_start().
ini_set('session.gc_maxlifetime', '1800'); // server keeps data for 30 minutes
ini_set('session.cookie_lifetime', '0'); // cookie dies when browser closes
ini_set('session.use_strict_mode', '1'); // reject session ids PHP didn't issue
ini_set('session.cookie_httponly', '1'); // JavaScript cannot read the cookie
ini_set('session.cookie_secure', '1'); // cookie only sent over HTTPS
ini_set('session.cookie_samesite', 'Lax'); // helps block cross-site (CSRF) abuse
session_start();
echo "Session id: " . session_id() . "\n"; // the value stored in the cookie
echo "Storage backend: " . ini_get('session.save_handler') . "\n";3️⃣ Session Locking & the Read-then-Write-Close Pattern
Here's a trap that surprises everyone. While a request holds the session open, PHP locks it so two requests can't clobber the data by writing at once. Any other request from the same user waits until the first one finishes or calls session_write_close(). On a page firing several AJAX calls together, they quietly run one-after-another instead of in parallel — and it just feels slow. The cure is the read-then-write-close pattern: do all your $_SESSION writes up front, call session_write_close() to release the lock, then do the slow work.
<?php
// Sessions LOCK. While one request holds an open session, PHP makes other
// requests from the SAME user WAIT until it calls session_write_close(). With
// AJAX-heavy pages this serialises requests and feels sluggish.
session_start();
$_SESSION['last_seen'] = time(); // do all your writes up front...
// ...then close the session AS SOON as you're done with it. This releases the
// lock so other requests for this user can proceed in parallel. $_SESSION stays
// readable in memory; you just can't write to storage after this point.
session_write_close();
echo "Lock released — slow work below no longer blocks other tabs.\n";
// Imagine a slow API call or report here. Because the lock is already gone,
// a second request (e.g. an autosave ping) doesn't queue behind it.
$report = "...expensive work that takes a few seconds...";
echo "Finished the slow part without holding the session lock.\n";After session_write_close() you can still read $_SESSION from memory — you just can't save changes to storage. So write first, close, then read freely while the heavy lifting happens unblocked.
4️⃣ Session Security: Fixation & Hijacking
Two attacks target sessions directly. In fixation, an attacker plants a session id they already know on a victim (say, via a link); when the victim logs in, the attacker shares that authenticated session. The single defence is to issue a fresh id the moment privileges change — session_regenerate_id(true) right after login, where true deletes the old session. In hijacking, an attacker steals a valid session id (often via XSS or an insecure cookie). You raise the bar by binding the session to a fingerprint — the client's IP and user-agent — and dropping it if that fingerprint suddenly changes.
<?php
// SESSION FIXATION: an attacker plants a known session id on a victim (e.g. via
// a link), the victim logs in, and the attacker now shares their logged-in
// session. Defence: give the user a BRAND-NEW id the moment privileges change.
session_start();
function login(int $userId): void
{
// The single most important line in session security:
session_regenerate_id(true); // new id; 'true' deletes the OLD session
$_SESSION['user_id'] = $userId;
$_SESSION['logged_in'] = true;
$_SESSION['ip'] = $_SERVER['REMOTE_ADDR'] ?? ''; // bind to a fingerprint
$_SESSION['ua_hash'] = hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
}
function logout(): void
{
$_SESSION = []; // clear the data in memory
session_destroy(); // delete it from storage
setcookie(session_name(), '', time() - 3600, '/'); // expire the cookie too
}
// HIJACKING check: if the request's fingerprint no longer matches, drop it.
function isHijacked(): bool
{
$ipChanged = ($_SESSION['ip'] ?? '') !== ($_SERVER['REMOTE_ADDR'] ?? '');
$uaChanged = ($_SESSION['ua_hash'] ?? '')
!== hash('sha256', $_SERVER['HTTP_USER_AGENT'] ?? '');
return $ipChanged || $uaChanged;
}
login(42);
echo "Logged in as user {$_SESSION['user_id']} with a fresh session id.\n";Pair this with the secure cookie flags from Section 2 (HttpOnly stops JavaScript reading the cookie, Secure keeps it on HTTPS, SameSite blunts CSRF) and most session attacks lose their footing.
5️⃣ Stateless Alternatives (JWT) & Trade-offs
There's a different model entirely: store nothing on the server. A JWT (JSON Web Token) is a small signed token the client carries in a cookie or header. Because it's signed with a secret using hash_hmac, the server can verify it wasn't tampered with — no database lookup needed. That scales effortlessly and suits APIs. The catch is real: you can't instantly revoke a JWT (it's valid until it expires), and stuffing data into it bloats every request. Server-side sessions are the mirror image — easy to revoke (delete the row), tiny cookie — but they need shared storage. Verify signatures with hash_equals(), which is timing-safe; never use ===.
Now you try. The login below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — harden a login. Fill in each blank marked ___ , then run it
// on your own server (php -S localhost:8000) and inspect the cookie flags.
// 1) Set secure cookie params BEFORE the session starts
session_set_cookie_params([
'lifetime' => 0,
'httponly' => ___, // 👉 true — block JavaScript from reading the cookie
'secure' => ___, // 👉 true — only send the cookie over HTTPS
'samesite' => ___, // 👉 'Lax' — mitigate cross-site (CSRF) requests
]);
session_start();
// 2) After checking the password, rotate the id to stop session fixation
session_regenerate_id(___); // 👉 true — also delete the old session
$_SESSION['user_id'] = 7;
echo "Secured login for user {$_SESSION['user_id']}.\n";
// ✅ Expected output:
// Secured login for user 7.
?>One more — the locking pattern in practice. Add the call that releases the lock before the slow work, following the 👉 hint.
<?php
// 🎯 YOUR TURN — an AJAX endpoint that does slow work. Fill in the blank so the
// session lock is released BEFORE the slow part, freeing other tabs.
session_start();
$_SESSION['pings'] = ($_SESSION['pings'] ?? 0) + 1; // record the visit
___; // 👉 session_write_close(); release the lock now
// Pretend this takes a few seconds — the lock is already gone, so a second
// request from the same user does NOT have to wait for it.
echo "Did slow work without blocking other requests.\n";
// ✅ Expected output:
// Did slow work without blocking other requests.
?>Common Errors (and the fix)
- Users get randomly logged out after a deploy or under load — file-based sessions don't scale. With two servers behind a load balancer the session file lives on only one of them. Switch to a shared backend: a database handler (SessionHandlerInterface) or Redis via session.save_handler = redis.
- AJAX-heavy pages feel slow even though each call is fast — session locking is serialising them. Each request waits for the previous one to release the session. Do your $_SESSION writes early, then call session_write_close() so the others run in parallel.
- "Warning: session_start(): Cannot send session cookie - headers already sent" — something (an echo, stray whitespace, or a byte-order mark before <?php) sent output first. Move session_start() (and all cookie config) to the very top, before any output.
- Account stays hijackable after login — you never rotated the id, so a fixed session id still works. Call session_regenerate_id(true) immediately after authenticating to defeat session fixation.
- The session cookie is stolen via a script or over HTTP — the cookie is missing its flags. Set httponly => true, secure => true, and samesite => 'Lax' in session_set_cookie_params() before session_start().
Pro Tips
- 💡 Turn on session.use_strict_mode. It makes PHP reject any session id it didn't issue — a free, important defence against fixation.
- 💡 Regenerate on every privilege change, not just login — for example when a user becomes an admin or after a password change.
- 💡 Keep JWTs short-lived and pair them with a refresh token. Because you can't revoke a live JWT, a short expiry is your main safety valve.
📋 Quick Reference — Advanced Sessions
| Tool / Setting | Example | What It Does |
|---|---|---|
| SessionHandlerInterface | implements … | Custom storage (DB/Redis) for scaling |
| session_set_save_handler() | (new Handler, true) | Register your handler before start |
| session_write_close() | session_write_close(); | Release the lock; unblock other requests |
| session_regenerate_id() | …_id(true) | New id after login (stops fixation) |
| session.cookie_httponly | '1' | Hide cookie from JavaScript |
| session.cookie_secure | '1' | Send cookie only over HTTPS |
| session.cookie_samesite | 'Lax' | Limit cross-site requests (CSRF) |
| hash_equals() | hash_equals($a, $b) | Timing-safe compare (JWT signatures) |
Mini-Challenge: A Tiny Signed Token
No code is filled in this time — just a brief and an outline. Build a minimal stateless token from scratch, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same sign-then-verify loop every JWT library does under the hood.
<?php
// 🎯 MINI-CHALLENGE: a tiny stateless "token" check (no server-side storage).
// No code is filled in — work from the steps below, then run it at
// onecompiler.com/php or on your own machine and compare with the expected output.
//
// 1. Write base64url($data): return rtrim(strtr(base64_encode($data),'+/','-_'),'=')
// 2. Write sign($payload, $secret):
// - $body = base64url(json_encode($payload))
// - $sig = base64url(hash_hmac('sha256', $body, $secret, true))
// - return "$body.$sig"
// 3. Write verify($token, $secret) returning true/false:
// - split $token on '.' into [$body, $sig]
// - recompute the expected signature from $body
// - compare with hash_equals($expected, $sig) // timing-safe, NOT ===
// 4. Make a token for ['user_id' => 1], verify it (true), then change one
// character in the token and verify again (false).
//
// ✅ Expected output:
// Original valid? true
// Tampered valid? false
// your code here
?>🎉 Lesson Complete!
- ✅ File sessions live on one server — a custom handler (SessionHandlerInterface) puts them in a shared database or Redis so they scale
- ✅ Configure storage and cookies with session.save_handler, gc_maxlifetime, and the session.cookie_* flags — always before session_start()
- ✅ Sessions lock; use session_write_close() early so AJAX requests don't queue
- ✅ Call session_regenerate_id(true) on login to stop fixation, and fingerprint the session to spot hijacking
- ✅ Set HttpOnly, Secure, and SameSite on the session cookie
- ✅ Stateless JWTs need no server storage and scale freely, but trade away instant revocation — verify them with hash_equals()
Practice quiz
Why do file-based sessions break behind a load balancer?
- Files are too slow to read
- PHP disables sessions on multiple servers
- Each session file lives on one server, so another server can't find it
- Load balancers delete session files
Answer: Each session file lives on one server, so another server can't find it. Default sessions are files on the machine that handled the request; a different server won't have that file, so the user looks logged out.
What do you implement to store sessions in a database or Redis?
- SessionHandlerInterface
- SessionStorageTrait
- PDOSessionAdapter
- SaveHandlerAbstract
Answer: SessionHandlerInterface. A custom save handler is a class implementing SessionHandlerInterface (open, close, read, write, destroy, gc).
When must session_set_save_handler() be called?
- After session_start()
- Inside the write() method
- Only on logout
- Before session_start()
Answer: Before session_start(). Register the handler first; then session_start() routes all $_SESSION reads and writes through it.
For Redis-backed sessions, how do you usually configure PHP?
- Write a full custom handler class
- Set session.save_handler = redis and session.save_path to the Redis URL
- Call redis_session_start()
- Redis cannot store PHP sessions
Answer: Set session.save_handler = redis and session.save_path to the Redis URL. PHP ships with a Redis session handler, so you point session.save_handler and session.save_path at Redis - no code needed.
Why can AJAX-heavy pages feel slow due to sessions?
- Session locking serialises requests from the same user until the lock releases
- Sessions encrypt every request
- AJAX disables caching
- Each request creates a new session id
Answer: Session locking serialises requests from the same user until the lock releases. PHP locks an open session, so concurrent requests from the same user wait in line until session_write_close() or the request ends.
Which call releases the session lock early so other requests proceed?
- session_destroy()
- session_unset()
- session_write_close()
- session_abort()
Answer: session_write_close(). Do your $_SESSION writes up front, then session_write_close() to free the lock before slow work; you can still read $_SESSION from memory.
What is session fixation?
- Storing too much data in the session
- An attacker plants a known session id on a victim who then logs in with it
- The session id never changing across requests by design
- A corrupted session file
Answer: An attacker plants a known session id on a victim who then logs in with it. If the victim authenticates using an id the attacker already knows, the attacker rides that authenticated session.
How do you defeat session fixation on login?
- Call session_destroy() before login
- Set a longer cookie lifetime
- Store the password in the session
- Call session_regenerate_id(true) so the old session is replaced and deleted
Answer: Call session_regenerate_id(true) so the old session is replaced and deleted. Regenerating the id the moment privileges change issues a fresh id and deletes the old one, so a planted id is useless.
What is a key trade-off of stateless JWTs versus server-side sessions?
- JWTs are always more secure
- You cannot instantly revoke a JWT; it stays valid until it expires
- JWTs require a shared database to scale
- Sessions cannot be revoked at all
Answer: You cannot instantly revoke a JWT; it stays valid until it expires. JWTs scale without server storage but can't be revoked on demand; sessions are easy to revoke (delete the row) but need shared storage.
Which function safely compares a token signature against the expected one?
- ===
- strcmp()
- hash_equals($expected, $sig)
- md5_compare()
Answer: hash_equals($expected, $sig). hash_equals() is timing-safe, so it doesn't leak information about the secret the way === can.
Continue this course
- Previous: Password Hashing, Argon2 & Secure Credential Storage
- Next: Caching Techniques: OPcache, Redis & File Caching — Speed up PHP apps with OPcache, Redis object caching, and cache layers
- Quick reference: PHP cheat sheet
Frequently asked questions
Why don't file-based sessions work across multiple servers?
By default PHP writes each session to a file in a temp directory on the machine that handled the request. Put two or more web servers behind a load balancer and the next request can land on a different machine, where that file does not exist — so the user appears logged out at random. The fix is to move sessions into shared storage every server can reach: a database via a custom SessionHandlerInterface, or Redis configured through session.save_handler. Sticky sessions (pinning a user to one server) are a fragile band-aid because that server can still restart or fail.
What is session locking and why does it make AJAX pages slow?
While a request has the session open, PHP locks it so two requests cannot corrupt the data by writing at once. Any other request from the same user blocks until the first one calls session_write_close() (or finishes). On a page firing several AJAX calls at the same time, they end up running one after another instead of in parallel — it feels sluggish for no obvious reason. The cure is the read-then-write-close pattern: do your $_SESSION writes early, call session_write_close() to release the lock, then do the slow work.
What is session fixation and how does session_regenerate_id() stop it?
In a fixation attack, the attacker gets the victim to use a session id the attacker already knows (for example via a crafted link). When the victim logs in, the attacker's known id is now an authenticated session they can ride. Calling session_regenerate_id(true) the moment privileges change — right after a successful login — issues a brand-new id and deletes the old one, so any id the attacker planted becomes worthless. Always regenerate on login and on any privilege escalation.
Should I use JWT tokens instead of PHP sessions?
It depends on the trade-off you want. JWTs are stateless: the signed token lives in the client and the server stores nothing, which scales effortlessly and works well for APIs and across services. The cost is that you cannot instantly revoke a token — it stays valid until it expires — and putting too much data in it bloats every request. Server-side sessions are the opposite: trivial to revoke (just delete the row) and the cookie stays tiny, but they need shared storage to scale. Many apps use sessions for browser logins and short-lived JWTs for service-to-service or mobile APIs.
Which cookie flags should every session cookie have?
Set HttpOnly so JavaScript cannot read the cookie (this blocks most XSS-based session theft), Secure so it is only sent over HTTPS, and SameSite=Lax (or Strict) to limit cross-site requests that drive CSRF. Configure them with session_set_cookie_params() or the session.cookie_* ini settings before session_start(). Also turn on session.use_strict_mode so PHP rejects session ids it never issued.