Repository navigation
feat: harden session stores and add encryption for redis and database - #104
Merged
Merged
Conversation
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
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.
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hey! 👋
Problem
Requests from one browser can overlap. A logout, a
regenerate()or aSessionCollection.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:Sessionkept that id and wrote the new guest session under it.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.tagged()call betweentag()andwrite()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:
SET … XXin Redis,UPDATEwithoutINSERTin SQL, a file opened withr+, andattribute_existsin DynamoDB. A new session is written as before.SessionCollectionis a singleton, so it now makes a new store for each operation.renewMissingSessionIdoption (defaultfalse). Ininitiate(), if the store does not find the id from the cookie, the session gets a newrandomUUID()and isfresh. 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 usesession.sessionIdas a key for an empty session.refreshCookieoption (defaulttrue, so no change for existing apps). Withfalse, the session cookie is sent only when the session id changes or when the browser did not send one. Tradeoff: the cookieMax-Ageis not refreshed, so use this option withclearWithBrowserand let the store expire inactive sessions.tag()keeps the user id andwrite()adds the session to the user's set in the sameMULTI(standalone Redis), or storesuser_idin the same query as the data (SQL).tag()also applies immediately to a session that exists, soSessionCollection.tag()continues to work. For a session that the store read, the write does not tag it again, so anuntag()from another request stays. Redis Cluster refuses aMULTIover keys of different slots, so on a cluster the store sets the data and then adds the id, outside a transaction.encryptoption forstores.redis()andstores.database()(defaultfalse). The data is encrypted with the encryption service, with the session id as purpose.refreshCookie: false, the api client loads the session id it sent when the response has no session cookie.SessionClientnow waits forwrite()anddestroy().Example
Behavior changes
No option changes its default, so the session id, the cookie and the stored data format stay as before. These behaviors change:
tag()does not create a row for a session that has no data.encrypt, the existing sessions cannot be read. Each user starts a new session one time.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:
ulimit -f 2, the write of a session that the store read now rejects withEFBIGinstead of leaving a truncated file.CROSSSLOT. On a cluster, atagged()call that reads the set before the data and removes the id after theSADDcan still drop the session.awaitfix inSessionClient.r+write against a concurrent delete is not tested on Windows.refreshCookie: false, an empty guest session gets a new id on each request, so its cookie is still sent each time.