Skip to content

Sessions

The SDK also provides server-side sessions for FastAPI. The cookie carries an opaque identifier and nothing else; the record lives in Redis, where the server enforces both of its deadlines.

from fastapi import FastAPI
from redis_fastapi import FastAPIRedis, SessionDep

app = FastAPI()
FastAPIRedis(app).lifespan().sessions()

@app.post("/login")
async def login(session: SessionDep) -> dict:
    session["user_id"] = 42  # rotates the session ID after the endpoint completes
    return {"ok": True}

@app.get("/me")
async def me(session: SessionDep) -> dict:
    return {"user_id": session.get("user_id")}

request.session works too, so code written against Starlette's signed-cookie SessionMiddleware runs unchanged.


Session change

Writing to the session dictionary remains an in-memory change until the endpoint completes its work. The middleware loads the session before your endpoint runs. It saves, rotates or deletes the session after the endpoint returns, just before it sends the response headers. While your endpoint runs:

  • Reads in the same request see your writes.
  • Redis still holds the old record, and store.session_id(state) returns the old ID, or None for a new visitor.
  • A response with status 400 or more that changes the principal saves nothing. A sign-in followed by raise HTTPException(403) does not sign the user in.
  • If the save fails, the client gets a 500. Your endpoint's own side effects, such as a database commit, have already happened.

To change the session now, call the store. Each SessionStoreDep call goes to Redis at once:

@app.post("/login")
async def login(
    session: SessionDep, state: SessionStateDep, store: SessionStoreDep
) -> dict:
    session["user_id"] = 42
    new_id = await store.rotate(state, subject="42")  # rotates now
    return {"ok": True}

The middleware sees the new ID and only sets the cookie. It does not rotate a second time. You give up one protection: an error response after rotate() no longer cancels the sign-in.

Work that must wait until the session is saved belongs in a background task. It runs after the response is sent.

The session request lifecycle shows the full sequence.


Automatic rotation

Writing the identity rotates the session ID. There is no rotate() to call and therefore none to forget, which matters because forgetting it is the one mistake in this API that is a vulnerability - session fixation.

The middleware evaluates a principal twice per request, once before the application runs and once at http.response.start. If the two differ and the response status is below 400, it issues a new ID and deletes the old key first.

Watch more than the user ID to get OWASP's privilege-change rotation:

FastAPIRedis(app).lifespan().sessions(principal_keys=["user_id", "role"])

For anything a list of keys cannot express, supply a function:

FastAPIRedis(app).lifespan().sessions(
    # Identity exists, but authentication is not finished at MFA step one.
    principal_of=lambda s: s.get("user_id") if s.get("mfa_ok") else None,
)

principal_of must be pure, cheap and deterministic - it runs twice per request and the two results are compared by value - and it must return a verified identity, never something the client set.

A misconfigured resolver is silent

If your application writes uid but the resolver reads user_id, nothing rotates and nothing complains. Assert it once:

def test_login_rotates(client):
    before = client.post("/write").cookies["session"]
    after = client.post("/login").cookies["session"]
    assert after != before

The operation="rotate" counter is the runtime backstop: sign-ins with no rotations is a visible anomaly on a dashboard.


Session clocks

Setting Default What it bounds
session_idle_ttl 30 min Time since the last request carrying the cookie
session_absolute_ttl 8 h Time since the session was created, however active the user

The idle clock is the expiration of the hash field that holds the payload, and the absolute clock is the expiration of the session key itself. The library sets each TTL, and Redis enforces both: no code in this library reads a timestamp to decide whether a session has expired. The key's TTL is set once, when the session is created; the field's TTL is reset on every request that carries the cookie. At the absolute deadline Redis deletes the whole key, however recently the payload was refreshed. Writing the payload never touches the key's expiration, so no number of writes can extend the absolute deadline.

The absolute clock is the one that matters against a stolen session. An idle timeout cannot expire a session an attacker is actively using, because the attacker's own requests keep refreshing it - OWASP says so directly under absolute timeout. The absolute deadline is the only clock that fires on a live compromise.

Both accept an int number of seconds. The store constructor also accepts a timedelta; the settings and their environment variables take seconds. Setting either to 0 disables that clock; setting both gives a cookie-only session that the browser drops when it closes.

