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 future —
expires_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:
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¶
Sessions & CSRF — the task-first guide.
Appendix: Configuration Reference — every
SESSION_*key.Appendix: Satellite Packages —
Kinetis\Sessionin the package map.Telemetry — wrapping a store for tracing with the same
bootstrap.phprebind pattern.