Appendix: Sessions Reference

The contracts behind Sessions & CSRF: expiry, cookie name prefixes, how a presented cookie id is verified, how a CSRF check interacts with the session lifecycle, when writes reach the store, and what a custom store must honour. For the task-first path — drivers, middleware, forms, JavaScript clients and cookie settings — see Sessions & CSRF.

Expiry and lifetime

A session is live only while its expiry is strictly in the futureexpires_at > now for sql, the identical boundary for file’s own expiresAt. A session expiring at exactly the current second is already expired on both. redis has no boundary of its own to state: expiry is the key’s TTL.

An expired file session is deleted the next time it is read; files for sessions never touched again stay until session:gc sweeps them. An expired sql session is invisible to reads, and its row stays until session:gc deletes it.

SESSION_LIFETIME is checked the same way regardless of driver — it must be a positive number of seconds, and adding it to the current Unix timestamp must stay inside PHP’s integer range. Every driver applies both, redis included, even though it never computes an absolute timestamp of its own. The value is checked at middleware construction, before the handler runs, so a misconfigured value never lets a request perform real work only to fail when the session is written. SESSION_SAMESITE and SESSION_COOKIE are checked at the same point.

The sql driver’s migration stubs use DATETIME on MySQL and TIMESTAMP (without time zone) on Postgres, never MySQL’s own TIMESTAMP type or Postgres’s TIMESTAMPTZ. The store binds a bare Y-m-d H:i:s UTC wall-clock string with no embedded offset, which a timezone-naive column stores exactly as given. MySQL’s TIMESTAMP reinterprets a bound value through the connection’s session timezone, so the same literal can land as a different absolute instant. Choosing a timezone-naive column keeps a shared connection’s session timezone out of when a session expires; the package never changes that setting itself.

The sql driver uses the link kinetis/database-bridge binds, or a MysqlLink/PostgresLink your own bootstrap.php binds. The redis driver requires the application’s CacheInterface binding to be RedisSimpleCache, which AppScope::boot() registers when Redis is configured. Both name what is missing when they cannot find it.

The CSRF check

CsrfMiddleware passes GET, HEAD and OPTIONS untouched. For every other method it takes the X-CSRF-Token header when it is non-empty, otherwise a string _token member of getParsedBody(), which only form-encoded and multipart bodies populate. With no Session registered on the request — SessionMiddleware missing or declared after it — it answers 500 naming the declaration-order mistake. A missing or mismatched token answers 403 with the body {"error": "CSRF token mismatch."}.

The comparison goes through Session::verifyCsrfToken(), which is constant-time via hash_equals() and never generates a token. A session that never had csrfToken() called holds no token, so every submitted value fails. Checking a submitted token, right or wrong, is therefore never what creates one: an attacker sending unlimited wrong tokens against cookies the store has never heard of cannot force one stored session per request.

A mismatch against an existing session leaves it untouched — not just unwritten, but its flash data, TTL and cookie exactly as they were, because checking the token alone never runs the per-request flash aging a real access would. A matching token enters the session’s normal lifecycle the moment it is confirmed, flash aging included, reusing the store read that verified it: a route that does nothing but pass the CSRF check still ages pending flash data correctly.

regenerate() discards the token along with the old id, and the next csrfToken() call mints a different one.

When writes reach the store

regenerate() and destroy() change only in-memory state. The store is mutated afterward, when SessionMiddleware calls commit(), which happens only once the handler has returned. If a controller or later middleware throws instead, neither the store nor the browser’s cookie is touched, so the session the request started with is exactly as usable as if neither call had been made.

A regenerated id’s replacement data is written before the old id is destroyed, so a store failure partway through commit() never loses a session that was still recoverable. A destroyed session’s data is removed before the response expires the cookie. Every successful write sends a fresh Set-Cookie, even when the id is unchanged, so the cookie’s Max-Age and the store’s expiry count from the same write.

Concurrency and terminal writes

No store locks. PHP’s native session handler locks the session file, serializing a browser’s parallel requests against each other; that would conflict with the concurrent-worker model the framework is built around. Concurrent requests sharing one session are last-write-wins.

Removing a stored id is the exception, because it is terminal. A request that read a session and writes it back under the same id succeeds only while that id is still stored and still live: once destroy() or regenerate() has committed, the id is gone, the overlapping request’s write is refused, its commit discarded, and no Set-Cookie sent. That is what stops a stale write from recreating a logged-out or pre-rotation id. Every store enforces it: sql with a conditional UPDATE, redis with a single SET ... XX, file by opening the existing record rather than publishing a new one.

The guarantee covers exactly that case. A request that itself calls regenerate() writes under a new id, which creation always allows, so a rotation running alongside a logout is not coordinated with it and can carry the session forward under the new id. Call regenerate() from the request that changes privilege — the login or elevation itself.

“Last-write-wins” is not “a reader might see a half-written file” for a newly created session: the file store writes to a temporary file in the same directory and renames it into place, so a concurrent read sees either the complete previous state or the complete new one. That temporary file is named .sess-tmp-*, outside gc()’s own sess_* glob pattern, so a sweep cannot collect a creation in progress. An update to an existing file is written in place, because a rename would recreate a record another request may have just removed. A read overlapping such an update can therefore land on an incomplete envelope, which reads as an absent session: it fails closed, exposing nothing.

File store permissions

The file store’s session directory must have no group or world permissions at all — checked against the directory’s real, current mode on every construction, so an existing, externally provisioned directory is refused rather than silently narrowed; the store never changes the permissions of a directory it did not create itself. Every session file’s resulting mode is verified as private (0600) before it becomes the live session — a chmod() reporting success is not trusted on its own, since the file’s actual permissions are read back and compared. Either check failing, like every other write failure, cleans up the temporary file and throws rather than publishing something that was never confirmed private.

Custom stores

SessionStoreInterface is four methods — read, create, update, destroy — and anything implementing it can be bound in bootstrap.php to replace what SESSION_DRIVER would have picked:

bootstrap.php
use Kinetis\Config\Config;
use Kinetis\Container\AppScope;
use Kinetis\Session\SessionStoreInterface;

return static function (AppScope $app, Config $config): void {
    $app->bind(SessionStoreInterface::class, static fn (): MyStore => new MyStore($config));
};

read() returns null for a session that does not exist or has expired. update() must return false when the id has no live record left, so a custom store honours the terminal rule above; throw only for a real storage or encoding failure. A store that keeps expired records joins session:gc by also implementing GarbageCollectableStoreInterface — one method, gc(): int, returning how many sessions it removed.

See also