Sessions — Locking, Custom Handlers, and Stateless Alternatives

Understand how PHP's default file-based session handler locks a session for the duration of a script, how to release that lock early with session_write_close(), how to implement a database-backed session handler via SessionHandlerInterface, how stateless token-based auth compares to server-side sessions, and how to configure session cookies securely.

The earlier Sessions lesson covered the fundamentals -- session_start(), where session data actually lives, session_regenerate_id() against fixation, hijacking mitigations, logout/expiration, and moving storage to Redis. This second Sessions lesson goes deeper into three things that trip up real applications once they scale past a toy example: why concurrent requests from the SAME user can mysteriously queue up behind each other, how to build a fully custom session handler when Redis isn't part of your infrastructure, and how the stateless token-based alternative (JWT) compares once you understand what sessions are actually trading away for their simplicity. It closes with a focused recap of secure cookie settings specifically as they apply to the session cookie.

Session Locking: Why Two Tabs Can Feel Slow

PHP's default file-based session handler does something easy to miss: session_start() doesn't just read the session file, it acquires an EXCLUSIVE LOCK on it, and holds that lock for the rest of the script's execution (or until session_write_close() is called), releasing it only when the script ends. This is deliberate -- it prevents two concurrent requests from racing to write $_SESSION and one silently clobbering the other's changes, a classic lost-update bug.

The cost of that safety is real, though: two concurrent requests carrying the SAME session cookie do not run in parallel against that session. If a user opens a page that fires several AJAX calls at once, or simply has two tabs open, the second request blocks at session_start() until the first one finishes -- and if that first request happens to be slow (calling a slow third-party API, generating a large report), every sibling request queues up behind it purely because of lock contention, not because there's any actual conflicting work happening.

• session_start() acquires an exclusive lock on the session file for the DEFAULT file-based handler. • The lock is held until the script ends or session_write_close() is called, whichever comes first. • This prevents lost-update races between concurrent requests writing $_SESSION -- but serializes them in the process. • One slow request (e.g. a slow external API call) blocks every OTHER request sharing that session, not just itself.

Releasing the Lock Early: session_write_close()

The fix is simple once you know it exists: read and write everything you need from $_SESSION as early as possible in the request, then call session_write_close() before doing any slow, non-session work -- an external API call, generating a large file, a long-running computation. session_write_close() persists the current $_SESSION contents and releases the file lock immediately, letting sibling requests proceed instead of waiting.

There's one important caveat: after session_write_close(), any further writes to $_SESSION in the same script are NOT automatically persisted -- the session is effectively closed. If a later part of the same script legitimately needs to write session data again, it must call session_start() again to reacquire the session (and its lock) explicitly.

• Call session_write_close() as soon as you're done reading/writing session data for this request. • Writes to $_SESSION after write_close are silently lost unless you call session_start() again. • Matters most for: multiple concurrent AJAX calls per page, long-polling/SSE endpoints, and any handler that makes a slow outbound call after touching the session.

Database-Backed Sessions via SessionHandlerInterface

The earlier Sessions lesson's move to Redis was purely a configuration change (session.save_handler = redis) -- the storage swapped out, but PHP's built-in Redis handler still did the work. SessionHandlerInterface lets you go further: write the storage backend yourself, entirely in PHP, which is useful when session data needs to live in your primary relational database -- to join it with other tables, or simply because Redis isn't part of your infrastructure at all.

The interface has six methods: open() (called at session_start(), typically a no-op if your connection is already available), close() (called at the end, also often a no-op), read($id) (return the stored serialized data for a session ID, or an empty string if none exists), write($id, $data) (persist the given serialized data under that ID), destroy($id) (delete a session, called by session_destroy()), and gc($max_lifetime) (garbage-collect expired sessions, returning the number removed as of PHP 8.0+, or false on failure).

One thing is easy to overlook when rolling your own handler: the file-based default gives you the locking behavior from section 1 for free, purely as a side effect of how filesystem locks work. A naive read()/write() pair against a database table does NOT automatically replicate that guarantee -- without deliberate locking (e.g. a SELECT ... FOR UPDATE inside the same transaction as the eventual write, or an equivalent), a custom handler can reintroduce the exact lost-update race the file handler was quietly preventing.

• SessionHandlerInterface: open(), close(), read($id), write($id, $data), destroy($id), gc($max_lifetime). • Register with session_set_save_handler($handler, true) BEFORE calling session_start(). • Useful when session data must live in your primary relational database rather than Redis/Memcached. • The file handler's locking is a free side effect of filesystem locks -- a custom handler must re-implement equivalent locking deliberately, or it can reintroduce lost-update races.

Stateless Authentication: JWT/Token-Based Alternatives

Every server-side session model so far -- file, Redis, custom database handler -- shares one property: the session ID cookie is just a pointer, and the actual data lives in shared storage the server has to look up on every request. A JWT (JSON Web Token) inverts this: the claims themselves (user ID, roles, expiry) are packed directly into a signed token the client holds and sends with every request (typically an Authorization: Bearer <token> header), and the server verifies the signature and reads the claims WITHOUT looking anything up in shared storage at all. This statelessness is attractive for horizontally-scaled APIs with no shared session store, and for mobile/SPA clients that aren't naturally cookie-based.

The tradeoffs are real, though. There's no built-in revocation: a stolen or 'logged out' JWT remains cryptographically valid until it expires, because the server never checks anything beyond the signature -- the usual mitigations are short expiry windows paired with a separate refresh token, or maintaining a server-side denylist of revoked tokens, which reintroduces the exact shared-state lookup that sessions already had. Where the client stores the token matters a lot too: localStorage is readable by any successful XSS payload, so many security-conscious apps put the JWT in an HttpOnly cookie instead -- at which point it inherits most of a session cookie's properties anyway, including CSRF considerations.

Practical guidance: for a traditional server-rendered PHP application talking to one kind of client (a browser), server-side sessions remain simpler and, with the cookie hardening in the final section, at least as safe. For a genuinely stateless API consumed by multiple client types (a mobile app and a web SPA sharing one backend), token-based auth is often the better architectural fit -- and the two are not mutually exclusive within one application.

• Session: server holds the data; the client holds only an opaque ID (the cookie) pointing at it. • JWT: the client holds the actual claims, signed; the server verifies the signature with no storage lookup. • No built-in revocation for JWT -- mitigated with short expiry + refresh tokens, or a denylist (which reintroduces shared state). • Storing a JWT in localStorage exposes it to XSS; storing it in an HttpOnly cookie is safer but reintroduces session-cookie-like properties.

Secure Cookie Settings for Session Cookies Specifically

Zooming in on the session cookie itself: Secure prevents the browser from ever sending PHPSESSID over plain HTTP, so it can't be sniffed on an unencrypted connection. HttpOnly blocks JavaScript -- including a successful XSS payload's JavaScript -- from reading the session ID via document.cookie, which is one of the most effective single mitigations against session theft. SameSite controls whether the cookie is attached to cross-site requests at all: Lax (PHP's practical default for most apps) still sends the cookie on top-level navigation (clicking a link into your site) but withholds it on cross-site subresource loads and form POSTs, which blunts most CSRF; Strict is stronger still but can log a user out of an in-progress session the first time they arrive via an external link; None disables this protection entirely and requires Secure, reserved for cases like a legitimately cross-site embedded widget that needs the session cookie.

Alongside the Set-Cookie attributes, two php.ini/ini_set directives are worth calling out specifically because they only make sense in the session context: session.cookie_httponly/session.cookie_secure/session.cookie_samesite set the corresponding cookie attributes automatically on every Set-Cookie PHP emits for the session, and session.use_strict_mode rejects any session ID a client supplies that the SERVER never actually generated -- closing off a specific flavor of session fixation where an attacker simply invents an ID and tricks a victim's browser into using it.

• Secure: never sent over plain HTTP. • HttpOnly: not readable via document.cookie, blunting session-ID theft through XSS specifically. • SameSite=Lax: sent on top-level navigation, withheld on cross-site subresource/POST requests -- PHP's practical default for most apps. • session.use_strict_mode: rejects client-supplied session IDs the server never generated, closing a session-fixation avenue.

Code Examples

session_write_close() persists $_SESSION and releases the lock BEFORE the slow API call runs, so any other request from the same browser/session proceeds immediately instead of queueing behind this one purely due to lock contention.

Common mistake: Calling session_start() and then performing a slow operation before the script ends -- every other request from the same session queues up behind this one, waiting on a lock, not on any actual conflicting work.

Each SessionHandlerInterface method maps directly onto one moment in the session lifecycle: read()/write() on every session_start()/script-end pair, destroy() on session_destroy(), and gc() periodically for cleanup. session_set_save_handler()'s second argument (true) registers a shutdown function so the handler is still properly closed and flushed even if the script exits unexpectedly. This implementation is simplified for clarity and does not add explicit row-level locking -- a production-grade PDO session handler (Symfony's, for example) uses SELECT ... FOR UPDATE or an equivalent mechanism to preserve the same serialization guarantee the file-based handler gives for free, as discussed in this lesson's section on custom handlers.