The cookie's Max-Age is the time left on the absolute clock, and the number comes from Redis rather than from your process. It does not follow the idle clock: a read-only request sends no cookie, so an idle-sized cookie would expire while the user is still active. After an idle timeout the browser keeps a cookie that no longer names a session, and the next request with it gets an empty session.


Signing out

from redis_fastapi import SessionStateDep, SessionStoreDep

@app.post("/logout")
async def logout(state: SessionStateDep, store: SessionStoreDep) -> dict:
    await store.revoke(state)
    return {"ok": True}

@app.post("/logout-everywhere")
async def logout_all(session: SessionDep, store: SessionStoreDep) -> dict:
    return {"ended": await store.revoke_all(str(session["user_id"]))}

@app.get("/devices")
async def devices(
    session: SessionDep, state: SessionStateDep, store: SessionStoreDep
) -> list[dict]:
    current = store.session_id(state)
    return [
        {"id": info.session_id, "this_device": info.session_id == current,
         "last_seen": info.last_access}
        for info in await store.list_for_subject(str(session["user_id"]))
    ]

SessionDep is the data - a dict you read and write. SessionStateDep is the handle: the identifier, the subject and the timestamps. Operations that act on the session rather than its contents take the handle.

Simply emptying the session signs the user out too, and is often all you need:

@app.post("/logout")
async def logout(session: SessionDep) -> dict:
    session.clear()          # key deleted, index entry dropped, cookie cleared
    return {"ok": True}

revoke_id requires the subject and refuses an ID that is not indexed under it, so a handler taking an ID from a request cannot end a stranger's session.

A listing is verified before it is returned. The index that answers "which sessions has this user got?" is an upper bound: entries expire on the absolute clock while most sessions die of idleness long before, so an entry routinely outlives the session it names. Reporting one would show a user a device they are not signed in on and a sign-out button that does nothing.


Requiring a valid session

from fastapi import Depends
from redis_fastapi import valid_session

@app.post("/checkout", dependencies=[Depends(valid_session())])
async def checkout(session: SessionDep) -> dict: ...

valid_session() rejects a request that did not arrive with a session this application created in an earlier response. A value a client makes up finds no record in Redis, so it fails, whatever its shape.

It checks nothing else. It does not ask who the session belongs to: an anonymous session that holds a basket passes. Identity and roles stay with your own authentication.

The default rejection is a 401. To build your own, pass on_reject. It receives the request and a reason, and returns the response:

from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from redis_fastapi import SessionRejection

def start_over(request: Request, reason: SessionRejection) -> Response:
    if reason == "unavailable":
        return JSONResponse({"detail": "Try again shortly"}, status_code=503)
    return RedirectResponse("/?expired=1" if reason == "expired" else "/", status_code=303)

@app.get("/basket", dependencies=[Depends(valid_session(on_reject=start_over))])
async def basket(session: SessionDep) -> dict: ...
Reason What happened
"missing" No usable session: no cookie, a malformed one, or a session that an earlier dependency revoked or emptied
"expired" A well-formed cookie that names no live session: expired, revoked on another device, evicted or forged. The server cannot tell these apart.
"unavailable" Redis could not be read, or the record could not be decoded, and the request continued without a session. Answer 503, not a sign-in page.

on_reject may be sync or async. It returns the response instead of raising it, so a callback that forgets to return fails with a 500 instead of letting the request through.

The gate also:

  • marks the session as read, so the response carries Vary: Cookie and Cache-Control: private;
  • counts malformed cookies, on every request, gated or not, as the session operation metric with result="malformed". Every identifier the store issues passes the format check, so a malformed value was never ours. The middleware neither logs it nor clears it: the cookie may belong to another application on the same domain;
  • refuses a route excluded by skip with SessionConfigurationError, because such a route can never pass.

The WWW-Authenticate header

By default a rejection sends none, as most frameworks do for cookie sessions: the browser shows the body, and no password dialog. To send one, set challenge once for the application:

from fastapi.security import APIKeyCookie

# The security scheme your application already declares.
FastAPIRedis(app).lifespan().sessions(challenge=APIKeyCookie(name="session"))

