Skip to content

feat: harden session stores and add encryption for redis and database - #104

Merged
RomainLanz merged 14 commits into
8.xfrom
fix/concurrent-session-robustness
Oct 17, 2026
Merged

RomainLanz merged 14 commits into
8.xfrom
fix/concurrent-session-robustness

Conversation

@RomainLanz

@RomainLanz RomainLanz commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Hey! 👋

Problem

Requests from one browser can overlap. A logout, a regenerate() or a SessionCollection.destroy() can occur while another request runs. The session id is a UUID in a signed cookie, so a client cannot forge it. But the overlap could bring an ended session back:

request A (slow)              request B (logout)
read session S
                              destroy S
write S  → before: S exists again with the data A read
           now:    the write does nothing, S stays ended
  1. The redis, database, file and dynamodb stores wrote back a session that another request destroyed.
  2. When the cookie named a session that the store did not have (expired or destroyed), Session kept that id and wrote the new guest session under it.
  3. commit() sent the session cookie on each response. A late response could put back an id that the browser had left, for example the guest id after a login that regenerated the session.
  4. In Redis, a tagged() call between tag() and write() removed the new session from the user's set, because the session had no data yet.

Also, the redis and database stores only sign the data, and the japa plugins always wrote the test session to the memory store.

Solution

One commit for each change:

  • Stores do not write back a destroyed session. A store serves one request. It keeps the ids that it found on read. For these ids, the write updates the session only if it still exists: SET … XX in Redis, UPDATE without INSERT in SQL, a file opened with r+, and attribute_exists in DynamoDB. A new session is written as before. SessionCollection is a singleton, so it now makes a new store for each operation.
  • renewMissingSessionId option (default false). In initiate(), if the store does not find the id from the cookie, the session gets a new randomUUID() and is fresh. The id of an ended session is not used again. It is opt-in because a session without data then gets a new id on each request, and some apps use session.sessionId as a key for an empty session.
  • refreshCookie option (default true, so no change for existing apps). With false, the session cookie is sent only when the session id changes or when the browser did not send one. Tradeoff: the cookie Max-Age is not refreshed, so use this option with clearWithBrowser and let the store expire inactive sessions.
  • Tag and write are atomic. For a new session, tag() keeps the user id and write() adds the session to the user's set in the same MULTI (standalone Redis), or stores user_id in the same query as the data (SQL). tag() also applies immediately to a session that exists, so SessionCollection.tag() continues to work. For a session that the store read, the write does not tag it again, so an untag() from another request stays. Redis Cluster refuses a MULTI over keys of different slots, so on a cluster the store sets the data and then adds the id, outside a transaction.
  • encrypt option for stores.redis() and stores.database() (default false). The data is encrypted with the encryption service, with the session id as purpose.
  • japa plugins use the configured store. For the cookie store, they use the memory store, because the cookie store needs an HTTP request. Each call of the test client gets a new store, so a session read before a logout can be set again. With refreshCookie: false, the api client loads the session id it sent when the response has no session cookie. SessionClient now waits for write() and destroy().

Example

// config/session.ts
export default defineConfig({
  age: '2h',
  clearWithBrowser: true,
  refreshCookie: false,
  renewMissingSessionId: true,
  store: 'redis',
  stores: {
    redis: stores.redis({ connection: 'main', encrypt: true }),
  },
})

Behavior changes

No option changes its default, so the session id, the cookie and the stored data format stay as before. These behaviors change:

  • A write does not bring back a session that another request destroyed during the request. This is the fix.
  • The database store tag() does not create a row for a session that has no data.
  • When you enable encrypt, the existing sessions cannot be read. Each user starts a new session one time.
  • With an external store in tests, browserContext.close() now waits for the test session to be destroyed, and rejects if the store fails.

Tests

Each change has a test that fails without the fix, except the two items below. 244 tests pass on sqlite, postgres and mysql, with real redis and dynamodb-local.

Not covered:

  • No test makes the file store write fail part way. With ulimit -f 2, the write of a session that the store read now rejects with EFBIG instead of leaving a truncated file.
  • No test runs on Redis Cluster, because the CI has no cluster. The cluster path was checked by hand on a local one-node cluster: before the fix, the write of a tagged session failed with CROSSSLOT. On a cluster, a tagged() call that reads the set before the data and removes the id after the SADD can still drop the session.
  • The memory store does not have the guard of the first change.
  • No test fails without the await fix in SessionClient.
  • The file store r+ write against a concurrent delete is not tested on Windows.
  • With refreshCookie: false, an empty guest session gets a new id on each request, so its cookie is still sent each time.

A request that read a session and another that destroyed it (logout,
SessionCollection.destroy) could interleave. The first request's write
then recreated the session with the data it had read: setex in Redis,
an upsert in SQL, a plain file write, a PutItem in DynamoDB.

The redis, database, file and dynamodb stores now remember the ids they
found while reading. A store serves one request, so a write to one of
those ids only updates a session that still exists: SET ... XX in
Redis, an UPDATE without INSERT in SQL, a file opened with "r+", and a
PutItem guarded by attribute_exists in DynamoDB. A session the store
did not read is written as before.

SessionCollection is a container singleton, so it now creates a store
per operation instead of keeping one for the life of the process.
When the session cookie named a session the store no longer had
(expired, destroyed by a logout or SessionCollection.destroy), Session
kept that id and wrote the new guest session under it. A late request
that had read the old session could then find a key to write over, and
the id of an ended session lived on.