Verification here is pure computation (an HMAC comparison) with zero storage lookups, in direct contrast to a session lookup -- but notice there is also no way to invalidate an already-issued token short of waiting for exp to pass, which is exactly the revocation tradeoff described in this lesson's stateless-auth section.

Common mistake: Rolling your own JWT parsing/verification in production instead of using a maintained library -- subtle bugs here (accepting an 'alg: none' header, or comparing signatures with == instead of hash_equals()) are a well-documented source of real authentication bypasses.

All four directives must be set before session_start() is called, since that's the moment PHP actually emits the Set-Cookie header -- setting them afterward has no effect on the cookie already sent.

<?php
session_start();

// Read what we need from the session immediately.
$userId = $_SESSION['user_id'] ?? null;
if ($userId === null) {
    http_response_code(401);
    exit;
}

// Done touching $_SESSION for this request -- release the lock now,
// so a second tab/AJAX call sharing this session isn't blocked
// behind this slow API call.
session_write_close();

$weather = fetchSlowThirdPartyWeatherApi(); // e.g. takes 2-3 seconds

echo json_encode(['user_id' => $userId, 'weather' => $weather]);
<?php
final class PdoSessionHandler implements SessionHandlerInterface
{
    public function __construct(private PDO $pdo) {}

    public function open(string $path, string $name): bool
    {
        return true; // connection already open -- nothing to do
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        $stmt = $this->pdo->prepare(
            'SELECT data FROM sessions WHERE id = :id AND expires_at > NOW()'
        );
        $stmt->execute(['id' => $id]);
        $data = $stmt->fetchColumn();

        return $data !== false ? $data : '';
    }