# A fixed value.
FastAPIRedis(app).lifespan().sessions(
    challenge='Cookie realm="shop" form-action="/login" cookie-name=session',
)

# A value that depends on the request or the reason; None sends no header.
FastAPIRedis(app).lifespan().sessions(
    challenge=lambda request, reason: None if reason == "unavailable" else "APIKey",
)

Basic is refused: every browser answers it with a password dialog, on every gated page. The header goes on the default rejection only; a response from on_reject is your own.

Requiring a recently issued session

@app.post(
    "/account/email",
    dependencies=[
        Depends(current_user),                      # your authentication
        Depends(valid_session(issued_within=600)),  # the session ID is at most 10 minutes old
    ],
)
async def change_email() -> dict: ...

@app.post("/confirm-password")
async def confirm(
    form: PasswordForm, state: SessionStateDep, store: SessionStoreDep
) -> dict:
    if not await verify(form.password):
        raise HTTPException(401)
    await store.reauthenticate(state)   # issues a new session ID
    return {"ok": True}

issued_within asks one more question: was this session's ID issued within the last N seconds? A session gets a new ID when it is created, when the principal changes - a sign-in, a role change - and when you call store.rotate() or store.reauthenticate(). Reads and writes keep the ID. The age comes from the session key's TTL in Redis, so a container with a wrong clock cannot make an old session look recent.

This is the building block for a step-up before a sensitive action, as OWASP ASVS V7.5 asks: your route checks the password and calls reauthenticate(), and for the next issued_within seconds the sensitive routes pass.

issued_within is not an authentication check

  • A new anonymous session is recent. Pair it with your own authentication dependency, as in the example.
  • Every new ID counts. If your principal includes something a user can change without a password - an active tenant, say - changing it makes the session recent.
  • A handler that calls store.rotate() makes the session recent too.

A session that is too old gets the reason "stale". The default response is:

401
{"detail": "A recently issued session is required",
 "error": "stale_session",
 "issued_within": 600}

Branch on error in the client: open a password prompt, then retry. Every response from such a route, passed or rejected, says Cache-Control: no-store, so no cache keeps it, the browser's included. For a "confirm your password to continue" banner, store.session_age(state) returns the age in seconds.

Two side effects to know:

  • A step-up changes the cookie, because reauthenticate() rotates the session. A form open in another tab, carrying a CSRF token from the old session, fails after it.
  • Lowering session_absolute_ttl makes older sessions stale until they expire. The age is the configured lifetime minus the time left, and a session created under the longer lifetime has more time left than the new one allows. It is reported as "stale", never as recent.

When Redis is unreachable

The default is asymmetric by design:

  • A failed read yields an empty session. The user looks anonymous, your own authorization dependency finds no user, and a protected route stays protected because it never depended on the read succeeding.
  • A failed write raises SessionStoreError. Losing a login or a rotation is the worst outcome here and must never be silent.

Set session_fail_closed=True to turn the failed read into an error too, for a deployment that prefers a 503 to an anonymous page. Writes raise either way.

A record that cannot be decoded - corrupted, or encrypted with another key - is handled like a failed read. The record is kept, so fixing a wrong key brings the sessions back. Signing out still works: store.revoke() or emptying the session deletes the record and clears the cookie.

With session_fail_closed=True, users cannot sign out of an unreadable session

The request fails before your handler runs, so a sign-out route fails too. Every request that carries the cookie fails until the record reaches its absolute deadline, or the user deletes the cookie. One corrupted record locks out one user; a wrong encryption key locks out everyone. To recover, fix the key, or delete the affected records in Redis.


Nested changes are invisible

session["prefs"]["theme"] = "dark"     # NOT saved
session["prefs"] = {**session["prefs"], "theme": "dark"}   # saved

No dict subclass in any language can see a change inside a value it holds. Reassign the top-level key, or set session_always_save=True to write on every request that touched the session.

session_always_save writes on every request that read the session

"Touched" includes a plain read - session.get("user_id") is enough - so with the setting on, a route that only inspects the session writes it back on every request. Prefer reassigning the top-level key, and reach for the setting only where you cannot.

An empty session is exempt: reading one does not create it. Without that exemption every anonymous visitor to a session-touching route - every crawler, health check and preflight - would be minted an identifier, a Redis key and a cookie. Nothing the setting exists for is lost, because a nested mutation needs a top-level key already holding the nested value.


Caching an endpoint that uses the session

A cached response is stored once and served many times. A session-dependent response is different for every user. Without handling this in a special way the first user's body could end up being served to the second.

Using the vary_on_session parameter you can control three distinct situations:

cache(ttl=300, vary_on_session=True)    # depends on who is asking
cache(ttl=300, vary_on_session=False)   # reads the session but the body is the same for everyone
cache(ttl=300)                          # unknown, reading the session is potentially dangerous

vary_on_session=True - cache per user

Use it when the response contains the user's own data.

@app.get(
    "/me",
    dependencies=[Depends(cache(ttl=300, vary_on_session=True))],
)
async def me(session: SessionDep) -> dict:
    return {"user_id": session["user_id"], "cart": session.get("cart", [])}

Each user gets their own cache entry and their own hits - Alice's second request is a HIT on Alice's copy. The key is built from the subject, not the session ID, so it survives a rotation (a privilege change does not throw the entry away) and is shared across that user's devices.

The response carries Cache-Control: private, max-age=300 and Vary: Cookie, on the hit as well as the miss. The private is not optional and is added for you: our entry is per user, so a CDN told it may keep one copy for everyone would recreate the leak one hop further out. The Vary stops the browser showing one user's copy to the next person who signs in on it.

A caller with no session at all has no subject either. Every such caller shares one entry, computed with no user - which gives nothing away. Put valid_session() in front, first in dependencies=[...], to refuse them instead.

vary_on_session=False - reads the session, but cache is shared

Use it when an auth dependency reads the session but the payload does not depend on who asked.

async def require_user(session: SessionDep) -> int:
    if "user_id" not in session:
        raise HTTPException(401)
    return session["user_id"]

@app.get(
    "/catalogue",
    dependencies=[
        Depends(require_user),                          # reads the session
        Depends(cache(ttl=300, vary_on_session=False)), # body does not
    ],
)
async def catalogue() -> list[dict]:
    return await load_products()

One shared entry serves every signed-in user, and the Vary: Cookie the session middleware would otherwise add is suppressed - keeping it would force a CDN to store one identical copy per user and destroy the hit rate this declaration exists to protect.

This is an assertion, and the library takes your word for it. If the body does depend on the session, you have re-enabled the leak deliberately.

With valid_session() as the gate, two things change:

@app.get(
    "/members/catalogue",
    dependencies=[
        Depends(valid_session()),                       # first
        Depends(cache(ttl=300, vary_on_session=False)), # then the cache
    ],
)
async def members_catalogue() -> list[dict]:
    return await load_products()
  • The gate goes first. FastAPI resolves dependencies=[...] in order, and a cache hit ends the resolution, so in the other order a hit would be served before the gate runs - to anyone. valid_session() detects that order and raises SessionConfigurationError on the first request, before anything is stored.
  • The response says private. Redis still keeps one shared entry, because the gate runs before it on every request. A CDN cannot check a session, so it would serve that entry to anyone; private keeps it out.

A hand-written gate like require_user gets neither. List it before cache() yourself, and pass cache(..., private=True) when the route sits behind a CDN or another shared cache.

Not setting the vary_on_session at all

If the endpoint never touches the session, say nothing - there is nothing to declare and caching behaves exactly as it always has.

If it does touch the session and you have not declared anything, the response is served normally but not stored, and the route is named once in a log line:

WARNING  GET /me read the session but is cached without vary_on_session set;
         the response was not stored. Pass vary_on_session=True to cache it
         per user, or False if the response does not depend on the session.

Losing caching is a visible, recoverable problem. Serving Alice's account page to Bob is not, which is why the default errs this way.

Note that these last two are the same declaration - you write nothing in both cases. The library tells them apart by whether the endpoint actually read the session, which it can only know after the endpoint has run. That is also why the choice cannot be inferred for you: the cache key is needed before the endpoint runs, and reading the session does not tell us whether the response depends on it.

At a glance

Your endpoint Declaration Cached as A caller with no session Response headers
Body depends on the user vary_on_session=True one entry per user shares one entry with every other such caller private, max-age=N + Vary: Cookie
Gated by valid_session(), body depends on the user vary_on_session=True one entry per user 401 private, max-age=N + Vary: Cookie
Reads the session, body identical vary_on_session=False one shared entry the shared entry max-age=N, no Vary
Gated by valid_session(), body identical vary_on_session=False one shared entry 401 private, max-age=N, no Vary
Gated by valid_session(issued_within=...) either as declared 401 no-store, + Vary: Cookie when True
Never touches the session (nothing) one shared entry the shared entry max-age=N
Touches it, nothing declared (nothing) not cached not cached private, no-store + Vary: Cookie

Reading the table:

  • A hit sends the same headers as the miss. The only difference is max-age, which on a hit is the time left on the entry. The browser keeps whichever response it saw last, so the two must agree.
  • A gate changes who reaches the entry, not how it is keyed. Each gated row behaves like the row above it, except that a caller with no session gets a 401. The 401 says private (no-store under issued_within) and Vary: Cookie, so no cache replays it after the user signs in.
  • "Per user" means per subject. An anonymous session that holds data - a basket, say - has no subject, so its entry is keyed by the session ID. valid_session() lets it through: the gate checks that the session is live, not who it belongs to.

The rule underneath every row: what a response tells other caches they may do is never more permissive than what this library does itself. If we key per user, or serve our entry only to callers who pass the gate, we say private. If we refuse to store, we say no-store.

A session route with no caching on it

Reading the session on an uncached route emits Cache-Control: private and Vary: Cookie on its own, since nothing else will. Where cache() is present it owns the header outright - one writer, so you never see two contradictory Cache-Control values on one response.

For a sensitive page - an order history, a bank statement - set Cache-Control: no-store in the handler. The middleware then adds nothing, so the browser does not keep the page and cannot show it again from history after a sign-out:

@app.get("/orders")
async def orders(session: SessionDep, response: Response) -> dict:
    response.headers["Cache-Control"] = "no-store"
    return {"orders": await db.orders_for(session["user_id"])}

On a cached route, pass cache(..., no_store=True) instead: the entry is still kept in Redis, and every miss and hit says no-store.


Limitation: WebSockets have no session in this release

The middleware handles http scopes only. In a WebSocket handler, websocket.session raises AssertionError, and SessionDep / SessionStateDep raise SessionConfigurationError.

This is a deliberate boundary for the first release, not an oversight, and it is a difference from Starlette's own SessionMiddleware, which handles both scopes. If you are migrating from it and read the session inside a WebSocket handler, that code needs changing.

Why it is not simply switched on. The read half is easy; the write half has nowhere to go. A WebSocket has no http.response.start, so there is no point at which a cookie can be set - which means no rotation, no save, and no idle-clock refresh for the life of the connection. Half a session object, silently read-only, invites exactly the bug the rest of this design works to prevent: an application writes to it, sees no error, and loses the write.

What to do instead. Authenticate during the HTTP handshake, where the cookie is available, and pass what the socket needs into the handler:

@app.websocket("/ws")
async def ws(websocket: WebSocket) -> None:
    # The handshake is an HTTP request, so the cookie arrives with it.
    raw = websocket.cookies.get("session")
    store = await get_session_store(websocket)      # type: ignore[arg-type]
    loaded = await store.load(raw) if raw and store.is_valid_id(raw) else None
    if loaded is None:
        await websocket.close(code=1008)   # policy violation, RFC 6455 §7.4.1
        return

    user_id = loaded.record.data.get("user_id")
    await websocket.accept()
    ...

Load once at accept time and hold the identity for the connection. Nothing closes the socket when the session ends - by sign-out, revocation or expiry - so a socket that must not outlive its session has to check it again itself, for example with store.load(raw, refresh=False) on a timer.


A cookie is attached by the browser to every request to your origin, including one triggered by a form on somebody else's page. That is what makes a session cookie convenient and it is also the whole of CSRF: an attacker cannot read your session, but they can make the browser spend it.

A bearer token in an Authorization header does not have this problem, because nothing attaches it automatically. Moving to cookies gets you revocation, rotation and server-side expiry, and it hands this back.