initiate now picks a fresh randomUUID() when the cookie carries an id
the store does not find. The session is treated as fresh: it has no
previous id to destroy or migrate from.

The increment/decrement tests sent a cookie with no stored data and
expected the write under that id; they now start without a cookie.
Session.commit sends the session id cookie on every response. When
requests from one browser overlap, a late response can put back an id
the browser already left, for instance the guest id after a login that
regenerated the session.

With refreshCookie: false, the cookie is only sent when the session id
changes (new session, regenerate) or the browser did not send one. It
defaults to true, so nothing changes for existing apps.

The tradeoff: the cookie Max-Age is no longer refreshed by each
request. The option fits with clearWithBrowser, letting the store
expire inactive sessions through its age.
Session.tag runs before commit writes the session. In Redis, a
concurrent tagged() call that ran in between saw an id without data
and removed it from the user's set, so the logged-in session was
missing from it for good. In SQL, tag upserted a placeholder row with
empty data, which tagged() listed and which stayed behind when the
session was never written.

The redis and database stores now remember the user a session was
tagged with. Their write adds the id to the user's set in the same
MULTI (Redis) or stores user_id in the same query as the data (SQL).
tag still applies right away to a session that exists, so
SessionCollection.tag keeps working, but the database store no longer
inserts a row for a session without data.
The redis and database stores only signed the session data with
MessageBuilder, so anyone with read access to Redis or the sessions
table could read it in clear text. The cookie store already encrypts.

stores.redis() and stores.database() accept encrypt: true. The store
then encrypts the data with the encryption service, using the session
id as purpose, and decrypts it on read and in tagged(). It defaults to
false. Sessions written before enabling it no longer decode, so their
users start a new session once.
The api and browser client plugins always wrote sessions to the memory
store, so withSession and setSession only reached the application when
its tests also ran on the memory store. An app running its tests on
redis or the database could not seed a session.

Both plugins now create the store configured by "store", falling back
to memory for the cookie store, which needs an HTTP request.
SessionClient also awaits the store write and destroy, which async
stores need before the request is sent.
@RomainLanz
RomainLanz marked this pull request as draft October 17, 2026 16:11
Writing a tagged session sent SET and SADD in one MULTI. The session
key and the tag set key are in different hash slots, so Redis Cluster
refused the transaction with CROSSSLOT, and the commit after a login
failed.

On a cluster connection, the store now sets the data and then adds the
id to the tag set, outside a transaction. A standalone connection
keeps the MULTI. On a cluster, a tagged() call that read the set
before the data and removes the id after the SADD can still drop it.
The api client loaded the response session only from the session
cookie of the response. With refreshCookie: false, a request that
kept its session id got no cookie, so response.session() was empty.

When the response has no session cookie and refreshCookie is false,
the client now loads the session id it sent.
A store remembers the sessions it read and writes them back only while
they exist. The browser client kept one store for the whole browser
context, so after getSession() and a logout that destroyed the
session, a later setSession() was silently dropped.

The test store now creates a new store for each read, write, destroy
and touch call.
@RomainLanz RomainLanz changed the title fix: keep ended sessions ended when requests overlap feat: harden session stores and add encryption for redis and database Oct 17, 2026
tag() applied the tag right away and also kept it for the next write.
For a session the store had read, another request could untag it in
between, and the write then tagged it again.

The redis and database stores now keep the tag for the write only for
a session they did not read. A session they read already has data, so
the tag applied by tag() is enough.
The file store rewrote a session it had read with a single
FileHandle.write() call, which ignores how many bytes were written. A
write cut short (for example by a file size limit) resolved and left a
truncated file.

It now uses FileHandle.writeFile() on the "r+" handle, which writes
all the bytes or rejects, as writeFile() did before.
The browser client destroyed the test session in a "close" listener.
Playwright does not wait for its listeners, so with an async store the
destroy still ran after close() resolved. On Windows CI, it raced the
removal of the temporary directory and failed with an unhandled EPERM.

close() now destroys the session first, then closes the context.
@RomainLanz
RomainLanz marked this pull request as ready for review October 17, 2026 17:04
Giving a new id when the cookie names a session the store does not
have changed a behavior apps can rely on: a session without data kept
its id across requests. Apps that use session.sessionId as a key while
the session is empty (a guest cart, rate limiting) saw it change on
each request.

The new id is now behind renewMissingSessionId, which defaults to
false. Without it, Session keeps the cookie id as before.
@RomainLanz
RomainLanz merged commit 088965c into 8.x Oct 17, 2026
14 checks passed
@RomainLanz
RomainLanz deleted the fix/concurrent-session-robustness branch October 17, 2026 18:36
RomainLanz added a commit to adonisjs/v7-docs that referenced this pull request Oct 17, 2026
@adonisjs/session 8.2.0 adds options against overlapping requests
and changes how the japa plugins store test sessions. The guides did
not cover them and still said tests must use the memory driver.

Document refreshCookie, renewMissingSessionId and the encrypt option
of the redis and database stores, with their tradeoffs. A new
"Handling concurrent requests" section explains that stores no longer
write back a destroyed session, that sessions are not locked, and
recommends regenerating the session on logout. The testing guides now
say the plugins write to the configured store, with memory only
required for apps using the cookie store.

Related adonisjs/session#104
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant