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.new—Truewhen the request arrived without a valid session cookie.session.modified— flips toTruethe first time any mutating operation runs. The middleware skips the re-sign andSet-Cookieentirely when the handler never touched the session.session.accessed— flips toTruethe first time the handler reads a session value (session["k"],session.get(...),"k" in session, iteration). It drives theVary: Cookieheader (see Caching andVary: 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.
Caching and Vary: Cookie¶
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:
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 helpers —
session,flash,g, and the rest of the request-scoped helpers. - Middleware — ordering, function middleware, and the full middleware table.
- The API reference documents the
Signerthat backs the session cookie.