SameSite=Lax is the default here and it is most of the remedy. The browser withholds the cookie on cross-site POST, PUT, PATCH and DELETE. It does not withhold it on a cross-site top-level GET, so:

  • Never change state in a GET. A GET /account/delete is exploitable under Lax and no cookie attribute will save it.
  • Add a CSRF token for anything a browser form can reach. SameSite is a defence in depth, not a substitute
  • it is unenforced on some older browsers, and Lax has a two-minute exemption window for top-level POSTs in some Chromium versions.
  • SameSite=Strict closes the top-level GET hole too, at the cost of the cookie being withheld when a user follows a link into your site from anywhere else - including their own email.

The session is the natural place to keep the token - this is OWASP's synchronizer token pattern:

import secrets
from fastapi import HTTPException

@app.post("/login")
async def login(session: SessionDep) -> dict:
    session["user_id"] = 42
    session.pop("csrf", None)    # a fresh token for this sign-in
    return {}

@app.get("/form")
async def form(session: SessionDep) -> dict:
    token = session.setdefault("csrf", secrets.token_urlsafe(32))
    return {"csrf_token": token}

@app.post("/transfer")
async def transfer(session: SessionDep, csrf_token: str = Form(...)) -> dict:
    expected = session.get("csrf")
    if not expected or not secrets.compare_digest(csrf_token, expected):
        raise HTTPException(403, "CSRF token mismatch")
    ...

secrets.compare_digest rather than ==, and secrets.token_urlsafe for the token itself. A sign-in rotates the session ID but carries the data across, the token included, so drop the token when you sign the user in: the next setdefault then mints a fresh one.

A runnable version is in examples/sessions/csrf.py.


Encrypting the payload at rest

Anyone with redis-cli access can read a session. That is usually acceptable - the guidance is to keep identifiers in the session and entities outside it - but if you must store something sensitive, supply an Encryptor:

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

class AesGcmEncryptor:
    """Ten lines, and yours to audit."""

    def __init__(self, key: bytes) -> None:
        self._aes = AESGCM(key)              # 16, 24 or 32 bytes

    def encrypt(self, data: bytes) -> bytes:
        nonce = os.urandom(12)
        return nonce + self._aes.encrypt(nonce, data, None)

    def decrypt(self, data: bytes) -> bytes:
        return self._aes.decrypt(data[:12], data[12:], None)

Encryption wraps serialization, never the reverse: the coder turns your value into text, and only then does the encryptor see it. A coder is never handed ciphertext. The store keeps the ciphertext as base64, so it reads back the same whichever way the Redis client decodes responses.

A runnable version is in examples/sessions/encryption.py.

This package ships the seam and not the implementation, deliberately. AES-GCM, via AESGCM, in your codebase is a smaller liability for everyone than a cryptographic primitive maintained here - and it means a key rotation is your decision, on your schedule.

Two things to know before you turn it on. A record that will not decrypt is handled like a failed read and kept, not deleted: rotating a key without a re-encryption pass signs everybody out, and restoring the old key brings them back. By default that happens quietly, with a warning in the log; set session_fail_closed=True to make it fail every request instead, sign-outs included (see When Redis is unreachable). And the index descriptor is not encrypted - it holds only what your descriptor_of returns, so do not put anything sensitive there.


Migrating from something else

Sessions do not survive the switch, by decision: existing cookies reference records this store cannot read, so every user signs in again once. Deploy accordingly.

From Starlette's SessionMiddleware

# before
from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(SessionMiddleware, secret_key="…")

# after
FastAPIRedis(app).lifespan().sessions()
Their behaviour Here
request.session["user_id"] = 42 unchanged - and it now rotates the ID
request.session.clear() unchanged - and it now deletes the record too
payload capped at ~4 KB by the cookie no cap; the cookie carries an ID
data signed but readable by the client never leaves the server
max_age session_idle_ttl plus session_absolute_ttl
no revocation revoke, revoke_id, revoke_all

Handler code does not change. secret_key has no equivalent because nothing is signed: the cookie is an opaque 256-bit identifier, well past OWASP's 64-bit entropy floor.

From other session libraries

Many Starlette-compatible libraries offer lifetime and rolling options and a load_session call. The table maps those to this package.

