Skip to content

Sessions

A session is a per-user dictionary that survives across requests. Veloce offers two backends: SessionMiddleware keeps the whole payload in a signed, timestamped cookie, and ServerSessionMiddleware keeps only an opaque id in the cookie and stores the payload server-side.

A first session

Install SessionMiddleware with a secret key, then read and write request.session like a dict:

from veloce import Request, SessionMiddleware, Veloce

app = Veloce()
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")


@app.get("/count")
async def count(request: Request):
    visits = request.session.get("visits", 0) + 1
    request.session["visits"] = visits
    return {"visits": visits}

Each request reads its visits counter out of the session cookie, bumps it, and writes it back. The middleware re-signs the cookie on the way out only when the session was modified.

Keep the secret key secret

The cookie is signed with secret_key, not encrypted — clients can read the payload, they just cannot forge it. Never commit the key to source control, and never store anything you would not put in a response body. Load it from an environment variable or a secrets manager. See the OWASP Session Management Cheat Sheet.

The Session object

request.session is a Session, a dict subclass that tracks its own state. Two attributes drive the middleware:

  • session.newTrue when the request arrived without a valid session cookie.
  • session.modified — flips to True the first time any mutating operation runs. The middleware skips the re-sign and Set-Cookie entirely when the handler never touched the session.
  • session.accessed — flips to True the first time the handler reads a session value (session["k"], session.get(...), "k" in session, iteration). It drives the Vary: Cookie header (see Caching and Vary: Cookie).

Every mutating dict operation is tracked, including clear(), pop(), setdefault(), update(), and the |= merge:

from veloce import Request, SessionMiddleware, Veloce

app = Veloce()
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")


@app.post("/login")
async def login(request: Request):
    request.session["user_id"] = 42
    return {"ok": True}


@app.post("/logout")
async def logout(request: Request):
    request.session.clear()      # empties the session
    return {"ok": True}

Emptying the session (so it is falsy) tells the middleware to delete the cookie on the response.

Mutate in place, not the contents of nested objects

Session notices session["key"] = value but cannot see a mutation of a value you already stored — session["items"].append(x) does not flip modified. Re-assign the key, or set session.modified = True yourself.

app.add_middleware(
    SessionMiddleware,
    secret_key="change-me-in-production",
    vary_on_cookie=False,
)

A response built from session state is personalised per user, so a shared cache (a CDN, a reverse proxy) must not serve one user's body to another. When a handler reads or writes the session, the middleware adds Vary: Cookie to the response (merging with any existing Vary value), which tells caches that the response varies by the request Cookie header. A handler that never touches the session gets no extra Vary.

Varying on Cookie is the safe default. If a deployment never serves session-bearing responses from a shared cache — or manages cache-safety another way — construct the middleware with vary_on_cookie=False to turn the automatic header off for every response it handles. This is an app-wide switch, not a per-response one; leave it on unless a shared cache is genuinely not in play.

Permanent sessions

By default the cookie lives for max_age seconds (14 days). Set session.permanent = True to switch it to the longer permanent_lifetime (31 days by default):

from veloce import Request, SessionMiddleware, Veloce

app = Veloce()
app.add_middleware(
    SessionMiddleware,
    secret_key="change-me-in-production",
    max_age=3600,                  # 1 hour for normal sessions
    permanent_lifetime=86400 * 30, # 30 days for "remember me"
)


@app.post("/remember")
async def remember(request: Request):
    request.session.permanent = True
    request.session["user_id"] = 42
    return {"remembered": True}

The permanent flag is stored under a reserved _permanent key, so it persists in the cookie across requests.

Sliding expiry (idle timeout)

from veloce import Request, SessionMiddleware, Veloce

app = Veloce()
app.add_middleware(
    SessionMiddleware,
    secret_key="change-me-in-production",
    max_age=1800,            # expire after 30 minutes of inactivity
    renew_on_access=True,    # ...measured from the last access, not the last write
)

By default a session is only re-written when a handler modifies it, so a read-only request never moves the expiry forward and the session ages out at a fixed max_age from its last write.

Pass renew_on_access=True to switch to a sliding idle-timeout: any request that reads the session (via request.session) refreshes its expiry on the way out, so an active user is kept logged in and only an idle gap longer than max_age expires the session.

