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. You can still load heavier profile data elsewhere using user_id when needed.
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 from stario import Context, Handler, Middleware, Writer, responsesfrom stario.cookies import delete_cookie, get_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. Use one instance per application. Call get_user from code that already has a Context. Use attach_user or require_user when middleware should populate c.state (and optionally refresh the cookie) before the route handler runs. """ 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: # Compact JSON over the wire; HMAC covers exact bytes (treats payload as opaque). body = json.dumps({"uid": user_id, "exp": expires_at}, separators=(",", ":")).encode() sig = hmac.new(self.secret, body, hashlib.sha256).digest() # urlsafe base64 without wrapping; "." separates payload and MAC so we can split later. 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) # Omitted padding is common in tokens; length must be a multiple of 4 for decoding. 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: # Use when you need AuthUser or None without middleware (helpers, one-off checks). raw = get_cookie(c.req, self.cookie_name) if not raw: return None return self.decode_cookie(raw) def issue(self, w: Writer, user_id: str) -> None: # Issue a fresh expiry from "now" so max_age matches cookie lifetime semantics. 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: # Mirror issue()'s flags so the browser actually drops the cookie. delete_cookie( w, self.cookie_name, secure=self.secure, httponly=self.httponly, samesite=self.samesite, ) def attach_user(self) -> Middleware: # Use when anonymous traffic is OK but c.state should still see AuthUser or None, # and the cookie may be refreshed (sliding session). 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: # Use when the handler must not run unless the session is valid; else redirect. 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: # 303 See Other: safe default when redirecting after POST; use 302/307 if you rely on method replay. 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. Naming the inner coroutine handler keeps the pattern easy to scan.
The same idea works with a class: pass dependencies into __init__, and use methods as route handlers—behavior is almost identical, only the shape differs.
from stario import Context, Writer, responses from myapp.sessions import AuthSession, AuthUser 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: # Already signed in; skip the form and go to the app. responses.redirect(w, "/") 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: # if not verify_credentials(...): # responses.text(w, "Invalid login", status=401) # return user_id = "user-123" auth_session.issue(w, user_id) # 303: after POST, follow-up GET should not replay the POST (see [Runtime — Exception handling](/reference/runtime#exception-handling) for `RedirectException` vs `responses.redirect`). responses.redirect(w, "/", 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", 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: the handler can tell guest vs already logged in and redirect early. / uses require_user so only authenticated clients see the dashboard.
Sliding refresh (re-issuing the cookie when expiry is near) runs only in middleware that calls attach_user(). If a route uses require_user() alone, authenticated users on that path do not get the refresh branch unless you also compose attach_user with require_user.
Middleware order: In Stario, the last middleware in a middleware=[…] tuple is outermost on the inbound path (it runs first before inner). So list attach_user after require_user when you want attach to run first—middleware=[require_user(), attach_user()] means attach wraps the handler outside require. 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 AuthSession async def bootstrap(app: App, span: Span) -> None: 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("/", dashboard(auth_session), middleware=[auth_session.require_user()]) app.get("/login", login_get(auth_session), middleware=[auth_session.attach_user()]) app.post("/login", login_post(auth_session))A practical way to apply authentication in one place is a Router whose constructor middleware is require_user, then register every private handler on that router and mount it under a prefix. Fewer call sites means fewer chances to forget middleware.
from stario import App, Router # Every route on this subtree runs after require_user (session required).private = Router(middleware=[auth_session.require_user()])private.get("/dashboard", dashboard(auth_session))# private.get("/settings", settings(auth_session)) app.mount("/app", private)Route-level middleware=[…] on app.get is appended after global Router / App middleware. The App docstring summarizes the onion: last registered global middleware runs first on the inbound request; combine with per-route lists 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
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,get_cookie.