Skip to main content
    Courses/PHP/Advanced Sessions

    Lesson 26 • Advanced

    Advanced Sessions 📦

    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.

    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().

    A database-backed session handler
    <?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";
    Real PHP using SessionHandlerInterface. It needs a running server and a sessions(id, payload, expires_at) table, so run it with php -S localhost:8000 against your database.

    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().

    Point PHP at Redis and tune the cookie
    <?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";
    The Redis lines are php.ini config; the ini_set calls work at runtime. Run it with php -S localhost:8000 to see a real session id printed.

    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.

    Release the lock before 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";
    Output
    Lock released — slow work below no longer blocks other tabs.
    Finished the slow part without holding the session lock.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Regenerate the id and fingerprint the session
    <?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";
    Uses $_SESSION and $_SERVER, so run it on a server with php -S localhost:8000 and hit it from a browser to populate the request data.

    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.

    🎯 Your turn: harden a login
    <?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.
    ?>
    Output
    Secured login for user 7.
    Fill the four ___ blanks (three booleans plus a SameSite string), then run it with php -S localhost:8000. The page should print the secured-login line.

    One more — the locking pattern in practice. Add the call that releases the lock before the slow work, following the 👉 hint.

    🎯 Your turn: release the lock early
    <?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.
    ?>
    Output
    Did slow work without blocking other requests.
    Replace the ___ with session_write_close(); so the lock is gone before the slow part. Run it with php -S localhost:8000.

    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 / SettingExampleWhat It Does
    SessionHandlerInterfaceimplements …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)

    Frequently Asked Questions

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    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.

    🎯 Mini-Challenge: sign and verify a token
    <?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
    ?>
    Implement base64url, sign, and verify (using hash_equals), then prove a tampered token fails. Output should be two lines: true then false.

    🎉 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()
    • Next lesson: Caching Techniques — speed up your app with OPcache, Redis, and file caching

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service