    public function write(string $id, string $data): bool
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO sessions (id, data, expires_at)
             VALUES (:id, :data, DATE_ADD(NOW(), INTERVAL 1800 SECOND))
             ON DUPLICATE KEY UPDATE data = :data2,
                 expires_at = DATE_ADD(NOW(), INTERVAL 1800 SECOND)'
        );

        return $stmt->execute(['id' => $id, 'data' => $data, 'data2' => $data]);
    }

    public function destroy(string $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM ses
…
<?php
// Illustrative only -- use a vetted library (e.g. firebase/php-jwt) in
// production. This shows the mechanics a library like that handles for you.

function issueToken(array $claims, string $secret): string
{
    $header    = base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $payload   = base64UrlEncode(json_encode($claims));
    $signature = base64UrlEncode(
        hash_hmac('sha256', "{$header}.{$payload}", $secret, true)
    );

    return "{$header}.{$payload}.{$signature}";
}

function verifyToken(string $token, string $secret): array|false
{
    [$header, $payload, $signature] = array_pad(explode('.', $token, 3), 3, '');
    $expected = base64UrlEncode(
        hash_hmac('sha256', "{$header}.{$payload}", $secret, true)
    );

    // hash_equals() avoids leaking timing information about the comparison.
    if (!hash_equals($expected, $signature)) {
        return false;
    }

    $claims = json_decode(base64UrlDecode($payload), true);
    if (($claims['exp'] ?? 0) < time()) {
        return false; // expired
    }

    return $claims;
}

function base64UrlEncode(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '='
…
<?php
// Set BEFORE session_start() -- these apply specifically to the session cookie.
ini_set('session.cookie_httponly', '1');   // JS (and XSS) can't read the cookie
ini_set('session.cookie_secure', '1');     // never sent over plain HTTP
ini_set('session.cookie_samesite', 'Lax'); // blunts CSRF, still allows top-level nav
ini_set('session.use_strict_mode', '1');   // reject client-supplied unknown session IDs

session_start();

Important Rules

Read/write $_SESSION as early as possible, then call session_write_close() before any slow non-session work, to avoid blocking sibling requests on lock contention.

A custom SessionHandlerInterface implementation must deliberately re-implement whatever locking guarantee you need -- the file handler's serialization is not automatic in a hand-rolled database handler.

Register a custom session handler with session_set_save_handler() BEFORE calling session_start().

Set session.cookie_httponly, session.cookie_secure, session.cookie_samesite, and session.use_strict_mode before session_start() -- they have no effect if set afterward.

Choose stateless (JWT) auth for genuinely stateless, multi-client-type APIs; choose server-side sessions for traditional browser-facing apps where simplicity and easy revocation matter more.

Where this shows up

Laravel ships a 'database' session driver that is, under the hood, exactly this SessionHandlerInterface pattern against a sessions table. Meanwhile frameworks like Laravel (via Sanctum) and Symfony (via API Platform) commonly run BOTH models side by side in the same application -- server-side sessions for the browser-facing web app, and stateless token auth for the API consumed by a mobile app or SPA -- rather than treating the two as mutually exclusive choices.

Holding the session open (never calling session_write_close()) while making a slow external API call inside a request.

Read/write the needed $_SESSION data first, then call session_write_close() before any slow non-session work, so sibling requests aren't serialized behind it purely by lock contention.

The file-based session handler's exclusive lock is meant to protect concurrent writes, not to serialize unrelated slow work -- keeping the session open for longer than necessary turns a safety mechanism into an accidental bottleneck for every other request from the same user.

Storing a JWT in localStorage/sessionStorage for convenience, on the assumption that 'it's just a token, not a cookie, so it's safer.'

Store it in an HttpOnly, Secure cookie (or use a server-side session instead) whenever XSS exposure of the token is a realistic risk, accepting that this reintroduces some session-cookie-like properties like CSRF considerations.

localStorage has no equivalent of HttpOnly -- any JavaScript that runs on the page, including a successful XSS payload's, can read it directly, which defeats one of the main protections a well-configured session cookie already provides for free.

[multiple-choice] What does PHP's default file-based session handler do at session_start() that causes concurrent requests from the same session to serialize? A. It deletes the session data and starts fresh on every request B. It acquires an exclusive lock on the session file, held until the script ends or session_write_close() is called C. It redirects the second request to a different server D. It has no locking behavior at all -- serialization only happens with Redis

B. It acquires an exclusive lock on the session file, held until the script ends or session_write_close() is called — session_start() locks the session file exclusively for the duration of the script (or until session_write_close()), which prevents lost-update races but means concurrent requests sharing a session queue up behind each other.

[multiple-choice] What happens if you write to $_SESSION after calling session_write_close(), without calling session_start() again? A. It's written and persisted normally, just without the lock B. PHP throws a fatal error immediately C. The write silently does not get persisted -- the session is effectively closed D. It queues the write until the script ends

C. The write silently does not get persisted -- the session is effectively closed — session_write_close() finalizes and persists the session; any further $_SESSION writes in the same script afterward are not saved unless session_start() is called again to reopen the session.

[multiple-choice] Which six methods make up SessionHandlerInterface? A. connect, disconnect, fetch, save, remove, expire B. open, close, read, write, destroy, gc C. start, stop, get, set, clear, cleanup D. init, shutdown, load, persist, delete, sweep

B. open, close, read, write, destroy, gc — SessionHandlerInterface requires open(), close(), read($id), write($id, $data), destroy($id), and gc($max_lifetime) -- PHP calls each at the corresponding point in the session lifecycle.

[multiple-choice] What is the main tradeoff of stateless JWT authentication compared to a server-side session? A. JWTs can never be verified without a database lookup B. There is no built-in revocation -- a stolen or logged-out token remains valid until it expires, since the server never checks shared storage C. JWTs are always stored more securely than session cookies D. JWTs cannot carry any user information at all

B. There is no built-in revocation -- a stolen or logged-out token remains valid until it expires, since the server never checks shared storage — Because JWT verification relies purely on the signature with no server-side storage lookup, there's no natural mechanism to revoke a specific token early -- mitigations (short expiry + refresh tokens, or a denylist) either limit the exposure window or reintroduce the shared-state lookup sessions already have.

[multiple-choice] What does SameSite=Lax specifically do for a session cookie? A. Prevents the cookie from ever being sent, even on the site's own pages B. Still sends the cookie on top-level navigation into the site, but withholds it on cross-site subresource requests and form POSTs, blunting most CSRF C. Encrypts the cookie's value D. Only applies to cookies set over HTTP, not HTTPS

B. Still sends the cookie on top-level navigation into the site, but withholds it on cross-site subresource requests and form POSTs, blunting most CSRF — SameSite=Lax is a practical middle ground: it still allows the cookie through when a user clicks a link into the site (top-level navigation), while withholding it on cross-site subresource loads and POSTs, which is where most CSRF attacks rely on the cookie being sent automatically.

A dashboard page fires three AJAX requests in parallel on load, all sharing the same PHPSESSID. One of the three calls a slow third-party analytics API. Users report the other two AJAX calls feel slow too, even though they don't touch that API at all. Diagnose why, and describe the fix.

session_start() acquires an exclusive lock on the session file, held until the script ends. The slow request holds that lock for the entire duration of its third-party API call, so the other two requests -- which also call session_start() on the same session -- block waiting for the lock, even though they have no actual data conflict with the slow request. Fix: in the slow request's handler, read/write $_SESSION first, call session_write_close() immediately afterward, and only then make the slow API call -- releasing the lock before the slow part begins.

All three requests call session_start() and none of them call session_write_close(). What does the default file-based session handler do at session_start()?