Sessions & CSRF¶
Note
Not part of core. Install it separately:
composer require kinetis/session
Cookie-backed sessions and CSRF protection for browser applications — the counterpart to the token-based Authentication and JWT Authentication packages. Appendix: Sessions Reference holds the lifecycle and store contracts behind this page.
Choose a driver¶
SESSION_DRIVER turns the package on; unset, it binds nothing.
SESSION_DRIVER=file
Key |
Default |
Purpose |
|---|---|---|
|
— |
|
|
|
Seconds a session stays readable after its last write. Every write restarts both the cookie’s |
|
|
The cookie name. See Cookie security. |
|
|
|
|
|
The cookie’s |
|
|
The |
file— one JSON file per session and no backing service; suited to local development on one host.redis— stores sessions through the Redis cache the application already configures. Needskinetis/cache-redisandREDIS_URL,REDIS_HOST, orREDIS_CLUSTERwithREDIS_CLUSTER_SEEDS. Expiry is the key’s TTL.sql— akinetis_sessionstable on the connectionkinetis/database-bridgebinds fromDB_CONNECTION. Create the table from a Migrations stub:vendor/kinetis/session/resources/migrations/create_kinetis_sessions_table.mysql.php.stub vendor/kinetis/session/resources/migrations/create_kinetis_sessions_table.pgsql.php.stub
SESSION_SECURE defaults to true, so a browser sends the cookie back
only over HTTPS. For plain-HTTP local development, set
SESSION_SECURE=false there and nowhere else.
Read and write session data¶
SessionMiddleware is route middleware, never global: it registers
the request’s Session on the request scope, which only route
middleware resolves through. CsrfMiddleware follows it. The controller
below renders a form on GET and changes state only on POST:
use Kinetis\Http\Attributes\Body;
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Middleware;
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\Responses\RedirectResponse;
use Kinetis\Session\Middleware\CsrfMiddleware;
use Kinetis\Session\Middleware\SessionMiddleware;
use Kinetis\Session\Session;
use Kinetis\Validation\Constraints\In;
use Kinetis\Views\Views;
use Psr\Http\Message\ResponseInterface;
final readonly class ThemeForm
{
public function __construct(
#[In(['light', 'dark'])]
public string $theme,
) {}
}
#[Middleware(SessionMiddleware::class)]
#[Middleware(CsrfMiddleware::class)]
final readonly class PreferencesController
{
public function __construct(
private Session $session,
private Views $views,
) {}
#[Get('/preferences')]
public function edit(): ResponseInterface
{
return $this->views->response('preferences/edit', [
'theme' => $this->session->get('theme', 'light'),
'csrfToken' => $this->session->csrfToken(),
]);
}
#[Post('/preferences')]
public function update(#[Body] ThemeForm $form): ResponseInterface
{
$this->session->set('theme', $form->theme);
return RedirectResponse::to('/preferences', 303);
}
}
get(), set(), has(), remove() and all() are the data surface.
flash($key, $value) stores a value readable through flashed($key) on
the following request only — the companion to the redirect above. Values
must be JSON-serializable: every store saves the session as JSON, and a
read returns plain arrays and scalars.
The store is read on first use and written only when something changed.
A route that never touches its session costs no round trip and sends no
Set-Cookie, so attaching the middleware to a whole controller is
cheap. The session is written after the handler returns; a request that
throws leaves the stored session and the cookie unchanged.
CSRF in a browser form¶
The controller passes the token as ordinary view data, and the view
renders it, escaped, into a hidden _token field of a POST form:
<p>Current theme: <?= htmlspecialchars($theme, ENT_QUOTES, 'UTF-8') ?></p>
<form method="post" action="/preferences">
<input type="hidden" name="_token" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
<select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button type="submit">Save</button>
</form>
Latte and Twig escape {$csrfToken} and {{ csrfToken }} on their
own; Views covers escaping in each engine.
Order. Declare
SessionMiddlewarebeforeCsrfMiddleware; declaration order is execution order. Reversed, every state-changing request answers500naming the mistake.Grouping. Neither class is
final, so a route can reach both through one named middleware group instead of listing them — the same extension Authentication and JWT Authentication use, with the group’s own priority taking the place of declaration order. See Middleware’s “Middleware groups”.Safe methods.
GET,HEADandOPTIONSpass without a token check, so they must not change application state: another site can make a browser send them with the user’s cookie. The only session write theGETabove can cause is creating the token on its first render.Every other method needs the token, either as
_tokenin a form-encoded or multipart body or as theX-CSRF-Tokenheader. A missing or wrong token answers403(CSRF token mismatch.), and the handler never runs.No tokens in URLs. The middleware never reads the query string, and a URL leaks through logs, browser history and
Referer.Call
csrfToken()where the response carries the token. Its first call creates the token and persists the session, so call it when rendering a form or a page script, not on every request.
CSRF from JavaScript¶
Render the token into the page, then send it in the X-CSRF-Token
header:
<meta name="csrf-token" content="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>">
const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch('/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
},
body: JSON.stringify({theme: 'dark'}),
});
The same route accepts this request, since #[Body] hydrates
ThemeForm from JSON as well as from form fields. A JSON request sends
the token in the header only: the middleware reads _token from
form-encoded and multipart bodies, never from a JSON document.
Regenerate on privilege change¶
Call regenerate() on login and on every other privilege change, then
redirect:
$this->session->regenerate();
$this->session->set('userId', $user->id);
return RedirectResponse::to('/dashboard', 303);
It gives the session a fresh id and a fresh CSRF token and keeps the
application data, so neither an id nor a token planted before login
carries into the authenticated session. A page rendered before the call
holds a token that now answers 403; the redirect renders the new one.
Call destroy() on logout: the stored data is removed and the cookie
expired.
Garbage collection¶
The file and sql drivers keep expired sessions in storage until
session:gc deletes them:
php vendor/bin/kinetis session:gc
It deletes every expired session from the bound store and prints how many were removed. Nothing runs it implicitly — schedule it with whatever the deployment already uses (cron, a Kubernetes CronJob, an EventBridge rule), the same way as any other CLI command. Once a day suits most applications: expired sessions are already invisible to reads, so the schedule only controls how long dead data lingers.
With the redis driver there is nothing to schedule: a key disappears
when its TTL lapses, and session:gc says so and exits 0.
Concurrent requests¶
Stores take no locks. Concurrent requests sharing one session are last-write-wins, so keep session data small and low-contention — an auth reference, the CSRF token, flash data — rather than a shared workspace. Logout and regeneration are the exception: once either has committed, an overlapping request cannot write the old id back.
To replace the built-in drivers, bind your own SessionStoreInterface;
see Custom stores.
See also¶
Appendix: Sessions Reference — expiry, cookie prefixes, id verification, the CSRF check lifecycle, terminal writes, and custom stores.
Views — rendering and escaping view data.
Authentication / JWT Authentication — token-based authentication, the API-first counterpart to cookie sessions.
Middleware — how route middleware and middleware groups work.
Telemetry — a span per session store call.