NAME
PAGI::Middleware::Session - Session management middleware with pluggable State/Store
SYNOPSIS
use PAGI::Middleware::Builder;
# Default (cookie-based, in-memory store)
my $app = builder {
enable 'Session', secret => 'your-secret-key';
$my_app;
};
# Explicit state and store
use PAGI::Middleware::Session::State::Header;
use PAGI::Middleware::Session::Store::Memory;
my $app = builder {
enable 'Session',
secret => 'your-secret-key',
state => PAGI::Middleware::Session::State::Header->new(
header_name => 'X-Session-ID',
),
store => PAGI::Middleware::Session::Store::Memory->new;
$my_app;
};
# In your app:
async sub app {
my ($scope, $receive, $send) = @_;
# Raw hashref access
my $session = $scope->{'pagi.session'};
$session->{user_id} = 123;
# Or use the PAGI::Session helper
use PAGI::Session;
my $s = PAGI::Session->new($scope);
$s->set('user_id', 123);
my $uid = $s->get('user_id'); # dies if key missing
}
DESCRIPTION
PAGI::Middleware::Session provides server-side session management with a pluggable architecture for session ID transport (State) and session data storage (Store).
The State layer controls how the session ID travels between client and server (cookies, headers, bearer tokens, or custom logic). The Store layer controls where session data is persisted (memory, Redis, database).
By default, sessions use cookie-based IDs and in-memory storage.
Warning: The default in-memory store is suitable for development and single-process deployments only. Sessions are not shared between workers and are lost on restart. For production multi-worker deployments, provide a store object backed by Redis, a database, or another shared storage.
CONFIGURATION
secret (required)
Secret key used for session ID generation.
expire (default: 3600)
Session expiration time in seconds.
state (optional)
A PAGI::Middleware::Session::State object that implements
extract($scope)andinject(\@headers, $id, \%options). If not provided, a PAGI::Middleware::Session::State::Cookie instance is created usingcookie_name,cookie_options, andexpire.store (optional)
A PAGI::Middleware::Session::Store object that implements async
get($id),set($id, $data), anddelete($id). If not provided, a PAGI::Middleware::Session::Store::Memory instance is created.cookie_name (default: 'pagi_session')
Name of the session cookie. Only used when
statedefaults to PAGI::Middleware::Session::State::Cookie.cookie_options (default: { httponly => 1, path => '/', samesite => 'Lax' })
Options for the session cookie. Only used when
statedefaults to PAGI::Middleware::Session::State::Cookie. For production HTTPS deployments, addsecure => 1.
STATE CLASSES
State classes control how the session ID is extracted from requests and injected into responses. All implement the PAGI::Middleware::Session::State interface.
- PAGI::Middleware::Session::State::Cookie
-
Default. Reads the session ID from a request cookie and sets it via
Set-Cookieon the response. Suitable for browser-based web applications. - PAGI::Middleware::Session::State::Header
-
Reads the session ID from a custom HTTP header. Requires
header_name; accepts an optionalpatternregex with a capture group. Injection is a no-op (the client manages header-based transport).PAGI::Middleware::Session::State::Header->new( header_name => 'X-Session-ID', ); - PAGI::Middleware::Session::State::Bearer
-
Convenience subclass of State::Header that reads an opaque bearer token from the
Authorization: Bearer <token>header. Intended for opaque session tokens, not JWTs. For JWT authentication, use PAGI::Middleware::Auth::Bearer instead.PAGI::Middleware::Session::State::Bearer->new(); - PAGI::Middleware::Session::State::Callback
-
Custom session ID transport using coderefs. Requires an
extractcoderef; accepts an optionalinjectcoderef (defaults to no-op).PAGI::Middleware::Session::State::Callback->new( extract => sub { my ($scope) = @_; ... }, inject => sub { my ($headers, $id, $options) = @_; ... }, );
STORE CLASSES
Store classes control where session data is persisted. All implement the PAGI::Middleware::Session::Store interface; methods return Future objects for async compatibility.
- PAGI::Middleware::Session::Store::Memory
-
Default. In-memory hash storage. Not shared across workers or restarts. Suitable for development and testing only.
- External stores
-
Redis, database, and other shared stores are available as separate CPAN distributions. Any object implementing
get($id),set($id, $data), anddelete($id)(returning Futures) can be used.
PAGI::Session HELPER
PAGI::Session is a standalone helper object that wraps the session data with a clean accessor interface.
use PAGI::Session;
my $session = PAGI::Session->new($scope);
Key methods:
get($key)- Dies if the key does not exist (catches typos).get($key, $default)- Returns$defaultfor missing keys.set($key, $value)- Sets a session value.exists($key)- Checks key existence.delete($key)- Removes a key.keys- Lists user keys (excludes internal_-prefixed keys).id- Returns the session ID.regenerate- Requests session ID regeneration on next response.destroy- Marks the session for deletion.
The helper stores a reference to the underlying hash, so mutations are visible to the middleware.
IDEMPOTENCY
The middleware skips processing if $scope->{'pagi.session'} already exists. This prevents double-initialization when the middleware appears more than once in a stack.
For mixed auth patterns (e.g. web cookies for browsers, bearer tokens for APIs), use PAGI::Middleware::Session::State::Callback with fallback logic instead of stacking multiple Session middleware instances:
use PAGI::Middleware::Session::State::Callback;
use PAGI::Middleware::Session::State::Cookie;
use PAGI::Middleware::Session::State::Bearer;
my $cookie_state = PAGI::Middleware::Session::State::Cookie->new(
cookie_name => 'pagi_session',
expire => 3600,
);
my $bearer_state = PAGI::Middleware::Session::State::Bearer->new();
enable 'Session',
secret => $ENV{SESSION_SECRET},
state => PAGI::Middleware::Session::State::Callback->new(
extract => sub {
my ($scope) = @_;
return $bearer_state->extract($scope)
// $cookie_state->extract($scope);
},
inject => sub {
my ($headers, $id, $options) = @_;
$cookie_state->inject($headers, $id, $options);
},
);
NON-HTTP SCOPES (WebSocket, SSE, ...)
If there is a session, WebSocket and SSE connections see it too: a scope whose type is not 'http' but which still carries a headers key -- both a WebSocket upgrade request and an SSE request are typed this way (see PAGI::Context's is_websocket/is_sse; SSE is its own distinct scope type, not 'http' plus a flag) -- gets a read-only session: the same state->extract + store->get lookup the 'http' path uses, populating $scope->{'pagi.session'} (and $scope->{'pagi.session_id'}, when a real session was found) before calling the wrapped app. This is store-agnostic -- it works with whatever store/state pair the middleware was configured with, not just cookie-backed setups -- and scope-type-agnostic: any current or future scope type with a headers key gets the same treatment, WebSocket and SSE are simply the two named, tested cases today.
Nothing is ever saved. There is no http.response.start-shaped event on a protocol upgrade to hook a save onto, so store->set is never called and no transport (e.g. Set-Cookie) is ever injected for one of these scopes. Any mutation an application makes to the session hashref during a WebSocket/SSE connection is visible for the lifetime of that connection and then silently discarded -- it never reaches the store and is never seen again, by this connection or any other. Establish or change session state (login, logout, regeneration) over a regular 'http' request; treat a non-http scope's session as read-only.
A session id that resolves to nothing (absent, malformed, or expired) populates $scope->{'pagi.session'} with an explicit empty hashref {} rather than leaving the key missing -- every 'http' request is guaranteed a usable session (even a brand new, never-before-seen one falls through to a freshly created hashref), so application code written against that guarantee (e.g. anything that unconditionally builds a PAGI::Session from the scope) sees the same guarantee here instead of having to special-case "no session key at all" for non-http scopes. $scope->{'pagi.session_id'} is left unset in this case -- there is no real session id to report, and fabricating one for a hashref that was never loaded from (or will ever be saved to) the store would be misleading.
A scope with no headers key at all (a lifespan scope, which carries no request to extract a session id from) is passed through completely unmodified, exactly as every scope type other than 'http' always was before this feature existed.
SCOPE EXTENSIONS
This middleware adds the following to $scope:
pagi.session
Hashref of session data. Modify this directly to update the session, or wrap it with PAGI::Session for strict accessor methods. Keys starting with
_are reserved for internal use.pagi.session_id
The session ID string. Absent (not merely undef) for a non-'http' scope whose session id didn't resolve to anything -- see "NON-HTTP SCOPES (WebSocket, SSE, ...)".
This applies to a non-'http' scope too, as long as it carries a headers key -- see "NON-HTTP SCOPES (WebSocket, SSE, ...)" for the read-only, never-saved semantics that applies there instead of the full load/mutate/save cycle described below.
SESSION DATA
Keys starting with _ are reserved for internal use by the session middleware. Do not use underscore-prefixed keys for application data.
Reserved keys (read-only metadata)
_id- Session ID_created- Unix timestamp when session was created_last_access- Unix timestamp of last access (updated each request)
Reserved keys (middleware-consumed flags)
These flags are set by the application and consumed by the middleware during response handling. They are deleted after processing.
_regenerated- Set to 1 to regenerate the session ID on this response. The old session is deleted from the store and a new ID is issued. Always do this after authentication to prevent session fixation attacks._destroyed- Set to 1 to destroy the session entirely. The session data is deleted from the store and the client-side state (cookie) is cleared. Use this for logout.
WHEN THE SESSION TRANSPORT IS EMITTED
On each response, the middleware saves the session unconditionally, but only emits the transport (e.g. Set-Cookie) via $state->inject when the session is new, regenerated, or its data has changed since it was loaded at the start of the request. A pure-read request against an existing, unmodified session emits no transport.
"Changed" is determined by comparing the session's data at load time against its data immediately before saving, regardless of how the mutation happened: $session->set(...), $session->data->{...} = ..., or direct hashref mutation via $scope->{'pagi.session'} all count. This matters most for transport-is-data stores (e.g. PAGI::Middleware::Session::Store::Cookie), where a discarded transport after a mutation would leave the client holding stale, incorrect data.
This entire section describes the 'http' scope path only. A non-http scope (see "NON-HTTP SCOPES (WebSocket, SSE, ...)") never saves and never emits a transport, regardless of whether its session was mutated.
SEE ALSO
PAGI::Session - Standalone session helper object
PAGI::Middleware::Session::State - Base class for session ID transport
PAGI::Middleware::Session::Store - Base class for session storage
PAGI::Middleware - Base class for middleware
PAGI::Middleware::Cookie - Cookie parsing