The pyfly.session module provides server-side HTTP session management with a
pluggable store backend. It mirrors the Spring Session model: a SessionFilter
reads a session cookie on every request, loads (or creates) an HttpSession
from a SessionStore, attaches it to request.state.session, and persists
changes after the response. Two stores ship out of the box — in-memory for
development and Redis for production.
from pyfly.session import HttpSession, SessionFilter, SessionStore
from pyfly.session.adapters.memory import InMemorySessionStore
store = InMemorySessionStore()
filter_ = SessionFilter(store=store, cookie_name="PYFLY_SESSION", ttl=1800)
# Inside a request handler, once the filter has run:
# session = request.state.session # HttpSession instance
# session.set_attribute("user_id", "alice")
# session.get_attribute("user_id") # "alice"
# session.invalidate() # marks for deletionEnable sessions in pyfly.yaml. Auto-configuration wires the store and filter
automatically:
pyfly:
session:
enabled: true
store: memory # memory (default) | redis
cookie-name: PYFLY_SESSION # default
ttl: 1800 # seconds (default: 30 minutes)
cookie:
secure: false # set true in production (HTTPS only)
redis:
url: redis://localhost:6379/0| Key | Default | Description |
|---|---|---|
pyfly.session.enabled |
— | Must be true to activate session support |
pyfly.session.store |
memory |
Store backend: memory or redis |
pyfly.session.cookie-name |
PYFLY_SESSION |
Name of the session cookie |
pyfly.session.ttl |
1800 |
Session lifetime in seconds |
pyfly.session.cookie.secure |
false |
Set true to mark the cookie Secure (HTTPS only) |
pyfly.session.redis.url |
redis://localhost:6379/0 |
Redis connection URL (used when store=redis) |
The redis store requires redis.asyncio to be installed
(pip install redis). If it is not available, the auto-configuration falls
back silently to the in-memory store.
HttpSession wraps the session data dictionary with typed accessors and tracks
mutation state so the filter knows when to persist.
from pyfly.session import HttpSession| Property / Method | Description |
|---|---|
id |
Unique session identifier (UUID hex string) |
is_new |
True if the session was created during this request |
created_at |
Unix timestamp of session creation (float) |
last_accessed |
Unix timestamp of the most recent access (float) |
invalidated |
True if invalidate() has been called |
modified |
True if any attribute was set or removed (or session is new) |
get_attribute(name) |
Return attribute value or None |
set_attribute(name, value) |
Set an attribute; marks session as modified |
remove_attribute(name) |
Remove an attribute if present |
get_attribute_names() |
List of all user-set attribute names (excludes internal _* keys) |
invalidate() |
Mark the session for deletion; filter will delete cookie and store entry |
get_data() |
Raw session dict (includes internal metadata) |
from pyfly.session import SessionStoreAll session backends implement this runtime_checkable Protocol:
class SessionStore(Protocol):
async def get(self, session_id: str) -> dict[str, Any] | None: ...
async def save(self, session_id: str, data: dict[str, Any], ttl: int) -> None: ...
async def delete(self, session_id: str) -> None: ...
async def exists(self, session_id: str) -> bool: ...from pyfly.session.adapters.memory import InMemorySessionStoreThread-safe in-memory store with TTL-based expiry. Uses asyncio.Lock.
Suitable for development, testing, and single-process deployments. Data is
lost on restart.
from pyfly.session.adapters.redis import RedisSessionStoreRedis-backed store. Values are JSON-serialized; dataclass attributes (such as
SecurityContext) are round-tripped via a type-tag mechanism so OAuth2 session
login persists correctly. Keys are prefixed with pyfly:session:.
import redis.asyncio as aioredis
from pyfly.session.adapters.redis import RedisSessionStore
client = aioredis.from_url("redis://localhost:6379/0")
store = RedisSessionStore(client=client)from pyfly.session import SessionFilterAn OncePerRequestFilter ordered at HIGHEST_PRECEDENCE + 150. It runs
before authentication filters so the session is available when
OAuth2SessionSecurityFilter (HP+225) reads request.state.session.
| Constructor parameter | Default | Description |
|---|---|---|
store |
required | SessionStore instance |
cookie_name |
PYFLY_SESSION |
Session cookie name |
ttl |
1800 |
Session TTL in seconds |
secure |
False |
Whether to set the Secure cookie flag |
Cookie properties set by the filter:
| Property | Value | Reason |
|---|---|---|
httponly |
True |
Prevents JavaScript access (XSS mitigation) |
samesite |
lax |
Blocks cross-site request forgery for most flows |
secure |
configurable | Should be True in production |
max_age |
ttl |
Slides forward on every request (rolling TTL) |
On invalidation, the filter deletes the cookie and removes the store entry.
Two auto-configuration classes activate when pyfly.session.enabled=true:
| Class | Bean | Condition |
|---|---|---|
SessionStoreAutoConfiguration |
session_store |
SessionStore bean not already present |
SessionFilterAutoConfiguration |
session_filter |
always (when enabled) |
A third class, SessionConcurrencyAutoConfiguration, activates independently
when pyfly.session.concurrency.enabled=true — see
Concurrency Control.
SessionStoreAutoConfiguration checks pyfly.session.store:
redis→RedisSessionStore(requiresredis.asyncio; falls back to memory if unavailable)- any other value →
InMemorySessionStore
Provide your own SessionStore bean to bypass auto-configuration entirely.
OAuth2LoginHandler writes the authenticated SecurityContext into the
session under the key SECURITY_CONTEXT. On subsequent requests,
OAuth2SessionSecurityFilter (ordered at HP+225, after SessionFilter at
HP+150) reads this attribute and restores the SecurityContext onto
request.state.security_context.
This means browser-based OAuth2 login works without any extra wiring: enable sessions, enable OAuth2 login, and the two filters cooperate automatically.
Mirroring Spring Security's maximumSessions, PyFly can cap the number of
concurrent sessions per authenticated principal. The cap is enforced at the
single point where a principal becomes bound to a session — OAuth2 login —
after the session id has been rotated. With no cap configured, the registry is
unused and behavior is unchanged.
pyfly:
session:
concurrency:
enabled: true
max-sessions: 1 # -1 = unlimited (default)
strategy: evict-oldest # evict-oldest (default) | reject-new| Key | Default | Description |
|---|---|---|
pyfly.session.concurrency.enabled |
— | Must be true to activate concurrency control |
pyfly.session.concurrency.max-sessions |
-1 |
Maximum live sessions per principal; -1 means unlimited |
pyfly.session.concurrency.strategy |
evict-oldest |
What to do when the cap is exceeded: evict-oldest or reject-new |
Strategies
evict-oldest— the new login succeeds; the oldest session(s) for that principal are removed from the registry and deleted from the session store.reject-new— the new login is refused. The handler invalidates the pending session and responds with HTTP401and body{"error": "max_sessions", ...}.
The cap is enforced against a SessionRegistry — a per-principal index of live
session ids, kept separate from the SessionStore. Three backends ship out of
the box, selected by pyfly.session.concurrency.registry:
registry |
Implementation | Scope | Requirements |
|---|---|---|---|
memory (default) |
InMemorySessionRegistry |
Single process only | none |
redis |
RedisSessionRegistry |
Cross-process / multi-instance | redis.asyncio installed |
postgres |
PostgresSessionRegistry |
Cross-process / durable | SQLAlchemy AsyncEngine bean |
memory— in-process index guarded by anasyncio.Lock(mirrorsInMemorySessionStore). Each app instance counts only its own sessions, so the cap is not enforced across multiple processes. Suitable for single-node deployments, development, and testing. State is lost on restart.redis— a cross-process index shared by all app instances. Each principal's live sessions are stored in a Redis sorted set (score =created_at, member =session_id), solist_sessionsis naturally oldest-first. Requiresredis.asyncio; if it is unavailable the auto-configuration falls back to the in-memory registry. The connection URL comes frompyfly.session.concurrency.redis.url, falling back topyfly.session.redis.url, thenredis://localhost:6379/0.postgres— a durable, queryable, cross-process index for relational-only deployments (no Redis required). Session ids are stored in a Postgres table (session_idPK,principal,created_at), created lazily and idempotently on first use. Resolves a SQLAlchemyAsyncEnginebean from the container; this requires the data module / anAsyncEngineto be configured.
pyfly:
session:
concurrency:
enabled: true
max-sessions: 1
strategy: evict-oldest
registry: redis # memory (default) | redis | postgres
redis:
url: redis://localhost:6379/0 # optional; falls back to pyfly.session.redis.url| Key | Default | Description |
|---|---|---|
pyfly.session.concurrency.registry |
memory |
Registry backend: memory, redis, or postgres (case-insensitive) |
pyfly.session.concurrency.redis.url |
falls back to pyfly.session.redis.url, then redis://localhost:6379/0 |
Redis connection URL (used when registry=redis) |
When pyfly.session.concurrency.enabled=true,
SessionConcurrencyAutoConfiguration registers a
SessionConcurrencyController bean backed by the registry selected via
pyfly.session.concurrency.registry (InMemorySessionRegistry by default).
The OAuth2 login auto-configuration resolves this bean (if present) and passes
it to OAuth2LoginHandler, so no manual wiring is required:
| Class | Bean | Condition |
|---|---|---|
SessionConcurrencyAutoConfiguration |
session_concurrency_controller |
pyfly.session.concurrency.enabled=true |
The Redis client and SQLAlchemy AsyncEngine are obtained in the
auto-configuration (the composition root) and injected into the adapters — the
adapters never import their driver at module scope (hexagonal wiring).
The controller's session_deleter is wired to SessionStore.delete, so an
evicted session is purged from whichever store backend is active (memory or
Redis).
from pyfly.session import (
ConcurrencyControlPolicy,
InMemorySessionRegistry,
SessionConcurrencyController,
SessionRegistry,
)ConcurrencyControlPolicy is a frozen dataclass holding the cap configuration:
policy = ConcurrencyControlPolicy(max_sessions=1, strategy="reject-new")| Field | Default | Description |
|---|---|---|
max_sessions |
-1 |
Cap per principal; -1 (negative) means unlimited |
strategy |
"evict-oldest" |
"evict-oldest" or "reject-new" |
SessionRegistry is a runtime_checkable Protocol — a per-principal index of
live session ids, kept separate from the SessionStore. It is also exported
from pyfly.session.ports:
from pyfly.session.ports import SessionRegistry
class SessionRegistry(Protocol):
async def register(self, principal: str, session_id: str, created_at: float) -> None: ...
async def deregister(self, principal: str, session_id: str) -> None: ...
async def list_sessions(self, principal: str) -> list[tuple[str, float]]: ... # oldest first
async def count(self, principal: str) -> int: ...InMemorySessionRegistry is the in-process implementation (guarded by an
asyncio.Lock), the default used by auto-configuration when
registry=memory. Two cross-process implementations ship as adapters; both
have their driver/engine injected by the composition root:
from pyfly.session.adapters.redis_registry import RedisSessionRegistry
from pyfly.session.adapters.postgres_registry import PostgresSessionRegistryRedisSessionRegistry(client, *, key_prefix="pyfly:session:user:", ttl=86400)
stores each principal's sessions in a Redis sorted set (oldest-first by
created_at). The ttl (seconds) bounds orphan growth and slides forward on
each register. Used when registry=redis.
PostgresSessionRegistry(engine_factory, *, table="pyfly_session_registry")
stores sessions in a Postgres table. engine_factory is a zero-arg callable
returning a SQLAlchemy AsyncEngine (resolved lazily on first use); the table
name is validated as a SQL identifier. Used when registry=postgres.
You may still provide your own SessionRegistry bean to override the
auto-configured one entirely.
SessionConcurrencyController enforces the policy:
| Method | Description |
|---|---|
__init__(registry, policy, *, session_deleter=None) |
session_deleter is an async (session_id) -> None callable used to evict store entries |
on_login(principal, session_id, created_at) |
Registers the session, enforcing the cap. Returns False if rejected (reject-new), True otherwise |
on_logout(principal, session_id) |
Deregisters the session |
Constructing a controller manually:
from pyfly.session import (
ConcurrencyControlPolicy,
InMemorySessionRegistry,
SessionConcurrencyController,
)
from pyfly.session.adapters.memory import InMemorySessionStore
store = InMemorySessionStore()
controller = SessionConcurrencyController(
InMemorySessionRegistry(),
ConcurrencyControlPolicy(max_sessions=1, strategy="reject-new"),
session_deleter=store.delete,
)
# allowed is False once the cap is exceeded under "reject-new"
allowed = await controller.on_login("alice", session_id="abc123", created_at=1717000000.0)- Security — JWT authentication,
@securedecorator,OAuth2SessionSecurityFilter - Web Filters —
OncePerRequestFilter, filter ordering,WebFilterChainMiddleware