Theirs Here
lifetime=N, rolling=True session_idle_ttl=N
lifetime=N, rolling=False session_absolute_ttl=N
both behaviours at once not expressible for them; set both settings here
load_session(request) nothing - the load is automatic and eager
regenerate_session_id(request) delete the call; writing the identity rotates
get_session_metadata(request) created and last_access from store.list_for_subject(); lifetime is store.absolute_seconds
RedisStore(...) FastAPIRedis(app).lifespan().sessions()

Their lifetime accepts a timedelta; so does this store's constructor.

From fastapi-users' RedisStrategy

RedisStrategy is a token store, not a session store: it maps an opaque token to a user ID and nothing else. Keep fastapi-users for registration, password reset and OAuth linking - this package does not replace it. Swap the strategy for the session and read the identity from request.session instead of from the strategy's token.

From an in-process store

A dict keyed by session ID, or a TTLCache. The behaviour you gain is that it survives a restart and is shared across workers; the behaviour you lose is none. Delete the store and call .sessions().


Running it in production

A session store is not a cache, and maxmemory-policy must say so. Under allkeys-lru, allkeys-lfu or allkeys-random, Redis will evict live sessions to make room, and every evicted session is a user signed out mid-task with nothing in any log to explain it. Use volatile-ttl or noeviction, or give sessions their own instance or logical database.

This is the single most likely production incident with this feature.

A large tenant's index key is a single key that every login and logout writes, which makes it a candidate hot spot. HOTKEYS START METRICS 2 CPU NET SAMPLE 100 finds it; the subject_of seam is what shards it.


Sync endpoints

from redis_fastapi import SyncSessionStoreDep

@app.post("/logout")
def logout(session: SessionDep, store: SyncSessionStoreDep) -> dict:
    store.revoke(session)
    return {"ok": True}

Replacing the pieces

Every seam is a keyword argument to .sessions():

FastAPIRedis(app).lifespan().sessions(
    coder=pydantic_model_coder(MySession),   # serialization
    encryptor=AesGcmEncryptor(key),          # encryption at rest
    id_factory=my_id_factory,                # identifier generation (validated)
    key_prefix="myapp",                      # key namespace
    idle_ttl=timedelta(minutes=15),          # idle clock
    absolute_ttl=timedelta(hours=8),         # absolute clock
    # what a device listing shows for the session (stored unencrypted)
    descriptor_of=lambda req, s: {"ip": req.client.host},
    principal_keys=["user_id", "role"],      # what rotates the ID
    subject_of=lambda s: s.get("tenant_id"), # what the index is keyed on
    cookie_builder=my_cookie_builder,        # Set-Cookie rendering
    # requests that get no session and make no Redis calls
    skip=lambda req: req.url.path.startswith("/health"),
)

To supply the store object yourself - one you built and configured, or a test double - pass store or store_factory:

store = RedisSessionStore(redis, key_prefix="tenant-a", absolute_ttl=3600)
FastAPIRedis(app).lifespan().sessions(store=store)

In tests, dependency_overrides reaches the middleware as well as your handlers, so one override covers the whole request:

app.dependency_overrides[get_session_store] = lambda request: fake_store

Settings

Every setting is an environment variable prefixed REDIS_, so session_idle_ttl is REDIS_SESSION_IDLE_TTL. See Configuration for how these are loaded.

Setting Default Notes
session_cookie_name session Matches Starlette and other libraries
session_cookie_https_only True Adds Secure. Turn it off for local HTTP only
session_cookie_same_site lax none requires https_only=True
session_idle_ttl 1800 0 disables the idle clock
session_absolute_ttl 28800 0 disables the absolute clock
session_gc_ttl 2592000 Backstop so Redis can always collect an abandoned key
session_refresh_on_load True False: only a request that used the session counts as activity
session_fail_closed False Read behaviour when Redis is down or a record is unreadable
session_always_save False Escape route for nested mutation. Writes on every request that read the session; an empty session is exempt
session_principal_keys ["user_id"] What a change to rotates the ID. Comma-separated in the environment: REDIS_SESSION_PRINCIPAL_KEYS=user_id,role

References

The standards and specifications this design follows: