When opening DevTools in Odoo, ever wondered what hides behind the session_id cookie? Sessions are the invisible backbone of every authenticated interaction in Odoo. Understanding them makes debugging faster, security reasoning clearer, and production operations less opaque.
One scope note up front: this post covers HTTP sessions, the mechanism that tracks authenticated browser connections. POS sessions are a separate concept tied to the Point of Sale module and are not covered here.
This is the first post in a three-part series. Post 1 (this one) covers the current session implementation in Odoo 19.0. Post 2 covers community modules that extend or replace it. Post 3 is a FAQ answering the session questions we hear most often in production.
What Is a Session?
From the user perspective
A web application is built on individual HTTP requests. Each request is stateless: the server receives it, sends a response, and forgets the exchange. A “session” is the mechanism that groups a series of requests under one authenticated identity. It starts when a user logs in and ends when they log out or the session expires.
From a debugging standpoint, this matters. When a user reports a problem, the interesting unit is often not a single request but a sequence: what requests preceded the failure, what session state they operated under, and whether a concurrent request may have altered that state.
From the server perspective
When a user logs in, Odoo creates a session and assigns it a unique identifier: the session_id. The server returns it to the browser via the Set-Cookie response header:
Set-Cookie: session_id=bbe0bb65...; Expires=...; Max-Age=604800; HttpOnly; Path=/
The Max-Age of 604800 seconds is seven days, the default session lifetime since Odoo 16.0 (it was 90 days before). The browser stores the cookie and attaches it to every subsequent request:
Cookie: tz=Asia/Saigon; session_id=bbe0bb65...
The server reads the cookie, looks up the matching session, and reconstructs the user’s state. The HttpOnly flag is intentional: it blocks JavaScript from reading the cookie, which closes a class of XSS-based session theft.
What Does Odoo Put in a Session?
The Session class
Session is a collections.abc.MutableMapping subclass: a dict-like object with dirty-tracking built in [10]. Each write to a session field flips an internal is_dirty flag, which the request lifecycle uses to decide whether to save the session file at request end. Internal bookkeeping is kept in __slots__, strictly separated from the data that gets persisted:
class Session(collections.abc.MutableMapping):
__slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new', 'should_rotate', 'sid')
The slots (is_dirty, should_rotate, can_save, is_new, sid) are never written to disk. Only the dict data is serialised.
The session fields
The canonical set of fields for a fresh session is defined in get_default_session() [1]:
uid: the authenticated user ID (integer), orNonefor anonymous sessions. This is the key that binds a session to a user.db: the database name. Odoo is multi-tenant; every session is scoped to exactly one database.login: the user’s login string (typically an email address). Used for display and as an input to the session token computation.context: the user’s preference dict, containing language, timezone, and other settings. It is forwarded on every RPC call, so changing a user’s language here takes effect immediately without a new login.session_token: an HMAC-SHA256 value that binds the session to the user’s current state. Covered in Section 4.debug: debug mode flags (string). Controls whether the debug toolbar and extra logging are active.create_time: a float timestamp recording when the session was created. Used to trigger soft rotation every three hours._trace: device log data used by the “Connected devices” feature, which records the IP addresses and browsers that have accessed the session [8].
Where Does Odoo Store Sessions?
File-based storage
Odoo sessions are JSON files on disk. They are not rows in the database and not in-memory state. The default location is {data-dir}/sessions/, where data-dir is set via --data-dir at startup. Each file is named after the session ID.
Werkzeug integration (vendored)
Odoo builds its session store on top of Werkzeug’s FilesystemSessionStore, originally from werkzeug.contrib.sessions. Werkzeug removed the contrib package in v1.0. Rather than rewriting the store, Odoo vendored the module at odoo/tools/_vendor/sessions.py [12] and subclasses it to add scatter-directory logic.
The 4096-subdirectory scatter
A busy Odoo instance can accumulate tens of thousands of session files. Storing them all in a single directory degrades filesystem performance on most systems. Odoo scatters files across 4096 subdirectories: the directory name is the first two characters of the session ID. With 64 possible values per position, that produces 64x64 = 4096 directories [2]:
def get_session_filename(self, sid):
sha_dir = sid[:2]
dirname = os.path.join(self.path, sha_dir)
return os.path.join(dirname, sid)
A session with ID bbe0bb65... is stored at sessions/bb/bbe0bb65....
Session ID format
Session IDs are 84-character base64url strings, generated by taking the first 63 bytes of the SHA-512 hash of the current timestamp concatenated with 64 bytes of OS randomness, then base64url-encoding without padding [3]:
def generate_key(self, salt=None):
key = str(time.time()).encode() + os.urandom(64)
hash_key = sha512(key).digest()[:-1]
return base64.urlsafe_b64encode(hash_key).decode('utf-8')
This gives approximately 217 bits of entropy per session ID. The URL-safe alphabet means the ID can be used directly as both a cookie value and a filename without escaping. The first 42 bytes of the ID (the value of STORED_SESSION_BYTES) have a special role: they remain stable across soft rotation, which matters for CSRF token validity (Section 4 and Section 5).
How Does Odoo Secure Sessions?
The problem: a cookie is not enough
If an attacker intercepts a session_id cookie, they can replay it. A server that trusts the cookie value alone cannot tell the request from the legitimate user. Odoo adds a second layer of verification that does not travel over the wire.
Session tokens
Every session stores a session_token: an HMAC-SHA256 value computed from the session ID, keyed with user-specific state: database.secret, the user’s login, their hashed password, and their active flag [5]. On every authenticated request, Odoo recomputes the expected token from the live database state and compares it against the stored value using constant-time comparison to prevent timing attacks [8].
The consequence is significant: changing a user’s password, deactivating their account, or rotating database.secret immediately invalidates all of their sessions. An attacker holding a valid session_id cookie gains nothing if the recomputed token no longer matches. Legacy token formats from earlier Odoo versions are auto-upgraded on the first successful check with no migration step required.
CSRF tokens
CSRF tokens are a separate mechanism protecting against cross-site request forgery (a different attack from session hijacking). Odoo generates them as HMAC-SHA1 values keyed with database.secret, over a message composed of the first 42 bytes of the session ID plus a timestamp [6]:
msg = f"{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}".encode()
hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
The token embeds its own expiry: max_ts = now + CSRF_TOKEN_SALT (one year). The timestamp is included primarily as a BREACH mitigation: because max_ts changes on every call, each generated token looks different even within the same session, making compression oracle attacks harder. In practice, CSRF tokens are not long-lived. They are bound to the first 42 bytes of the session ID, so they are invalidated immediately on logout (hard rotation replaces those bytes). The one-year ceiling is effectively bounded by the session’s own inactivity expiry of seven days.
CSRF tokens survive soft rotation because soft rotation preserves the first 42 bytes of the session ID. This is the reason that prefix exists.
The role of database.secret
database.secret is the root cryptographic key for both session tokens and CSRF tokens. It is stored in ir.config_parameter and rarely changed in practice. Rotating it immediately invalidates all sessions for all users across the entire database: useful as an emergency response to a suspected compromise, but disruptive in normal operation.
The Session Lifecycle
Birth: the anonymous session
Every first request to Odoo, even before login, receives a session. That session has uid=None and db=None. The session file is created lazily: it is only written to disk if the session becomes dirty during the request.
Login: hard rotation
On successful authentication, Odoo performs a hard rotation: a new session ID is generated, and the old session is scheduled for deletion after a 120-second grace period (to avoid dropping any concurrent requests still using the old ID). The new session receives uid, db, login, context, and a freshly computed session_token. The browser receives the updated session_id cookie.
Active session: soft rotation every three hours
Every three hours of activity, Odoo performs a soft rotation [7]. The first 42 bytes of the session ID are preserved; the last 42 are regenerated (the full ID is 84 characters). The old session stores a next_sid pointer so any concurrent request arriving with the old ID is transparently redirected:
if soft:
static = session.sid[:STORED_SESSION_BYTES]
next_sid = static + self.generate_key()[STORED_SESSION_BYTES:]
session['next_sid'] = next_sid
session.sid = next_sid
This refreshes the session ID for security while keeping CSRF tokens valid throughout the user’s working session.
Logout: hard rotation again
Logout triggers another hard rotation: the session ID is fully replaced, the old session is scheduled for deletion, and the browser receives a new, empty session cookie.
Why an active session never actually expires
A cookie’s Max-Age is a one-shot instruction to the browser: delete this in N seconds unless told otherwise. The browser does not ask the server whether the cookie is still good; it just counts down locally. So for a session to survive past its Max-Age, the server has to keep reissuing the cookie with a fresh countdown before the old one runs out.
That reissue happens at the end of every request, but only when the session is dirty or its ID changed [9]:
if sess.is_dirty or cookie_sid != sess.sid:
self.future_response.set_cookie('session_id', sess.sid,
max_age=get_session_max_inactivity(env), httponly=True)
is_dirty is not set on every request; it is only set when something writes to the session data. On an authenticated request, that write is guaranteed at least once per hour by check_session() [8], which touches the session’s device-trace entry (_trace) once the last update on that device is more than an hour old. That single write is enough to mark the session dirty, trigger a save, and reissue the cookie with a full new Max-Age.
The same mechanism protects the file on disk: vacuum() reaps sessions based on the file’s modification time, and that mtime only advances when the session is saved — the same hourly write keeps it current.
The practical consequence: “sessions expire after seven days of inactivity” does not mean a session cookie set once counts down to zero after a week of active use. It means a short-lived cookie (seven days) gets silently reissued roughly every hour for as long as the user is active, so the countdown never reaches zero. Only once requests actually stop does the last-issued cookie run out its clock, and the file’s mtime stop advancing.
Expiry
Sessions expire after seven days of inactivity by default, controlled by the sessions.max_inactivity_seconds system parameter. That single value drives both the cookie Max-Age sent to the browser and the server-side reaping threshold [13]. This consistency dates from Odoo 16.0: before that, the cookie was valid for 90 days while server-side garbage collection reaped sessions after 7 days of inactivity, so the cookie lifetime was effectively meaningless.
One operational nuance: expired session files are not deleted at the moment of expiry. Reaping happens when the “Base: Auto-vacuum internal data” cron runs _gc_sessions(), once per day by default [14]. If you set a short sessions.max_inactivity_seconds, increase that cron’s frequency accordingly. Note that Odoo does not check session age at request time: a browser drops the cookie when Max-Age elapses, but a non-browser client that keeps the cookie can continue using a session until the cron actually deletes the file. The effective cutoff is the reaping, not the threshold.
At request end, _save_session() works through a decision chain [9]: hard rotate if should_rotate is set, soft rotate if three hours have elapsed, write to disk if is_dirty, or do nothing.
Key Takeaways
Odoo sessions are JSON files on disk, each identified by an 84-character base64url string. The session_token field inside each file is an HMAC-SHA256 value that binds the session to the user’s current authentication state. Changing a password or deactivating an account invalidates all sessions for that user immediately, with no cache flush or manual step required. Soft rotation every three hours refreshes the session ID without breaking in-flight CSRF tokens.
With this model in hand, you can open any session file in {data-dir}/sessions/, read every key, and explain exactly what it is there for.
Sources
[1] odoo/http.py:234-245: get_default_session()
[2] odoo/http.py:968-1006: FilesystemSessionStore (subdirectory scatter)
[3] odoo/http.py:1050-1070: generate_key() (84-char base64url, ~217 bits entropy)
[4] odoo/http.py:1459, 1786-1824: _get_session_and_dbname(), cookie reading
[5] odoo/addons/base/models/res_users.py:851-884: _compute_session_token(), HMAC-SHA256
[6] odoo/http.py:1907-1956: csrf_token(), validate_csrf()
[7] odoo/http.py:1008-1040: rotate() (soft/hard rotation)
[8] odoo/service/security.py:13-33: check_session(), device tracking, legacy token upgrade
[9] odoo/http.py:2133-2168: _save_session(), cookie setting
[10] odoo/http.py:1110-1142: Session class definition
[11] odoo/addons/base/models/res_users.py:832-856: _get_session_token_query_params()
[12] odoo/tools/_vendor/sessions.py: vendored Werkzeug contrib.sessions
[13] odoo/http.py:452-461: get_session_max_inactivity() (reads sessions.max_inactivity_seconds)
[14] odoo/addons/base/models/ir_http.py:406-410: _gc_sessions() (autovacuum cron)