Skip to content

Sessions

Sessions are CRUDAuth's default transport. With no transports= argument you get cookie auth backed by a server-side session store, CSRF protection, and the /login, /logout, /register, and /me routes:

from crudauth import CRUDAuth

auth = CRUDAuth(session=get_session, user_model=User, SECRET_KEY="change-me")
app.include_router(auth.router)

To configure it, pass a SessionTransport explicitly:

from crudauth import CRUDAuth, SessionTransport, CookieConfig

auth = CRUDAuth(
    session=get_session, user_model=User, SECRET_KEY="change-me",
    transports=[
        SessionTransport(
            backend="redis",
            redis_url="redis://localhost:6379",
            session_timeout_minutes=30,
            max_sessions_per_user=5,
            cookies=CookieConfig(secure=True, samesite="lax"),
        ),
    ],
)

The routes you get

Method & path What it does
POST /register Create an account (email, username, password).
POST /login Log in with a configured login field (username or email by default) + password; sets the cookies.
POST /logout Terminate the current session and clear the cookies.
GET /me Return the authenticated user's identity.

POST /login is a form post and accepts a remember_me flag (see below). To gate your own routes, see Protecting routes.

A login of your own

Need a custom login (a different form, an extra step)? Call auth.authenticate_password for the hardened credential check (the shared lockout, timing-equalized verification, and the disabled-account check) and establish the session yourself, instead of reassembling those by hand:

@app.post("/my-login")
async def my_login(body: LoginIn, request: Request, response: Response, db: DbDep):
    user = await auth.authenticate_password(db, body.username, body.password, request=request)
    sid, csrf = await auth.sessions.create_session(request, user_id=auth.repo.user_id(user))
    auth.sessions.set_session_cookies(response, sid, csrf)
    return {"csrf_token": csrf}

A wrong password raises UnauthorizedException, a tripped lockout RateLimitException - the same responses /login gives. See Use the building blocks.

How a session works

On a successful POST /login, CRUDAuth:

  1. Verifies the credentials (with lockout and timing equalization).
  2. Creates a session record in the backend (in-memory or Redis).
  3. Generates a CSRF token bound to the session.
  4. Sets an httponly session_id cookie and a readable csrf_token cookie.

On later requests the session transport reads session_id, validates it against the backend, slides its idle timeout forward, and (on unsafe methods) checks CSRF before returning the Principal.

The browser holds a httpOnly session_id cookie and a JS-readable csrf_token cookie; the session_id is looked up in the server-side session store (memory or redis) which holds user_id, csrf_token and expiry; writes must echo csrf_token in the X-CSRF-Token header The browser holds a httpOnly session_id cookie and a JS-readable csrf_token cookie; the session_id is looked up in the server-side session store (memory or redis) which holds user_id, csrf_token and expiry; writes must echo csrf_token in the X-CSRF-Token header

CSRF

CSRF is on by default and uses the synchronizer-token pattern. The csrf_token cookie is not httponly so your frontend can read it and echo it back in the X-CSRF-Token header on mutating requests (POST, PUT, PATCH, DELETE):

function csrfToken() {
  return document.cookie.split("; ").find(c => c.startsWith("csrf_token="))?.split("=")[1];
}

await fetch("/account", {
  method: "POST",
  headers: { "X-CSRF-Token": csrfToken(), "Content-Type": "application/json" },
  body: JSON.stringify({ ... }),
});

A mutating request with a missing or wrong header is rejected with 403. Safe methods (GET, HEAD, OPTIONS) are exempt. You can disable CSRF with SessionTransport(csrf=False), but don't unless something else terminates CSRF in front of crudauth.

Remember me

POST /login accepts a remember_me form field. When set, the session and its cookie get the longer remember_me_days lifetime instead of the default idle window:

SessionTransport(remember_me_days=30)

CookieConfig controls the cookie attributes:

CookieConfig(secure=True, samesite="lax", path="/")

secure=True (the default) means the cookies are only sent over HTTPS. For local development over plain HTTP, set secure=False so the browser will store them. Use samesite="none" (with secure=True) only if your frontend is on a different site than your API.

Multi-device sessions

Each login is a separate server-side session, so you can build "manage devices" and "sign out everywhere" on top of auth.sessions:

@app.get("/account/sessions")
async def list_sessions(user: Principal = Depends(auth.current_user())):
    return await auth.sessions.list_for_user(user.user_id)

@app.post("/account/sessions/{session_id}/revoke")
async def revoke_session(session_id: str, user: Principal = Depends(auth.current_user())):
    await auth.sessions.revoke(session_id, owner_id=user.user_id)  # owner check prevents cross-user revoke

@app.post("/account/sign-out-everywhere")
async def sign_out_all(user: Principal = Depends(auth.current_user())):
    await auth.sessions.revoke_all(user.user_id)

max_sessions_per_user caps how many concurrent sessions a user can have; the oldest is evicted past the cap.

Backends and lifespan

The in-memory backend is per-process, which is fine for development but breaks under multiple workers. Use Redis in production, and open and close the connections in your app's lifespan:

from contextlib import asynccontextmanager

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

app = FastAPI(lifespan=lifespan)

The full set of knobs is on the SessionManager reference.


Next: Bearer tokens →