NAME

Punk::Session::Store - server-side sessions, on any store

SYNOPSIS

package MyApp;
use Punk;

cache 'file', dir => '/var/cache/app';

session secret  => secret('session_key'),
        expires => '7d',
        store   => 'cache';               # the payload moves off the client

post '/login' => sub {
    my ($c) = @_;
    my $user = authenticate($c) or return $c->redirect('/login');
    $c->session_rotate;                   # a new id at the privilege change
    $c->session->{user_id} = $user->{id};
    $c->redirect('/');
};

post '/logout' => sub {
    my ($c) = @_;
    $c->session_expire;                   # deletes the entry: revoked
    $c->redirect('/');
};

DESCRIPTION

store => ... on the session keyword moves the session off the client. The cookie carries a signed 128-bit id and nothing else; the session itself lives in a Punk::Cache store.

$c->session is unchanged, so no application code moves.

Three things follow from the payload being server-side:

  • The ~4KB ceiling goes. A session with a basket in it stops being a croak.

  • "Do not put secrets in a session" stops being true. The client can read a cookie session; signed is not sealed. Server-side it can read an opaque id.

  • Logout can revoke. A signed cookie is valid until it expires no matter what the server thinks. An entry can be deleted.

THE STORE IS A SEAM

Any Punk::Cache backend can hold a session, including one from outside this distribution. That is the point of putting sessions on that contract rather than inventing a second one: a Redis or DBI store implements get, set, delete, clear and stats because it already had to, so one module serves cache, Punk::Plugin::Idempotency and this.

store takes four forms, told apart by what the value is rather than by reading a string and guessing:

session ..., store => 'cache';                  # the default store
session ..., store => 'sessions';               # one `cache` declared
session ..., store => { backend => 'Redis::Store', server => '...' };
session ..., store => $object;                  # a ready-made one

'cache' is reserved for the default store, because that is what an application with one store calls it; a store genuinely declared under that name is a boot error naming the collision. Any other string is the name of a store cache declared, and one that was never declared croaks - a session store that silently never hits looks like a login page that forgets everybody. A hashref is built here, in the shape the cache keyword already takes for a named store. An object is checked against the contract like any other backend.

Writing a backend is "Writing your own" in Punk::Cache, and t/lib/PCConform.pm takes a factory, so a store from outside this distribution answers the same assertions the shipped ones do. That is worth doing here more than anywhere: the failure mode of a disagreement is not a low hit rate, it is somebody logged out.

What a store owes a session

Four things, beyond the five methods:

  • Values are bytes. The session is JSON before it reaches the store.

  • A key is bytes and never becomes a path. The file store guarantees it by hashing; a store from elsewhere guarantees it itself.

  • get returns undef for absent and for expired, and a $ttl of 0 means no expiry rather than expire immediately. Backwards, that logs out the whole application at once.

  • is_shared must return true. Leaving it out means unshared, which is the safe default for a cache and a boot croak here - see below.

A session has a ceiling still

A megabyte, rather than the cookie's four kilobytes. Unbounded is not what replaces a ceiling, because a session an application can grow without limit is a way to fill a store from a login form. Over it, the session is not saved and the write-back warns with both numbers.

A store may also refuse a write of its own accord - the file store refuses a value too large for its budget rather than evicting everything else to fit. A refusal is warned about rather than swallowed: a login that appeared to work and did not is the failure this most needs to be loud about.

THE POOL

A session belongs to the pool, not to the worker that created it, so the store has to be shared between workers. One that is not is refused at boot:

Punk: the session store reports that it is not shared between workers ...

Because the alternative is not staleness. The session written on worker A is absent on worker B, so the user is logged out on whichever request the pool sends elsewhere, at random, forever. allow_unshared => 1 says there is only one process, which is a thing only the person deploying knows - a test suite, punk dev, a single-worker deployment.

The memory tier

Punk::Cache's memory option puts a per-worker copy in front of a shared store. Sessions go round it by default, whatever the store is configured with, because for a session that copy is a bound on how long a revoked session keeps working: a worker that missed the invalidation answers from its own copy, and the copy it answers with is the session before the logout.

Be accurate about the other direction, because the loose version of this argument is wrong: a tier miss falls through to the store, so a tier can never manufacture a missing session. The hazard runs one way, and it is authentication staleness.

Opt in when the arithmetic earns it - a network store, and sessions are the most read-mostly thing an application has:

cache 'file', dir => '/var/cache/app', memory => '32M', memory_ttl => 2;
session ..., store => 'cache', tier => 2;

The number is how many seconds you accept a revocation lagging by. It is capped at 5, the store must actually have a tier, and the store's memory_ttl must not exceed it - all three are boot errors naming both numbers, rather than a quiet decision to use the larger one.

Writes are never routed around the tier: set and delete on the store drop this worker's copy and publish an invalidation so the others drop theirs.

ROTATION

$c->session_rotate;

Keep the session, give it a new id, delete the entry under the old one. Call it at the privilege boundary - a login, an elevation - and nowhere else.

This is the one that gets missed. Session fixation: an attacker plants a known id in a victim's browser and waits for them to log in. If logging in writes the user into the session the attacker planted, the attacker's id is now an authenticated session. A cookie session is immune by accident, because its value changes wholesale when its contents do. A stored session is not - the id survives the login unless something changes it.

It cannot be automatic, and the candidates for guessing it are all wrong: rotating whenever the session gains a key rotates on a shopping basket, rotating on every POST is a store write per form submission, and watching for a key named user_id is a convention nobody agreed to. So it is a call the application makes, at the one place that knows.

Without a store it is a documented no-op rather than an error, because there is nothing to rotate.

SLIDING EXPIRY

session ..., store => 'cache', expires => '7d', sliding => 1;

Off by default, in which case a session expires a fixed period after it was created - which is what a cookie session does when nothing writes to it.

On, a session in use is extended, and the extension is throttled: it happens once the session is past half its lifetime, not on every request. Sliding on every request would be a store write per request, which is the cost the read-on-demand path exists to avoid. The cookie is reissued along with the entry, because a cookie that ran out while its entry lived would be exactly the logout sliding exists to prevent.

SEE ALSO

Punk::Session for the keyword, the cookie and flash; Punk::Cache for the store contract and for writing a backend; "session_rotate" in Punk::Context.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)