With the cookie middleware this re-signs the cookie (new server-side timestamp and Max-Age); with ServerSessionMiddleware it refreshes the store entry's TTL (through SessionStore.touch) and re-stamps the cookie. The default is False, preserving the write-only behavior.

SessionMiddleware options

SessionMiddleware accepts these keyword arguments:

Argument Default Meaning
secret_key SECRET_KEY config A string, or a list of strings for rotation (first one signs).
cookie_name "session" Name of the session cookie.
max_age 86400 * 14 Cookie lifetime in seconds for normal sessions.
path "/" Cookie Path attribute.
httponly True Set the HttpOnly attribute.
secure False Set the Secure attribute (HTTPS-only cookie).
samesite "lax" SameSite attribute — "lax", "strict", or "none".
domain None Cookie Domain attribute (scope the cookie to a host/subdomains).
cookie_prefix None "host" or "secure" — add the __Host-/__Secure- name prefix.
partitioned False Set the Partitioned (CHIPS) attribute for partitioned storage.
permanent_lifetime 86400 * 31 Cookie lifetime when session.permanent is set.
max_cookie_size 4093 Largest rendered Set-Cookie before the cookie is dropped (or chunked).
renew_on_access False Slide the expiry forward on a read-only access (idle timeout).
chunked False Split an oversized signed value across numbered cookies and reassemble it.
max_chunks 8 Upper bound on chunk cookies; larger sessions are dropped with a warning.

Arguments left out fall back to app.config on the first request: secret_key to SECRET_KEY (also settable as app.secret_key), cookie_name to SESSION_COOKIE_NAME, path to APPLICATION_ROOT, httponly / secure / samesite to the matching SESSION_COOKIE_* keys, permanent_lifetime to PERMANENT_SESSION_LIFETIME, and max_cookie_size to MAX_COOKIE_SIZE. An explicit argument always wins over config, and a config key left at its default keeps the middleware default shown above. So the shortest complete setup is:

app = Veloce()
app.secret_key = "change-me-in-production"
app.add_middleware(SessionMiddleware)

Without either a secret_key= argument or a configured SECRET_KEY, the first request raises RuntimeError.

cookie_prefix enforces the RFC 6265bis name-prefix invariants: both prefixes require secure=True, and "host" additionally requires path="/" and domain=None. partitioned=True (CHIPS) requires secure=True and samesite="none". A violation raises ValueError at construction. ServerSessionMiddleware accepts the same domain, cookie_prefix, and partitioned arguments, and resolves its own omitted cookie settings from the same config keys.

Set secure=True behind HTTPS

secure=False is the development default. In production, serve over HTTPS and pass secure=True so the session cookie is never sent over plain HTTP. The httponly and samesite defaults follow RFC 6265 cookie-security guidance — keep httponly=True to keep the cookie out of JavaScript.

The cookie body is signed and timestamped by Signer, and max_age is enforced server-side on read — an attacker cannot extend a session by replaying an old cookie.

Secret rotation

Pass a list of secrets to rotate the signing key without invalidating live sessions. The first key signs new cookies; the rest are accepted on read until existing cookies age out:

from veloce import SessionMiddleware, Veloce

app = Veloce()
app.add_middleware(
    SessionMiddleware,
    secret_key=["new-key", "previous-key"],
)

Cookie size limit

Because the whole session lives in the cookie, a large payload can exceed max_cookie_size (4093 bytes). When the rendered Set-Cookie is too large, SessionMiddleware logs a warning and drops the Set-Cookie rather than corrupting the session. Pass chunked=True to instead split the signed value across numbered cookies (session.0, session.1, ...) and reassemble them on the next request; max_chunks (default 8) bounds the split, so a session that needs more chunks is still dropped with a warning. Shrinking or deleting a session clears its stale chunk cookies. For large payloads, ServerSessionMiddleware is usually the better choice — it keeps the cookie small and makes sessions revocable.

Server-side sessions

ServerSessionMiddleware stores the payload in a SessionStore and puts only an opaque, unguessable id in the cookie. This keeps the cookie small and, crucially, makes sessions revocable — emptying the session or deleting its id from the store destroys it server-side immediately.

The default store is a process-local InMemorySessionStore, which is fine for a single process and for tests:

from veloce import Request, ServerSessionMiddleware, Veloce

app = Veloce()
app.add_middleware(ServerSessionMiddleware)


@app.post("/login")
async def login(request: Request):
    request.session["user_id"] = 42
    return {"ok": True}

The same Session API applies — read and write request.session, and the middleware persists changes to the store.

InMemorySessionStore does not scale across workers

InMemorySessionStore keeps state in one process. A multi-worker or multi-host deployment needs a shared backend (Redis, a database) implementing the SessionStore interface, or each worker will see a different set of sessions.

For multi-worker deployments, the batteries-included RedisSessionStore shares session state across every worker and host. It uses native Redis TTLs for expiry, sliding renewal, and the race-safe conditional write:

from redis.asyncio import Redis

from veloce import ServerSessionMiddleware, Veloce
from veloce.contrib.redis import RedisSessionStore

app = Veloce()
store = RedisSessionStore(Redis.from_url("redis://localhost:6379/0"))
app.add_middleware(ServerSessionMiddleware, store=store)

Install the backend with pip install veloceframework[redis].

ServerSessionMiddleware takes the same cookie options as SessionMiddleware (cookie_name, max_age, path, httponly, secure, samesite) plus a store argument. It has no secret_key (the cookie carries no signed payload to protect) and no permanent_lifetime.

Revoking a session

Keep a reference to the store to revoke any session by id, and rotate the id at privilege boundaries with session.regenerate_id():

from veloce import InMemorySessionStore, Request, ServerSessionMiddleware, Veloce

app = Veloce()
store = InMemorySessionStore()
app.add_middleware(ServerSessionMiddleware, store=store)


@app.post("/login")
async def login(request: Request):
    # Rotate the session id on login to defeat session fixation.
    request.session.regenerate_id()
    request.session["user_id"] = 42
    return {"ok": True}

Calling session.regenerate_id() mints a fresh server-side id on the next response and drops the old store entry, so a pre-existing (possibly attacker-planted) id can no longer be replayed against the elevated session. This is the session-fixation defence.

Writing a custom SessionStore

To back sessions with Redis or a database, subclass SessionStore and implement its async methods. The interface is async so a network-backed store does not block the event loop:

from typing import Any

from veloce import ServerSessionMiddleware, SessionStore, Veloce


class RedisSessionStore(SessionStore):
    def __init__(self, client: Any) -> None:
        self._client = client

    async def read(self, session_id: str) -> dict[str, Any] | None:
        raw = await self._client.get(f"session:{session_id}")
        if raw is None:
            return None
        import orjson

        return orjson.loads(raw)

    async def write(self, session_id: str, data: dict[str, Any], max_age: int) -> None:
        import orjson

        await self._client.set(f"session:{session_id}", orjson.dumps(data), ex=max_age)

    async def delete(self, session_id: str) -> None:
        await self._client.delete(f"session:{session_id}")


app = Veloce()
# app.add_middleware(ServerSessionMiddleware, store=RedisSessionStore(redis_client))

read returns the stored payload or None when the id is absent, expired, or revoked. write persists the payload to expire after max_age seconds. delete revokes an id so a later read returns None.

Override replace for atomic writes

SessionStore also defines replace(session_id, data, max_age), used by the middleware to write back an existing session only if it still exists — so a request in flight cannot resurrect a session a concurrent delete removed. The default implementation is a non-atomic read-then-write. A store with an atomic conditional write (Redis SET ... XX, a SQL UPDATE) should override replace to close the check-then-write window.

Override touch for sliding expiry

When renew_on_access=True, the middleware calls touch(session_id, max_age) to refresh an existing entry's expiry on a read-only access without rewriting its payload. The default reads then rewrites the payload; a store with a native TTL-refresh primitive (Redis EXPIRE, a SQL UPDATE ... expires_at) should override touch to avoid moving the payload.

Reading the session outside the handler

The top-level session proxy resolves to the current request's session, so you can read it without threading the request through every call:

from veloce import SessionMiddleware, Veloce, session

app = Veloce()
app.add_middleware(SessionMiddleware, secret_key="change-me-in-production")


@app.get("/whoami")
async def whoami():
    return {"user_id": session.get("user_id")}

See the Flask-style helpers guide for the full set of request-scoped proxies.

Next steps

  • Flask-style helperssession, flash, g, and the rest of the request-scoped helpers.
  • Middleware — ordering, function middleware, and the full middleware table.
  • The API reference documents the Signer that backs the session cookie.