NAME
Dancer2::Session::Pg - PostgreSQL session backend for Dancer2
VERSION
version 0.001
STATUS
Package Dancer2::Session::Pg is under development so changes in the API are possible, though not likely.
SYNOPSIS
use Dancer2::Session::Pg ();
my $engine = Dancer2::Session::Pg->new(
dsn => 'dbi:Pg:dbname=app;host=db',
dbuser => 'app_web',
dbpass => $ENV{'APP_DB_PASSWORD'},
dbtable => 'sessions', # required
dbschema => 'web', # optional; else search_path
session_duration => 900,
# One or more SLOTS, each pairing a key with the cipher that uses it.
# Exactly one is active: that is the one sessions are written with, and
# the rest stay to be read. See SECURITY for where the key comes from.
encryption_keys => {
0 => {
key => $ENV{'SESSION_KEY_0'},
alg => 'AES-256-GCM',
active => 1,
},
},
# Optional; see THE PRINCIPAL COLUMN for whether you want it at all.
principal_key => 'principal',
principal_column => 'account_id',
);
Most applications configure this from config.yml rather than in Perl -- see "A configuration file" in Dancer2::Session::Pg. Installing the engine by hand is for when dbh has to be a coderef, something YAML cannot express, and there is a trap in doing it which "CONNECTIONS" in Dancer2::Session::Pg describes.
DESCRIPTION
Stores Dancer2 sessions in PostgreSQL, and uses PostgreSQL's own features to make that storage safer than a serialised blob in a table.
A web session is not ordinary data. It frequently carries the credentials that prove who somebody is -- with OpenID Connect, an access token and a refresh token -- so the store is worth more than the account it belongs to. Three properties follow from that, and each is provided by the database rather than by convention:
- Authenticated encryption at rest
-
The payload is encrypted with an AEAD cipher, so a dump, a backup or a support copy of the table does not hand over the contents of a session, and a row that has been altered fails to decrypt instead of deserialising into a structure the application would then trust.
Nor does it hand over a way in. The session id is stored as a SHA-256 digest, not verbatim, because the id is the session cookie: a table full of raw ids would be a table full of working credentials, usable against the live application by anyone who read a backup, no key required. What a dump contains is digests, which open nothing.
The id is also authenticated with the payload, so a sealed payload opens only under the session it was written for and cannot be moved from one row to another. "SECURITY" in Dancer2::Session::Pg says what that stops, where the key should live, and when to rotate it.
Which cipher is a property of the key it is used with, and both are replaceable: every payload records the key and the cipher that sealed it, so a cipher found wanting next year is three deployments rather than a forced logout. See "THE STORED PAYLOAD" in Dancer2::Session::Pg, "Rotating the key" in Dancer2::Session::Pg and Dancer2::Session::Pg::Cipher.
- Expiry decided by the server's clock
-
expiresis atimestamptzand every read filters on it. Application clocks drift; the database's clock is the one every process shares, so all of them agree about whether a session is still alive.The expiry is set when the row is created and is not moved by later writes.
session_durationis therefore an absolute cap measured from creation, which is what Dancer2::Core::Role::SessionFactory describes: a limit on session validity, regardless of the cookie. An idle timeout is a different thing and is the cookie's job -- seecookie_duration, which slides.This matters more than it sounds. A cap that every request pushes further away is never reached by a session in continuous use, and a session in continuous use is what somebody holding stolen cookies has.
- Atomic writes
-
Sessions are written with
INSERT ... ON CONFLICT DO UPDATE, which is atomic. Any number of workers may write one session id concurrently without producing a duplicate row, a unique violation or a deadlock, and without moving the expiry cap.That is a guarantee about database integrity, not about every write succeeding: concurrent writers to one row serialise on its lock, and a waiter that exceeds
statement_timeoutis cancelled on purpose rather than holding a worker. See "A blocked write fails rather than waiting" in Dancer2::Session::Pg.It does not mean two workers cannot lose each other's changes. The payload is one encrypted blob, so a write replaces all of it and the last writer wins. See "CONCURRENCY" in Dancer2::Session::Pg, which says exactly what is and is not promised, and is backed by a test rather than by this paragraph.
On top of that, an optional clear column beside the encrypted payload makes it possible to find and end every session belonging to one account without decrypting anything -- see "destroy_for_principal" in Dancer2::Session::Pg. Suspending an account has little effect while the suspended user's cookie still works. That column is off by default and need not exist; "THE PRINCIPAL COLUMN" in Dancer2::Session::Pg is about whether you want it.
Why this is PostgreSQL and not portable SQL
A reasonable question, since a session row is four columns and a blob. The answer is that the three guarantees above are not properties of the schema -- they are properties of statements and settings that standard SQL either does not have or does not define strongly enough to rely on.
INSERT ... ON CONFLICT DO UPDATE, notMERGE-
The standard spells an upsert
MERGE, PostgreSQL has had it since 15, and it is not a substitute here.MERGEdecides between itsWHEN MATCHEDandWHEN NOT MATCHEDbranches from a snapshot; it does not take the speculative insertion lock thatON CONFLICTdoes, so when two transactions pick theNOT MATCHEDbranch for the same key, one of them inserts and the other raises a unique violation.That is not a theoretical difference. Sixteen processes upserting one key forty times each, on PostgreSQL 17:
INSERT ... ON CONFLICT DO UPDATE 0 of 16 workers failed MERGE 4 of 16 workers failed ERROR: duplicate key value violates unique constraintA session is written on more or less every request, and concurrent writes to one session id are the normal case, not the edge: a page with parallel
XHRs does it by itself. WithMERGEa quarter of those workers would have had to carry retry logic for a constraint violation that cannot happen withON CONFLICT. Writing portable SQL here would mean writingSELECT-then-INSERT-or-UPDATEin the application, which has the same race and loses atomicity as well. statement_timeout, so a blocked write fails instead of hanging-
"A blocked write fails rather than waiting" in Dancer2::Session::Pg is a guarantee about the worker, not the row, and it rests on a PostgreSQL setting applied per connection. The standard has no equivalent: there is no portable way to say "cancel this statement after 400ms". Without it a writer that lands behind an open transaction waits as long as that transaction lives, holding a web worker the whole time -- and a handful of those is an outage, for a session that was not worth waiting on.
bytea, and a driver that binds it as binary-
The sealed payload is ciphertext: arbitrary bytes, which must come back byte for byte or the authentication tag fails and the session is lost.
byteawith DBD::Pg'sPG_BYTEAbinding does that with no encoding in the middle. The standardBLOBis spelled and handled differently by every engine, and the usual portable workaround -- base64 into a text column -- inflates every row by a third and adds a transform to each read and write of a credential store. timestamptzand the server clock-
Expiry is decided by
now()on the server, againsttimestamptz, so one clock decides whether a session is alive. Application clocks drift, and with several workers the answer would otherwise depend on which machine the request reached.timestamptzalso removes the zone question entirely, because PostgreSQL stores it as an instant rather than a local time with an offset.
None of this rules out a portable session store -- it rules out a portable one with these properties. A session table meant to run on several engines is a reasonable thing to want, and it is a different module from this one. This one is for the case where the session store is the most security-sensitive table in the database, and you would rather the database enforced that than your application remembered to.
REQUIREMENTS
PostgreSQL 9.5 or later, for INSERT ... ON CONFLICT DO UPDATE -- see "Why this is PostgreSQL and not portable SQL" in Dancer2::Session::Pg for why that statement and not the standard MERGE.
Perl v5.14 or later (Dancer2's required Perl as per Dancer2 v1.0.0).
THE TABLE
Create it yourself. The DDL below is a starting point, not a mandate: schema name, ownership, tablespace, index names, whether IF NOT EXISTS suits your deployment and whether the table joins an existing migration scheme are local decisions this module has no business making.
Three things are actually required: the column names and types of the columns this module writes, id unique so ON CONFLICT (id) has an arbiter, and privilege to SELECT, INSERT, UPDATE and DELETE.
Put the result under whatever migration scheme you already use. A session table that exists only in somebody's shell history disappears the next time the schema is rebuilt, and the symptom at the far end is a login that never completes.
There are three variants below, and the second is the smallest thing that works. Start there unless you know you want the principal column.
DDL
With the optional principal column, as free text -- the general case, where the identifier in the session is not a key of any table in this database (a federated sub claim, a tenant-scoped id, an opaque token):
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
principal_id text,
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
COMMENT ON TABLE web.sessions IS
'Dancer2 session store (Dancer2::Session::Pg). Rows hold authenticated-encrypted session payloads; treat as credential material.';
COMMENT ON COLUMN web.sessions.id IS
'SHA-256 of the Dancer2 session id -- NOT the id itself, which is the session cookie and would be replayable from a dump. Arbiter for ON CONFLICT.';
COMMENT ON COLUMN web.sessions.principal_id IS
'OPTIONAL -- drop this column if principal_key is not configured. Clear copy of the value named by principal_key, so sessions can be found and revoked without decrypting. May be free text as here, or a foreign key; see "THE PRINCIPAL COLUMN" in the module docs. NULL when the session has no such value.';
COMMENT ON COLUMN web.sessions.session_data IS
'AEAD-encrypted session payload: version || cipher id || key id || iv || tag || ciphertext. Unreadable without the application key, and tamper-evident.';
COMMENT ON COLUMN web.sessions.created IS
'When the row was first written. Server clock.';
COMMENT ON COLUMN web.sessions.updated IS
'When the row was last written. Server clock.';
COMMENT ON COLUMN web.sessions.expires IS
'When the session stops being valid. Every read filters on it. NULL means no expiry, which is rarely right for a session holding credentials.';
CREATE INDEX sessions_principal_id_idx
ON web.sessions (principal_id) WHERE principal_id IS NOT NULL;
COMMENT ON INDEX web.sessions_principal_id_idx IS
'OPTIONAL. Supports destroy_for_principal and sessions_for_principal. Partial: sessions without a principal id are not indexed.';
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
COMMENT ON INDEX web.sessions_expires_idx IS
'Supports reap(). Partial: rows without an expiry are never reaped.';
DDL without the principal column
The smallest table this module can use. Leave principal_key unset and the column is never written, never read and need not exist:
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
DDL with the principal column as a foreign key
When the identifier in the session is a key of a table in this same database, the column can be a real foreign key rather than free text. Name it after what it references -- principal_column exists for that -- and let the database keep it honest:
-- Your accounts table already exists. It is shown here only so that the
-- example below is complete and can be applied as it stands.
CREATE TABLE web.accounts (
id bigint PRIMARY KEY,
email text NOT NULL
);
CREATE TABLE web.sessions (
id text PRIMARY KEY, -- SHA-256 hex of the session id
account_id bigint REFERENCES web.accounts(id) ON DELETE CASCADE,
session_data bytea NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
updated timestamptz NOT NULL DEFAULT now(),
expires timestamptz
);
COMMENT ON COLUMN web.sessions.account_id IS
'OPTIONAL. Foreign key to web.accounts(id), written by Dancer2::Session::Pg from the session key named by principal_key. ON DELETE CASCADE: deleting an account ends its sessions in the same statement.';
CREATE INDEX sessions_account_id_idx
ON web.sessions (account_id) WHERE account_id IS NOT NULL;
CREATE INDEX sessions_expires_idx
ON web.sessions (expires) WHERE expires IS NOT NULL;
Choosing a foreign key
The table above is configured as:
principal_key: "account_id" # the session key to copy
principal_column: "account_id" # the column to copy it into
ON DELETE CASCADE is the reason to do this: deleting an account ends its sessions in the same statement, with no application code to forget. But a foreign key is a constraint, and a constraint has consequences the free-text column does not. Read these before choosing it:
A session write fails if the principal is not a row in the referenced table. That is the point of a foreign key, and it is also a way to break every request for one user: a subject id that has not been provisioned locally yet, a soft-deleted account, an id from another tenant's table. If the identifier in your session is not guaranteed to exist as a key right now, use free text.
The type must match the referenced key. A
bigintcolumn will reject a non-numeric session value at write time --'anonymous'in a column referencingbigintis an error, not aNULL. This module copies the value it is given and does not coerce it.Without
ON DELETE CASCADEorSET NULL, the default isNO ACTION, and you will not be able to delete an account until its sessions are gone. "destroy_for_principal" then has to run first, which is the opposite of the automation you wanted.Every session write now checks the constraint. That is a row lock on the referenced table for the duration of the write, on a table that is read on every authenticated request. It is cheap, but it is not free, and it couples session writes to the availability of the accounts table.
THE PRINCIPAL COLUMN
It is optional, off by default, and the column can be absent from the table entirely. This section is about whether you want it at all.
Leaving it out
Do not set principal_key. Then:
no principal column is written and none is read, so the table in "DDL without the principal column" -- which does not have one -- is complete;
"destroy_for_principal" and "sessions_for_principal" croak if called, naming the configuration they need rather than failing in SQL;
"count_sessions" returns
liveandexpiredand omitssigned_in, because there is no column that could answer it;nothing else changes. Encryption, expiry, atomic writes, "reap" and the rest of the module do not involve the principal at all.
What it is for
An application that already keeps a stable identifier for the signed-in party somewhere in its session -- an account id, a user id, a principal -- can have that one value copied into a plain column beside the encrypted payload. Nothing else is exposed. What it buys is the ability to ask "which sessions belong to this account?" and to end them all, without decrypting anything and without scanning every row.
That matters when an account is suspended, deleted, or has its password or roles changed. Until its sessions are gone, the change has not really taken effect -- the holder of the cookie carries on with the access they had. See "destroy_for_principal".
Free text or a foreign key
Both work, and the module does not care which you chose: it writes the value and reads it back, and the column's type and constraints are the database's business.
free text the identifier is not a key of any table HERE -- a federated
subject claim, an opaque id, a value from another service.
Nothing can go wrong at write time. Nothing keeps it honest
either: a typo is just a session nobody can revoke.
foreign key the identifier IS a key here. ON DELETE CASCADE ends an
account's sessions when the account goes, the database
guarantees the column means what it says, and a principal
that does not exist is refused at write time -- which is
either exactly what you want or an outage for one user.
See "DDL with the principal column as a foreign key" for the trade-offs spelled out.
When it will do nothing for you
sessions are anonymous, as for a cart or a wizard, so there is no account to revoke;
you never need to end sessions administratively, and expiry alone is enough;
the identity in the session is a structure rather than a value. A
HASHorARRAYis skipped -- but if one scalar can be computed from it, giveprincipal_keya coderef and that is no longer a limitation:principal_key => sub { return $_[0]->{'user'}{'id'} }
To be plain about its provenance: this is a pattern that works, generalised from one application. It is not an established convention of the Dancer2 session engines, and it is offered as a capability rather than as advice about how your sessions ought to be shaped. If it does not fit, leave principal_key unset and lose nothing else.
THE STORED PAYLOAD
version (1 byte) || cipher id (1 byte) || key id (1 byte) || iv || tag || ciphertext
\-------------------- 3-byte header --------------------/
Every row says what wrote it. The lengths of iv and tag are not in the header: they come from the cipher the id names, so a cipher with a 24-byte nonce needs no change to the format.
version 1. A payload with any other version is refused, not guessed at.
cipher id which cipher sealed it; see Dancer2::Session::Pg::Cipher.
key id which SLOT sealed it -- the key and the cipher together,
so either can be replaced without logging anybody out.
See "Rotating the key".
Replacing a cipher
This is the whole reason the header exists. A cipher that turns out to be a bad idea next year should cost a restart, not every session in the database.
alg: "ChaCha20-Poly1305" # was AES-256-GCM
Give the new cipher a slot of its own. That is the whole procedure, and it is the same one as changing a key, because the cipher belongs to the slot: add the slot, move active, and drop the old slot once its sessions have expired. New sessions are sealed with the new cipher while rows written by the old one keep decrypting until they expire, and nobody is logged out. The steps are in "Rotating the key".
What you must not do is edit a slot's alg in place while its sessions are still alive. The key would still be right, so the row would still find its slot, but the cipher that wrote it would be gone. See "The one mistake slots cannot prevent".
There is no separate list of readable ciphers to maintain. A cipher you can still read but no longer write is the other slot.
Writing a cipher
A cipher is a class consuming Dancer2::Session::Pg::Cipher, which is seven small methods. A slot's alg names it:
encryption_keys:
0: { key: "...", alg: "AES-256-GCM" } # kept, read only
1:
key: "..."
alg: "My::Cipher::XChaCha20"
active: true
In Perl, alg also takes a [ class, %arguments ] pair or an object already built, for a cipher that needs constructing:
alg => [ 'My::Cipher::XChaCha20', rounds => 20 ]
alg => $cipher_object
Every slot's cipher is exercised when the engine is built -- a round trip, the advertised tag length, a bit flipped in both the ciphertext and the tag, and the additional authenticated data altered -- so a cipher that is not authenticated encryption, or that silently drops the session-id binding, stops the process at startup instead of accepting a forged session later. Cipher ids 128..255 are the third-party range.
CONFIGURATION
Most of these are type-checked with Type::Tiny, which Dancer2 already depends on, so nothing new is installed. The point is not tidiness: a session engine is configured from a YAML file, and YAML produces the wrong shape easily. These are now refused when the engine is built rather than several minutes later, somewhere less obvious:
encryption_keys: "a key" # a string where the slots belong
connect_timeout: two # would have been appended to the DSN verbatim
dbh: "dbi:Pg:dbname=app" # a string where a handle or coderef belongs
principal_column: "" # an empty identifier
A slot's alg is deliberately not type-checked, because it accepts four different shapes and the cipher resolver has a specific message for each. dsn, dbuser, dbpass and dbschema accept undef, because a key present but empty in YAML means "not set" and the checks below answer that with a sentence you can act on.
A configuration file
The whole of it, for an application with no principal concept:
# config.yml
session: 'Pg'
engines:
session:
Pg:
dsn: "dbi:Pg:dbname=app;host=db"
dbuser: "app_web"
dbpass: "..."
dbtable: "sessions" # required
dbschema: "web" # optional; omit to use search_path
session_duration: 900
encryption_keys:
0:
key: "${ENV:SESSION_KEY_0}"
alg: "AES-256-GCM"
active: true
# and, if you want the optional principal column
principal_key: "principal"
principal_column: "account_id"
json_module: "JSON::MaybeXS"
The ${ENV:...} placeholders are expanded by a config reader, which is how the key reaches the process without being written down in the repository. See "Where the key should live".
dsn, dbuser, dbpass
Passed to DBI. connect_timeout is appended to the DSN unless it is already there. Required unless dbh is given.
dbh
An existing DBI handle, or a coderef returning one. Use this when the application already has a connection -- from Dancer2::Plugin::Database, DBIx::Class, or a pool -- rather than opening a second one. A coderef cannot be written in YAML, so this attribute means constructing the engine in Perl: see "CONNECTIONS", including the one way of doing that which fails silently.
SHARING A HANDLE
A handle you supply belongs to you. This module does not apply statement_timeout, does not reconnect it, and does not commit it.
One requirement, and it is a refusal rather than a warning: RaiseError must be on. This module checks no DBI return value of its own, because a handle that raises is the only arrangement in which it can report the truth. With DBI's default of RaiseError => 0, execute and do answer undef on failure and carry on: a session write that never happened looks like one that did, and "destroy_for_principal" reports 0 sessions revoked when the DELETE actually failed. Being told that an account's sessions are gone when they are still live is a worse outcome than any exception. So the engine croaks rather than proceed. Set RaiseError => 1 on the handle you pass, or pass a dsn and let the module open its own.
That last one deserves a straight answer, because it is the obvious question. If your handle has AutoCommit off, a session write joins whatever transaction is already open, and it becomes durable when you commit -- not before. It would be easy for this module to call commit and make the session "just work". It must not: your transaction is not its transaction, and committing it would also commit whatever unrelated work you had in flight. Silently turning somebody else's half-finished unit of work into a permanent one is a worse failure than a session that waits for its caller.
So the rule is: with AutoCommit off on a shared handle, commit is yours. The module says so once per worker if it sees that state, because a session write that is real in the process but not yet in the database is exactly the sort of thing that resurfaces later as a login that did not complete.
When it says so depends on which form you used, and the difference is not cosmetic. A handle passed directly is inspected at construction, so the warning arrives at startup. A coderef cannot be: calling it there would open a connection during construction, which is the one thing the coderef form exists to avoid. So that handle is inspected at its first use instead, and the warning arrives with the first request rather than at startup.
If you would rather not think about it, do not share the handle: give this module a dsn and it opens its own connection with AutoCommit on, where the question does not arise.
dbtable
The name of the table. Required: there is no sensible default to guess.
dbschema
The schema the table lives in. Optional, and there is no default.
When it is set, references are qualified as "schema"."table". When it is not, the table is referenced bare as "table" and resolved by the connection's search_path.
This module deliberately does not assume public. A deployment's default schema may be called anything, and qualifying a name that the deployment never asked to have qualified takes a decision that belongs to search_path -- typically set per role with ALTER ROLE ... SET search_path, or per connection through the DSN. If you rely on search_path, make sure it is set somewhere durable: a session engine that cannot find its table fails on every request.
encryption_keys
One or more slots. Each slot pairs a key with the cipher that uses it, and exactly one slot is active -- the one sessions are written with. The others stay to be read, until the sessions written under them have expired.
encryption_keys:
0:
key: "${ENV:SESSION_KEY_0}"
alg: "AES-256-GCM"
active: true
Required, and one slot is a complete configuration: a deployment that never rotates writes exactly that and nothing more.
Why a key and a cipher together
Because separating them makes key length load-bearing, and that turns out to infect everything. With a list of keys and one global choice of cipher, nothing records which cipher a key was meant for, so length becomes the only thing linking them -- and it then has to be re-derived when validating the written key, when catching a typo in a read key, when deciding which ciphers are usable at all, when checking a row's cipher against its key, and even when deciding whether a configured string is hex or raw bytes.
Paired, all of that is one check in one place: this slot's key must fit this slot's cipher. Two slots may hold keys of different lengths without any of it mattering, which is also what makes "Changing the key length" possible.
It removes a whole attribute as well. An earlier draft had read_algs, for naming a cipher you can still read but no longer write. That is the other slot.
Each slot
key-
The key, as raw bytes or as hex of exactly twice the length this slot's cipher needs. Generate one with
openssl rand -hex 32for a 32-byte cipher. Checked at startup against that cipher and nothing else. alg-
Which authenticated cipher this slot uses: a built-in name, a class name, a
[ class, %arguments ]pair, or an object.name key cipher id AES-128-GCM 16 bytes 1 AES-192-GCM 24 bytes 2 AES-256-GCM 32 bytes 3 ChaCha20-Poly1305 32 bytes 4AES-256-GCMis the usual answer: AES-NI makes it the fastest option on essentially every server CPU in use, it is the most widely reviewed choice, and it is the one most likely to be acceptable to whoever audits you. PreferChaCha20-Poly1305on hardware without AES acceleration.Dancer2::Session::Pg->algorithmslists the built-in names, and Dancer2::Session::Pg::Cipher is the contract for writing your own.Do not edit a slot's
algin place while sessions written under it are still alive. Give the new cipher a slot of its own; that is what slots are for, and the engine will tell you if you do it the other way. active-
True on exactly one slot. Not a pointer kept elsewhere, so it cannot dangle.
Two active slots is a startup error, and deliberately so. Picking one silently would be unsafe twice over: Perl randomises hash order per process, so "the first" would differ between pods and between restarts of the same pod -- every pod writing a different key, which is the outage of "Rotating the key" made permanent. And even in a defined order, activating a new slot while forgetting to deactivate the old one would leave the rotation not done while looking done; the old slot would then be retired on schedule and every live session would die at once. If the reason for rotating was that the old key leaked, it would also mean quietly continuing to use the leaked key.
A process that refuses to start is the better failure. Under Kubernetes the pod crashloops, the rollout halts, and the previous generation carries on serving.
The slot id is 0 .. 255, being one byte of every stored payload. It is the structure's key rather than a position, so slots cannot be reordered into meaning something else.
principal_key
What to copy into the principal column, or undef -- the default -- to turn the feature off entirely. Either the name of a session key, or a coderef handed the session data:
principal_key => 'account_id' # a session key name
principal_column => 'account_id'
principal_key => sub { $_[0]->{'user'}{'id'} } # computed
A coderef must not throw: it runs inside the session write, so an exception there fails the request. A non-scalar result from either form is skipped, the column is left NULL, and the fact is reported once through log_cb -- see "THE PRINCIPAL COLUMN", which is about whether you want any of this.
principal_column
The column principal_key's value is written to. Defaults to principal_id, and ignored entirely when principal_key is unset.
Named separately from the session key because the two answer to different things: the session key is the application's vocabulary, the column is the database's. A deployment making the column a foreign key will want it named after the table it references. The name is quoted by the driver, so it may be any legal identifier -- except one this module writes itself (id, session_data, created, updated, expires), which is refused at construction because _flush would then name the same column twice in one INSERT.
json_module
The module used to serialise the session before encryption. Default JSON::MaybeXS. Any module offering the JSON::XS object interface -- new, utf8, canonical, encode, decode -- will do, including JSON::PP and Cpanel::JSON::XS.
Sessions written with one serialiser are readable by another, since the stored form is plain JSON before encryption.
connect_timeout, statement_timeout
Seconds and milliseconds, defaulting to DEFAULT_CONNECT_TIMEOUT (2) and DEFAULT_STATEMENT_TIMEOUT_MS (2000), applied only to a connection this module opens. A session write that blocks indefinitely blocks a worker indefinitely.
CONNECTIONS
Five ways to give this engine a database, in increasing order of how much Perl you have to write. The first needs none and is the right answer unless you have a reason.
1. Let the engine open its own connection
Give it dsn, dbuser and dbpass and there is nothing else to do. It opens one connection per worker process with AutoCommit on, RaiseError on, a connect timeout and a statement timeout, reconnects once if the database restarts, and commits its own writes. All of it fits in config.yml:
engines:
session:
Pg:
dsn: "dbi:Pg:dbname=app;host=db;port=5432"
dbuser: "app_web"
dbpass: "..."
dbtable: "sessions"
encryption_keys:
0:
key: "${ENV:SESSION_KEY}"
alg: "AES-256-GCM"
active: true
The cost is one more connection per worker than the application strictly needs. With a dozen workers that is a dozen connections; with a thousand it is a reason to read on.
2. Share the application's handle
Everything below passes dbh, and dbh is either a handle or a coderef returning one. Prefer the coderef. It is re-invoked on every access, so it keeps working with a pool that hands out a different handle over time, or after a reconnect that replaced the handle the engine was given once.
A coderef cannot be expressed in YAML, so the engine has to be built in Perl. Do that in the application package, and install it with set_session_engine.
The examples below take the slots from config->{'session_keys'}, meaning a top-level key in config.yml rather than one under engines. The structure is the same either way, and keeping it in the configuration is what lets the keys themselves stay in the environment -- see "Where the key should live":
# config.yml
session_keys:
0:
key: "${ENV:SESSION_KEY_0}"
alg: "AES-256-GCM"
active: true
package MyApp;
use Dancer2;
use Dancer2::Session::Pg ();
app->set_session_engine(
Dancer2::Session::Pg->new(
dbh => sub { MyApp::dbh() },
dbtable => 'sessions',
dbschema => 'web',
encryption_keys => config->{'session_keys'},
session_duration => 900,
)
);
Why not "set session => $engine"
Because it is silently ignored half the time, and the half it is ignored in is the dangerous one.
set session => $engine; # works ONLY if no session engine exists yet
Dancer2's config trigger for session begins is_ref($value) and return $value and never installs the object; only the lazy builder honours a reference, and only if it has not already run. So if anything has touched the session engine first -- another set session earlier in the file, a session: key in config.yml, a plugin, any code that reads a session at startup -- the assignment does nothing at all, no warning is issued, and the application carries on with Dancer2::Session::Simple: sessions in memory, unencrypted, not shared between workers, gone at restart.
An encrypted session store that has quietly become an unencrypted one is not a failure you want to discover from a security review. set_session_engine installs the object unconditionally. Use it.
3. From Dancer2::Plugin::Database
package MyApp;
use Dancer2;
use Dancer2::Plugin::Database;
use Dancer2::Session::Pg ();
app->set_session_engine(
Dancer2::Session::Pg->new(
dbh => sub { database('sessions') },
dbtable => 'sessions',
encryption_keys => config->{'session_keys'},
)
);
with the connection itself still in config.yml, where it belongs:
plugins:
Database:
connections:
sessions:
dsn: "dbi:Pg:dbname=app;host=db"
username: "app_web"
password: "..."
dbi_params:
AutoCommit: 1
RaiseError: 1
Set both dbi_params explicitly rather than relying on what the plugin defaults to. RaiseError: 1 is required -- the engine croaks without it, for the reason in "SHARING A HANDLE" -- and with AutoCommit off this module will not commit the handle, so your sessions wait for a commit that never comes.
4. From DBIx::Class
dbh => sub { return $schema->storage->dbh }
$storage->dbh re-establishes the connection if it has gone away, which is why the coderef form matters here -- a handle fetched once and kept would be the stale one. Note that DBIx::Class does not run the session SQL: this module talks to the handle directly, and your result classes need know nothing about a sessions table.
If your schema is wrapped in transactions -- txn_do, or a test running inside a rolled-back transaction -- read "SHARING A HANDLE" first. Session writes join that transaction.
5. A plain DBI handle
package MyApp;
use Dancer2;
use DBI ();
use Dancer2::Session::Pg ();
my $dbh;
sub dbh {
$dbh = DBI->connect( $dsn, $user, $pass,
{ AutoCommit => 1, RaiseError => 1, PrintError => 0, pg_enable_utf8 => 1 } )
if !$dbh || !$dbh->ping;
return $dbh;
}
app->set_session_engine(
Dancer2::Session::Pg->new(
dbh => \&dbh, dbtable => 'sessions',
encryption_keys => config->{'session_keys'} ) );
Note the ping. A handle created at startup and never checked is a handle that breaks every request after the first database restart -- which is the work the engine does for you in option 1, and which becomes yours the moment you supply the handle.
Do not connect at module scope and hand over the bare handle if the application is preforked: a connection made before the fork is shared by every child, and two children using one PostgreSQL connection corrupt each other's protocol state. Connect lazily, as above, so each worker opens its own.
METHODS
destroy_row
my $deleted = $engine->destroy_row($row_id);
Deletes one row by the value its id column holds -- a digest, as returned by "sessions" or "sessions_for_principal". Returns the number of rows removed, so 0 means there was nothing there.
This exists because the id column stores a digest rather than the session id ("Why the session id is stored as a digest"), which makes the two deletes different operations:
$engine->destroy( id => $from_a_cookie ); # hashes its argument
$engine->destroy_row($from_sessions); # does not
Using the wrong one croaks rather than silently deleting nothing. That is the whole point of the pair: destroy would hash a digest a second time and match no row, which a cleaning script would report as a successful deletion.
So the iteration that Dancer2::Core::Role::SessionFactory describes works:
for my $row_id ( @{ $engine->sessions } ) {
$engine->destroy_row($row_id);
}
Although for the two cases that actually come up, one statement is better than a loop: "reap" for everything expired, "destroy_for_principal" for one account.
destroy_for_principal
my $removed = $engine->destroy_for_principal($principal);
Deletes every session whose principal column matches. Returns the number deleted. This is how an account suspension takes effect immediately.
Croaks if principal_key is not configured -- there is no column to match against, and failing with that sentence is more use than a SQL error.
sessions_for_principal
my $digests = $engine->sessions_for_principal($principal);
Returns an arrayref of row identifiers for a principal's unexpired sessions. Croaks if principal_key is not configured.
Those are digests, not session ids, for the reason in "Why the session id is stored as a digest". A digest is what "destroy_row" takes, so a list from here can be iterated and deleted; it is not what destroy takes, which expects the value out of a cookie.
To end a principal's sessions rather than look at them, "destroy_for_principal" does it in one statement and never puts them in a variable.
count_sessions
my $counts = $engine->count_sessions;
{ live => 12, signed_in => 9, expired => 3 } # with principal_key
{ live => 12, expired => 3 } # without
One query, nothing decrypted, nobody named: the clear columns answer all of it.
live counts sessions that have not expired, expired the rows "reap" would remove now -- a number that only grows if reaping has stopped -- and signed_in those among the live ones carrying a principal.
signed_in is absent, not zero, when principal_key is unset. The column need not exist in that configuration, so there is no query that could answer it. Test with exists if your caller handles both:
my $counts = $engine->count_sessions;
say "signed in: $counts->{'signed_in'}" if exists $counts->{'signed_in'};
reap
my $removed = $engine->reap;
Deletes rows whose expires has passed, and returns the number deleted. An expired row is still encrypted credential material, and keeping it serves no purpose.
Something has to call it. A scheduled job is the obvious answer, but it is not the only one, and this module does not care which you choose -- pruning can just as well live in the database, as a pg_cron job or as a sampled trigger on the table. A database-side rule keeps the retention policy with the data and needs nothing of the application; a trigger, though, prunes only when there is activity, which is worth knowing before choosing one.
algorithms
my @alg = Dancer2::Session::Pg->algorithms;
Returns the built-in cipher names, which are the values a slot's alg accepts by name. A cipher of your own is not listed, because nothing registers it globally -- see "Writing a cipher".
CONCURRENCY
Measured with real processes in t/concurrency.t, not reasoned about. At the default size that is 8 processes making 200 writes to one session id, and the two environment variables in that file turn it up.
What is promised
One row. Concurrent writers to one session id produce a single row. No duplicate, no unique violation, no deadlock, no write that errors out.
The expiry cap holds.
expiresandcreatedare set by the first write and excluded from the conflict update, so after any number of concurrent writesexpiresis still exactlycreatedplussession_duration. This is the invariant worth the most: a race that moved the cap would hand an attacker with stolen cookies a session that never expires.The payload is never torn. A reader sees one writer's complete, decryptable payload. It cannot see half of one write and half of another, because the blob is written as a single value in a single statement.
What is NOT promised
Two workers can lose each other's changes, and will. The session is one encrypted blob, so a write replaces the whole of it:
worker A worker B
read { cart => [apple] }
read { cart => [apple] }
write { cart => [apple, pear] }
write { cart => [apple], step => address }
# the result holds B's step and has lost A's pear
Last writer wins, outright. That is not a defect of this module -- it is what any session store holding a serialised blob does, including Dancer2::Session::Simple and Dancer2::Session::YAML -- but it is worth knowing before putting something in the session that two concurrent requests might both change. A counter, a running total or a list that several requests append to belongs in a table of its own, where the database can do the arithmetic. In practice this bites hardest with parallel XHR from one page.
Which worker wins is a race, so nothing should depend on it.
A blocked write fails rather than waiting
A write that cannot proceed -- because some other transaction holds that session's row and has not committed -- is cancelled by statement_timeout and the request fails. It does not wait for the commit. That is deliberate: a session is not worth waiting on, and a worker blocked indefinitely is worse than a failed request. The usual causes are outside the application: an administrative query, a migration, a pool connection somebody left in a transaction.
The failure is transient and leaves nothing behind; once the other transaction ends, the same session id writes normally. This is tested too.
Note that this applies only to a connection this module opened. On a handle you supplied, statement_timeout is yours to set -- see "SHARING A HANDLE" -- and if you have not set one, a blocked session write waits as long as the database makes it wait.
SECURITY
The threat this module is built for
Somebody reads the database and does not have the application. A backup, a replica, a support copy of a table, a dump in a ticket, a stolen disk. The payload is encrypted with an AEAD cipher, so what they get is the shape of your session traffic and not the credentials in it.
It is not built for an attacker who has the application's memory or its configuration. The key is in the process, so anyone who can read the process or the file the key came from can read every session. Encryption at rest moves the secret from the database to the key store; it does not remove it.
Why the session id is stored as a digest
Encrypting the payload would be half a job. The session id is itself a bearer token -- Dancer2 reads it from the cookie and this module hands back whatever it unlocks -- so a table storing ids verbatim would contain a working credential for every unexpired session. Someone who read a backup could replay any of them against a reachable instance of the application and be logged in as that user, without the encryption key and without decrypting anything. The most carefully sealed payload in the world does not help if the key to the front door is in the same dump.
So the id column holds SHA-256 of the session id. The cookie is unchanged, every lookup hashes first, and what a dump yields is digests.
The digest is unkeyed, on purpose. A keyed digest would additionally stop an attacker confirming a guessed id, but Dancer2's ids are long and random, which is the same reason an API token needs no salt. More importantly a key here could never be rotated -- rotating it would orphan every live session -- and the whole design of "Rotating the key" is that keys rotate. A key that cannot rotate, sitting among keys that must, would be a worse trap than the attack it prevents.
Two consequences worth knowing:
_sessionsreturns what is in the column, so the values it hands back are digests and not session ids. They are useful for counting and for nothing else; you cannot turn one back into a cookie, which is the point.The column is 64 hex characters rather than Dancer2's id, so size your index accordingly if you are tuning.
Where the key should live
Not in a configuration file you commit. A key in config.yml is a key in your version control history permanently, readable by everyone who can clone the repository and by every CI job that ever checked it out -- and rotating it then means rotating something that is still in the history.
Keep the key in the environment and have the configuration refer to it. Dancer2::ConfigReader::Config::Any describes the mechanism in its own DESCRIPTION: a config reader that extends it and expands ${ENV:NAME} placeholders as the configuration is read. The configuration then names the variable and never holds the value:
# config.yml -- committed, and contains no secret
engines:
session:
Pg:
dbtable: "sessions"
encryption_keys:
0:
key: "${ENV:SESSION_KEY}"
alg: "AES-256-GCM"
active: true
Select the reader with the DANCER_CONFIG_READERS environment variable, or from the configuration itself with an additional_config_readers key. Under Kubernetes the variable comes from a Secret, so the key reaches the process without being written down anywhere in the image or the repository, and a rotation is a Secret update and a rolling restart -- which is exactly what "Rotating the key" is about.
Generate a key with openssl rand -hex 32. Do not share one between deployments: two environments holding one key means a session row copied from either is valid in the other.
Rotating the key
Changing a single key is not one clean logout. It is a rolling outage.
That is worth stating plainly because it is easy to assume otherwise. During a rolling redeploy two generations of the application serve at once. If they hold different keys, each generation cannot read what the other wrote -- so a user is thrown out, logs in again on a pod of the other generation, and is thrown out again, for as long as the rollout takes. Measured against a half-rolled fleet:
one slot, key replaced: 6 of 12 requests lost the session
two slots, active moved: 0 of 12 requests lost the session
Slots are what make the second line possible, and the rotation can change the cipher at the same time, because the cipher belongs to the slot. A key that has to be replaced and an algorithm that has to be replaced are the same operation, which is convenient, since the reasons tend to arrive together.
The procedure
Three deployments. The middle one is the only one that changes behaviour, and no session is lost at any point.
- 1. Add the new slot, leave the old one active
-
encryption_keys: 0: key: "${ENV:SESSION_KEY_0}" alg: "AES-256-GCM" active: true 1: key: "${ENV:SESSION_KEY_1}" alg: "ChaCha20-Poly1305" # changing the cipher too, if you likeNothing changes yet. When this has finished rolling, every pod can read both slots, which is the condition the next step needs.
- 2. Move
active -
encryption_keys: 0: key: "${ENV:SESSION_KEY_0}" alg: "AES-256-GCM" 1: key: "${ENV:SESSION_KEY_1}" alg: "ChaCha20-Poly1305" active: trueDuring the rollout the old generation writes slot 0 and the new one writes slot 1, and both read both, so a request landing on either finds its session. New sessions are sealed with the new key, and the new cipher, from here.
- 3. Drop the retired slot, once its sessions have expired
-
Wait longer than
session_duration-- after that no readable row can still be under slot 0 -- then remove it and restart:encryption_keys: 1: key: "${ENV:SESSION_KEY_1}" alg: "ChaCha20-Poly1305" active: trueRemoving it earlier logs out whoever still holds a session written under it. The engine says so when it meets one:
key id 0 is not configured.
Each step is a configuration change and a rolling restart, which under Kubernetes is a Secret update and kubectl rollout restart. Do not collapse steps 1 and 2 into one deployment: that is the single-slot case again, because the pods writing slot 1 roll out alongside pods that have never heard of it.
One slot is still the simple case
A deployment that never rotates configures one slot and is done. There is no separate single-key form to migrate away from later: adding a second slot is the first step above, and the slot already there keeps its id, so every row it has written stays readable.
Changing the key length
Nothing special is needed, which is the point of pairing a key with its cipher. Each slot's key is checked against that slot's cipher and nothing else, so two slots may hold keys of different lengths:
encryption_keys:
0: { key: "${ENV:OLD_16_BYTE_KEY}", alg: "AES-128-GCM", active: true }
1: { key: "${ENV:NEW_32_BYTE_KEY}", alg: "AES-256-GCM" }
Then move active as in step 2. Getting off a 16-byte key without logging anybody out was impossible in the draft that kept the key and the cipher apart.
The one mistake slots cannot prevent
Editing a slot's alg in place, rather than giving the new cipher a slot of its own. The key is still right, so the row's key id still finds its slot -- but the cipher that wrote it is gone, and those sessions are stranded.
The cipher id in the header exists to make that diagnosable rather than mysterious. The engine reports:
slot 0 now says ChaCha20-Poly1305, but this row was written with cipher
id 3. Changing a slot's alg in place strands its sessions; give the new
cipher a slot of its own
Rotate before 2**32 writes
Every write draws a fresh 96-bit nonce from Crypt::PRNG. That is the right construction, and it has a ceiling: with random nonces of that size, NIST SP 800-38D limits a single key to 2**32 encryptions, after which a nonce collision becomes likely enough to matter, and a collision in GCM is not a degradation but a break.
2**32 is about 4.3 billion session writes. That sounds unreachable and is not: a site writing a thousand sessions a second gets there in about seven weeks. Count your own writes rather than assuming -- one per authenticated request is a fair estimate, since Dancer2 flushes a session it considers dirty.
This is the reason slots exist rather than being a nicety. Reaching the ceiling is not an emergency if rotation costs a rolling restart and nobody notices.
A payload is sealed against its session id
The header and the session id go into the cipher as additional authenticated data, so a sealed payload only opens under the id it was written for.
This matters more than it looks. Without it the tag covers the payload and nothing else, which makes a payload portable between rows: somebody with UPDATE on the sessions table and no key at all could copy an administrator's session_data into their own row, and their own cookie would then open an administrator's session. Binding the id turns that copy into a row that does not decrypt. It is tested, in t/cipher.t and against a real database in t/session_pg.t.
The cost is that renaming a row is no longer a rename: "destroy_for_principal" is unaffected, but session-fixation protection -- change_id on login -- has to re-seal the payload under the new id, which is one extra round trip per login.
A cipher of your own must pass the additional data through. Dancer2::Session::Pg::Cipher tests that it does, because a cipher that accepts it and silently drops it would remove this protection while everything appeared to work.
What the clear column discloses
If you use the principal column, understand what a database dump then contains: which accounts had sessions, how many, and when they were created and last used. That is the trade. The ability to revoke an account's sessions without decrypting anything is the same property as the ability to read that pattern off a backup.
Nothing else leaves the payload. If even that pattern is too much, leave principal_key unset and revoke by expiry.
Configuration is trusted input
A slot's alg and json_module name a class that this module loads. A deployment that lets an untrusted party influence its session configuration has already lost, but to be explicit: these are not safe to take from a request, a database row, or a file anybody else can write.
What is logged, and why it is so little
The session id is a bearer token. Dancer2 takes it straight from the session cookie, so an id written to a log is a live credential written to a log, and read access to that log becomes session hijacking. The principal is out for the same reason and for a second one: it names a person.
So this module logs nothing per session -- no ids on a retrieve, a write, a destroy, or a failure to decrypt. What it does log is per process and per configuration, each message once, through the engine's log_cb:
warning a stored session could not be read, and which of the
reasons it was. No id, so a run of these means "the key
changed" or "somebody is poking at cookies" without
saying whose session.
warning principal_key produced a reference rather than a value,
so the principal column was left NULL and
destroy_for_principal will not find those sessions. The
value is NOT logged; it is session content.
warning a supplied dbh has AutoCommit off, which is also carped
so that it reaches an engine built outside Dancer2.
info the connection had gone away and was reopened. One after
a database restart is the system working; a stream of
them is something killing connections.
Each is reported once per engine rather than once per request, because anything on the request path otherwise produces a log nobody can read -- and because a repeated message is how an attacker poking at cookies fills your disk.
If you want per-session audit logging, do it in the application, where you have the request, the user and a policy about retention. A session store is the wrong layer to decide that credentials belong in a log file.
WHAT AN UNREADABLE ROW DOES
A row that cannot be decrypted -- wrong key, a cipher this engine cannot read, a payload somebody has edited -- is treated as a session that does not exist. Dancer2 then starts a fresh one, so the user is logged out rather than shown an error. That is deliberate: the alternative is handing the application a structure whose integrity has not been established.
It is also reported, once per engine per distinct reason, through the engine's log_cb. Fail-closed without a trace is how a changed encryption_key becomes "users keep getting logged out" with nothing written down anywhere; once per reason rather than once per request is so the log stays readable when somebody is poking at cookies.
LIMITATIONS
No cross-process invalidation. A logout deletes the row, so another process sees it gone on its next read, but there is no push: PostgreSQL LISTEN/NOTIFY would allow one, and _destroy and _change_id are where it would be emitted.
Key rotation needs three deployments and some patience, but it costs no sessions: see "Rotating the key". The old key has to stay in encryption_keys until everything written under it has expired, so the whole exercise takes longer than session_duration from start to finish, and there is no way to shorten that without logging those sessions out.
Changing the key length, or the cipher, works the same way and in the same step; see "Changing the key length" and "Replacing a cipher".
The test suite needs a PostgreSQL cluster the test user may create databases on. Where there is none it skips, which means a smoke-test report of "pass" from such a machine has exercised the unit tests only.
SEE ALSO
Dancer2::Session::Pg::Cipher -- the cipher contract, if you are writing one
Dancer2::ConfigReader::Config::Any -- how to keep the key out of the config file
AUTHOR
Mikko Koivunalho <mikko.koivunalho@iki.fi>
LICENSE AND COPYRIGHT
This software is copyright (c) 2026 by Mikko Koivunalho.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.