Skip to content

Session Backends

CRUDAdmin keeps admin sessions with crudauth. The session backend decides where sessions, CSRF tokens and login lockout counters are stored. This guide covers the three backends, how to pick one, and the settings that shape sessions and logins. Session settings go in a SessionConfig, passed as sessions=; login protection settings in an AccessConfig, passed as access=.

Prerequisites

  • CRUDAdmin 0.6 or later
  • For the redis backend: a Redis server and pip install "crudadmin[redis]"

Choosing a backend

Backend Where state lives Shared between workers Extra infrastructure Use it for
memory (default) The process's memory No None Development, tests, a single-process deployment
redis Redis Yes A Redis server Production with several workers or several hosts
database Two tables in the admin database Yes None Production with several workers, without Redis

The backend holds more than sessions: login lockout counters live in the same place. With several workers on the memory backend, an admin who logs in on one worker is unknown to the next, and each worker counts failed logins on its own, so lockout is several times weaker. Any deployment that runs more than one worker process (for example gunicorn -w 4 or uvicorn --workers 4) needs redis or database.

Pass it as SessionConfig(backend=...); the value is case-insensitive.

Memory

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
)

memory is the default. Sessions are lost when the process restarts. CRUDAdmin logs a warning at startup when this backend is active, as a reminder that it isn't suitable for multi-worker production.

Redis

from crudadmin import CRUDAdmin, RedisConfig, SessionConfig

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(
        backend="redis",
        redis=RedisConfig(
            host="redis.internal",
            port=6379,
            db=0,
            username="crudadmin",
            password=REDIS_PASSWORD,
            pool_size=10,
            connect_timeout=5,
        ),
    ),
)

redis takes a RedisConfig or a plain dict with the same keys. Without it, CRUDAdmin connects to localhost:6379, database 0.

A connection URL works too:

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(
        backend="redis",
        redis=RedisConfig(url="rediss://crudadmin:secret@redis.example.com:6380/1"),
    ),
)

The URL is passed to the Redis client whole, so a rediss:// URL keeps TLS. pool_size sets the client's maximum number of connections and connect_timeout its connection timeout in seconds.

CRUDAdmin opens the Redis client and closes it in admin.shutdown(). Every key starts with crudadmin:, then a fingerprint of SECRET_KEY and the admin's mount path, for example crudadmin:3fa9c1d2e4b5/admin:. The admin can therefore share a Redis with your application, including one that uses crudauth itself, and with other admins: two admins in one app at different paths, or two apps that both mount an admin at /admin, never read each other's sessions.

Prefix Holds
crudadmin:<fingerprint><mount path>:session: Sessions
crudadmin:<fingerprint><mount path>:csrf: CSRF tokens
crudadmin:<fingerprint><mount path>:rl: Login lockout counters

Changing SECRET_KEY or the mount path changes the prefix, so it signs every admin out and resets the lockout counters.

Database

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(backend="database"),
)

Sessions, CSRF tokens and lockout counters are stored in two tables of the admin database, crudadmin_auth_store and crudadmin_auth_counters. They are created by admin.initialize(). This uses crudauth's DatabaseStore, which opens a short-lived database session for each operation and works on SQLite, PostgreSQL and MySQL.

Choose it when you run several workers but don't run Redis. Every worker reads the same tables, so logins and lockout are shared. For a multi-worker deployment, point the admin database at PostgreSQL or MySQL with admin_db_url; SQLite serializes writers and suits a single host.

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    admin_db_url="postgresql+asyncpg://user:password@db.internal/admin",
    sessions=SessionConfig(backend="database"),
)

Expired rows are removed in small batches as writes go by, so the tables don't grow without bound.

Application lifecycle

Call admin.initialize() before serving requests and admin.shutdown() when the application stops:

from contextlib import asynccontextmanager

from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    await admin.initialize()
    yield
    await admin.shutdown()


app = FastAPI(lifespan=lifespan)
app.mount("/admin", admin.app)

initialize() creates the admin tables (and the database backend's tables), and opens the session stores. shutdown() closes the stores and the Redis client CRUDAdmin opened.

How sessions work

Sessions are created at login and kept by crudauth:

  • The browser gets two cookies, crudadmin_session and crudadmin_csrf. The names are namespaced, so the admin and an application on the same domain don't overwrite each other's cookies.
  • crudadmin_session is HttpOnly. Both cookies are SameSite=Strict, scoped to the admin's mount path, and Secure unless you pass SessionConfig(secure_cookies=False) (only do that for local development over plain HTTP).
  • The cookies have no fixed lifetime. A session ends when the admin is idle for timeout_minutes; every request moves that deadline forward.
  • The store keeps an HMAC of each session id and CSRF token, keyed with SECRET_KEY, never the raw values. Read access to Redis or to the tables doesn't yield a working session. Changing SECRET_KEY signs every admin out.
  • Logging in again from the same browser ends the session it presented.
admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(
        backend="redis",
        timeout_minutes=60,
        max_per_admin=3,
        cleanup_interval_minutes=15,
    ),
)
SessionConfig field Default Meaning
backend "memory" "memory", "redis" or "database"
redis None The Redis server for the redis backend
timeout_minutes 30 Idle time after which a session ends
max_per_admin 5 Sessions one admin may hold at once; a login over the limit ends the least recently active one
cleanup_interval_minutes 15 Minimum time between sweeps of idle sessions; each store also expires idle sessions on its own
secure_cookies True Send the cookies over HTTPS only

CSRF protection

Every POST, PUT, PATCH and DELETE to the admin must carry the session's CSRF token in an X-CSRF-Token header, or it gets a 403. A cookie is sent with cross-site requests but a custom header is not, so requiring the header is what blocks cross-site request forgery.

The admin's own pages handle this for you: the bundled admin.js reads the crudadmin_csrf cookie and adds the header to htmx requests, fetch calls, and the create and update forms. Logout is a POST for the same reason, so a link or an image on another page can't log an admin out.

If you call admin endpoints from your own scripts, read the crudadmin_csrf cookie and send it back as X-CSRF-Token.

Login protection

Lockout

Failed logins are counted per username and per IP address. With CRUDAdmin's defaults, five failures lock the login for a minute; repeated lockouts double the duration, up to five minutes. A successful login clears the counters.

Anyone who knows an admin's username can trigger that lockout, so the cap is deliberately short. To keep strangers away from the login page altogether, restrict it with AccessConfig(allowed_ips=...) or allowed_networks.

Pass a crudauth LockoutConfig as AccessConfig(lockout=...) to change the defaults:

from crudauth.ratelimit import LockoutConfig

from crudadmin import AccessConfig, CRUDAdmin, SessionConfig

admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(backend="redis"),
    access=AccessConfig(
        lockout=LockoutConfig(max_attempts=10, lockout_max_seconds=900),
    ),
)

Client IP behind a proxy

AccessConfig(trusted_proxy_hops=...) tells CRUDAdmin how many reverse proxies sit in front of the application. It decides which address is used for lockout and recorded on sessions.

  • 0 (the default) ignores X-Forwarded-For and uses the connecting address. Use it when clients connect to the application directly.
  • N reads the N-th entry of X-Forwarded-For counted from the right, the address your outermost trusted proxy saw. Values a client adds on the left are never read.
admin = CRUDAdmin(
    session=get_session,
    SECRET_KEY=SECRET_KEY,
    sessions=SessionConfig(backend="redis"),
    access=AccessConfig(trusted_proxy_hops=1),
)

Set it to the real number of proxies. Too high a value lets a client choose its own address; too low makes every request look like it comes from the proxy.

The Sessions page

The admin sidebar links to Sessions (/management/sessions). It lists active sessions with the browser and operating system, IP address, sign-in time and last activity, and marks the current one. Session ids are never shown.

  • An admin sees and can end their own sessions.
  • A superuser sees and can end every admin's sessions.

The same operations are available in code through admin.session_manager, crudauth's session manager:

sessions = await admin.session_manager.list_for_user(user_id)
await admin.session_manager.revoke_by_handle(sessions[0]["id"], owner_id=user_id)
await admin.session_manager.revoke_all(user_id)

Logins, logouts, failed logins and lockouts are recorded in the event log when track_events=True.

Removed and deprecated options

Option Status
session_backend, redis_config, session_timeout_minutes, max_sessions_per_user, cleanup_interval_minutes, secure_cookies Deprecated in 0.7: they still work, with a DeprecationWarning. Pass sessions=SessionConfig(backend=..., redis=..., timeout_minutes=..., max_per_admin=..., cleanup_interval_minutes=..., secure_cookies=...) instead. Passing both raises.
allowed_ips, allowed_networks, enforce_https, https_port, trusted_proxy_hops, lockout Deprecated in 0.7 the same way; pass access=AccessConfig(...) with the same names.
backend="memcached", memcached_config Raises ValueError at startup naming the replacements, redis and database. MemcachedConfig can still be imported, so existing imports don't break.
backend="hybrid" Deprecated: runs on redis and emits a DeprecationWarning. It will be removed in a later release.
track_sessions_in_db=True Deprecated: emits a DeprecationWarning. With the memory backend it switches to database, so deployments that relied on it keep a shared store; with redis it changes nothing. Use SessionConfig(backend="database") instead.

Sessions are no longer stored in an admin_session table. A database upgraded from an earlier version keeps that table unused; you can drop it once every worker runs 0.6.

Next steps