Skip to content

Sessions and signed cookies

Namespace Keel\Session. The session lives in a request attribute, never in a service, so nothing leaks between requests in worker mode. Enable it with SessionMiddleware in HttpConfig and a SessionConfig in config/session.php.

// config/session.php
return static fn(Env $env): SessionConfig => new SessionConfig(
    secret: $env->require('APP_SECRET'),   // at least 32 characters
    storage: 'cookie',                     // or 'file'
    ttl: 14 * 86400,                 // seconds of inactivity, at least 1
);

// controller: the session arrives by parameter type
#[Post('/login')]
public function login(#[FromBody] Login $cmd, Session $session): array
{
    $session->regenerate();                // new id, defeats session fixation
    $session->set('user_id', $id);
    return ['ok' => true];
}
Storage Where the data lives When it fits
cookie (default) in the cookie itself, HMAC-SHA256 signed, with an expiry no server state, scales horizontally; the client can read the data (no secrets), 4 KB limit
file <filePath>/sess_<id>.json, only a signed id in the cookie data hidden from the client, more than 4 KB; needs a shared file system or one node

Keel\Session\RedisStorage keeps sessions in Redis: RedisConfig(use: ['sessions']) with RedisProvider, see Cache. Any other storage implements SessionStorageInterface (read, write, destroy) and is registered under that interface. Implement SessionExpiry (expiresAt) as well and the middleware can refresh ageing sessions (below).

Cookie storage is signed, not revocable. destroy() clears the browser's cookie, but a copy of it stays valid until its expiry, and the client may send back any older cookie it kept, so state stored there can be rolled back (a consumed flash, an attempt counter). Keep authoritative state, and sessions you must be able to end from the server, in file or your own storage. Under cookie storage Session::id() is a fresh random value on every request and regenerate() changes nothing, so do not bind CSRF tokens or locks to the id there.

File storage writes 0600 files into a 0700 directory and refuses a directory that is a symbolic link. Set filePath to a directory of your own (var/sessions): the default, <system temp dir>/keel-sessions, is shared with every local user of the machine. Concurrent requests of one session are not locked against each other; the last write wins.

Session API: get, set, has, remove, pull (flash), all, clear, regenerate, destroy, id, isNew. The cookie is sent when the session changed, and also when less than half of ttl is left, so ttl is a true inactivity timeout: an active user who never writes to the session stays signed in. A destroyed or emptied session clears the cookie (Max-Age=0) and removes the storage entry, including the one before a regenerate() in the same request.

Cookie flags: HttpOnly, SameSite from the config (Lax, Strict or None; anything else is rejected), Secure automatically on https. SameSite=None always sends Secure and refuses secure: false. "On https" is judged from the request URI: behind a TLS-terminating proxy that is only right with TrustedProxiesMiddleware configured, so set secure: true explicitly otherwise.

CookieSigner works on its own: sign($value) → payload.signature, verify($cookie) → the value or null on a bad signature; signature($payload) is the signature alone, for a payload that travels separately. Good for "remember me"; signed links have their own UrlSigner built on it. Give every use its own purpose, new CookieSigner($secret, 'remember-me'): a value signed for one purpose does not verify under another, so a session id can never pass as a remember-me token. The default (empty) purpose is what the session middleware uses and what earlier versions signed with.

Attribute injection works for any object: a middleware stores an object in the request under its class name ($request->withAttribute(Identity::class, $identity)) and a handler parameter Identity $identity receives it without touching the container.

CSRF protection

CsrfMiddleware, listed in HttpConfig after SessionMiddleware, keeps a synchronizer token in the session (created on first sight, kept for the life of the session) and leaves a CsrfToken on the request. A request with an unsafe method (anything but GET, HEAD, OPTIONS, TRACE) must repeat the token in the _token form field or the X-CSRF-Token header, or is answered 403 Invalid CSRF token.

// config/csrf.php (optional)
return new CsrfConfig(field: '_token', header: 'X-CSRF-Token', except: ['/api/']);

#[Get('/profile')]
public function form(CsrfToken $csrf): string
{
    return '<form method="post">' . $csrf->field() . '...</form>';   // hidden input
}

CsrfToken::$value goes into a fetch() header; field() renders the hidden input; CsrfMiddleware::token($request) reads it from middleware. except lists path prefixes left unchecked: an API that authenticates by bearer token or API key is not reachable with a browser's ambient cookies and needs no token. To rotate the token (after login, say) remove the session key (_csrf by default) and the next request gets a fresh one. Under cookie storage the token travels inside the signed cookie, which is fine: the attacker's page cannot read it, and that is all the check relies on.