# Session Management in Odoo, Part 1: Core Concepts

> What Odoo stores in a session, where the files live on disk, how the session token binds a session to the user's password, and why an active session never expires.

**Date:** 2026-05-30
**Source:** <https://trobz.com/insights/odoo-session-internals/>

---



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]({{< surl >}}/insights/odoo-session-modules) covers community modules that extend or replace it. [Post 3]({{< surl >}}/insights/odoo-session-faq) 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:

```python
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), or `None` for 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]:

```python
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]:

```python
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]:

```python
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:

```python
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]:

```python
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)

