Authentication with cookie sessions
This how-to is a recipe for browser sessions after you already know who the user is: put a signed cookie on the response that carries who they are and when the session ends. On each request, verify the signature, optionally refresh the cookie, and expose the result on c.state so handlers can authorize without re-parsing cookies themselves. Login and logout are thin: issue or clear the cookie; credential checks live wherever you implement them.
AuthUser and AuthSession on this page are example types for the recipe—they are not exported from stario.
AuthUser and AuthSession
AuthUser is the decoded identity for one request: at minimum a user_id and an expires_at (Unix seconds). You can extend or replace this type with whatever you want to keep in the session—for example display name, username, theme, or locale—so handlers read it from c.state without hitting a database on every request.
AuthSession is the single object that knows how to build, sign, and verify the cookie, how it is named, and how it maps into c.state. One instance per app (or process), constructed where you load secrets (for example bootstrap). It does not replace a user database or a login form—it is the transport between browser and server once authentication has succeeded.
import base64import hashlibimport hmacimport jsonimport timefrom dataclasses import dataclassfrom typing import Literal import stario.responses as responsesfrom stario import Context, Handler, Middleware, Writerfrom stario.cookies import delete_cookie, set_cookie @dataclass(frozen=True, slots=True)class AuthUser: """Who is signed in and until when, after the cookie has been verified.""" user_id: str expires_at: float # Unix time when the session expires @dataclass(frozen=True, slots=True)class AuthSession: """Signed session cookie: configuration, codec, issue/clear, and HTTP wiring.""" secret: bytes state_key: str = "auth_session" cookie_name: str = "session" max_age_seconds: int = 60 * 60 * 24 * 7 refresh_if_expires_within_seconds: int = 60 * 60 * 24 secure: bool = False httponly: bool = True samesite: Literal["lax", "strict", "none"] = "lax" def encode_cookie(self, user_id: str, expires_at: float) -> str: body = json.dumps({"uid": user_id, "exp": expires_at}, separators=(",", ":")).encode() sig = hmac.new(self.secret, body, hashlib.sha256).digest() return ( base64.urlsafe_b64encode(body).decode().rstrip("=") + "." + base64.urlsafe_b64encode(sig).decode().rstrip("=") ) def decode_cookie(self, token: str) -> AuthUser | None: try: body_b64, sig_b64 = token.split(".", 1) body = base64.urlsafe_b64decode(body_b64 + "=" * (-len(body_b64) % 4)) sig = base64.urlsafe_b64decode(sig_b64 + "=" * (-len(sig_b64) % 4)) if not hmac.compare_digest(hmac.new(self.secret, body, hashlib.sha256).digest(), sig): return None data = json.loads(body) exp = float(data["exp"]) if time.time() >= exp: return None return AuthUser(user_id=str(data["uid"]), expires_at=exp) except (ValueError, json.JSONDecodeError, KeyError): return None def get_user(self, c: Context) -> AuthUser | None: raw = c.req.cookies.get(self.cookie_name) if not raw: return None return self.decode_cookie(raw) def issue(self, w: Writer, user_id: str) -> None: expires_at = time.time() + self.max_age_seconds token = self.encode_cookie(user_id, expires_at) set_cookie( w, self.cookie_name, token, max_age=self.max_age_seconds, secure=self.secure, httponly=self.httponly, samesite=self.samesite, ) def clear(self, w: Writer) -> None: delete_cookie( w, self.cookie_name, secure=self.secure, httponly=self.httponly, samesite=self.samesite, ) def attach_user(self) -> Middleware: def middleware(inner: Handler) -> Handler: async def handler(c: Context, w: Writer) -> None: user = self.get_user(c) c.state[self.state_key] = user if user is not None: remaining = user.expires_at - time.time() if remaining < self.refresh_if_expires_within_seconds: self.issue(w, user.user_id) await inner(c, w) return handler return middleware def require_user(self, *, redirect_to: str = "/login") -> Middleware: def middleware(inner: Handler) -> Handler: async def handler(c: Context, w: Writer) -> None: user = self.get_user(c) c.state[self.state_key] = user if user is None: responses.redirect(w, redirect_to, status=303) return await inner(c, w) return handler return middlewareHandler dependencies via closures
The usual way to give handlers dependencies (here, an AuthSession) is a factory: an outer function closes over auth_session and returns the real handler.
import stario.responses as responsesfrom stario import Context, Writer from myapp.sessions import AuthSession, AuthUserfrom myapp.urls import DASHBOARD, HOME, LOGIN def login_get(auth_session: AuthSession): async def handler(c: Context, w: Writer) -> None: claims = c.state.get(auth_session.state_key) if claims is not None: responses.redirect(w, HOME.href()) return responses.text(w, "<form method=post action=/login>…</form>", status=200) return handler def login_post(auth_session: AuthSession): async def handler(c: Context, w: Writer) -> None: user_id = "user-123" auth_session.issue(w, user_id) responses.redirect(w, HOME.href(), status=303) # 303 See Other: browser follows with GET return handler def logout_post(auth_session: AuthSession): async def handler(c: Context, w: Writer) -> None: auth_session.clear(w) responses.redirect(w, LOGIN.href(), status=303) return handler def dashboard(auth_session: AuthSession): async def handler(c: Context, w: Writer) -> None: claims = c.state.get(auth_session.state_key) if claims is None: responses.redirect(w, LOGIN.href(), status=303) return responses.text(w, f"Hello, {claims.user_id}") return handler/login uses attach_user so c.state is filled before the handler runs. Protected routes such as / use attach_user before require_user so sliding session refresh runs on every visit, not only on /login.
Middleware order: In Stario, the first middleware in a middleware=[…] tuple is outermost on the inbound path (it runs before inner). List attach_user before require_user when attach must populate c.state first—middleware=[attach_user(), require_user()]. See Routing — Middleware.
Setup in bootstrap
Construct AuthSession, register routes, and keep bootstrap limited to wiring—no handler bodies.
import os from stario import App, Span from myapp.handlers import dashboard, login_get, login_postfrom myapp.sessions import AuthSessionfrom myapp.urls import DASHBOARD, HOME, LOGIN async def bootstrap(app: App, span: Span): secret = os.environ.get("SESSION_SECRET", "").encode() if len(secret) < 32: raise RuntimeError("Set SESSION_SECRET to a long random value (32+ bytes).") auth_session = AuthSession(secret=secret, secure=True) app.get(HOME, dashboard(auth_session), middleware=[auth_session.attach_user(), auth_session.require_user()]) app.get(LOGIN, login_get(auth_session), middleware=[auth_session.attach_user()]) app.post(LOGIN, login_post(auth_session)) yieldA practical way to apply authentication in one place is app.use on a path prefix—register middleware before routes on that prefix:
from stario import App from myapp.urls import APP_PREFIX, DASHBOARD app.use(APP_PREFIX, auth_session.attach_user())app.use(APP_PREFIX, auth_session.require_user())app.get(DASHBOARD, dashboard(auth_session))Scoped middleware runs in registration order inbound — register attach_user before require_user so sessions refresh before enforcement.
APP_PREFIX might be UrlPath("/app") and DASHBOARD might be UrlPath("/app/dashboard"). Per-route middleware=[…] on app.get composes with app.use as documented in Routing — Middleware.
Things to remember
c.stateis per request; middleware that resolves the cookie runs before handlers that readc.state[auth_session.state_key].Secrets — never commit
SESSION_SECRET; rotating it invalidates existing cookies until users sign in again.HTTPS — set
secure=Trueon cookies in production; terminate TLS in front of the app (Deployment).Payload — the cookie is signed (integrity), not encrypted; treat anything you put in JSON as visible to the browser if someone inspects storage.
Cookie sessions alone do not replace rate limiting, CSRF strategy for cookie-backed
POSTforms (tokens,SameSite, or idempotent patterns), or broader threat modeling—see Responses — Cookies forSameSiteandHttpOnly.
Related
Testing with TestClient — cookie jar; use
follow_redirects=Falseto assert303to/login.Deployment: Containers, TLS, and safe releases — HTTPS in front of Stario.
Structuring larger applications — where
bootstrap, middleware, and feature packages live in larger apps.Responses — Cookies —
set_cookie,delete_cookie; read inbound cookies withc.req.cookies.get(name).