Stario has no accounts system and no session store. It has cookie helpers in stario.cookies, middleware, and c.state. That is enough for a session that fits in your head: login puts a signed cookie on the browser, middleware verifies it on every request and puts the user on c.state, and logout clears it. There is no table of sessions to clean up, because the cookie is the session.
This page builds that in about a hundred lines you own. AuthUser and AuthSession below are your types, not imports. If you would rather start from a working version with a login screen and a users table, the stario-auth skill in stario-create has one.
1. The cookie#
AuthUser is what a verified cookie says: a user id and an expiry. Add fields such as a display name if handlers need them without a database query.
AuthSession holds the secret and the cookie settings, and knows how to encode, decode, issue, and clear the cookie. Build one in bootstrap, where the secret is loaded.
import base64import hashlibimport hmacimport jsonimport timefrom dataclasses import dataclassfrom typing import Literal from stario import Context, Writerfrom stario.cookies import delete_cookie, set_cookie @dataclass(frozen=True, slots=True)class AuthUser: user_id: str expires_at: float # Unix time @dataclass(frozen=True, slots=True)class AuthSession: secret: bytes state_key: str = "user" cookie_name: str = "session" max_age_seconds: int = 60 * 60 * 24 * 7 refresh_within_seconds: int = 60 * 60 * 24 secure: bool = False samesite: Literal["lax", "strict", "none"] = "lax" def encode(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 _b64(body) + "." + _b64(sig) def decode(self, token: str) -> AuthUser | None: try: body_b64, sig_b64 = token.split(".", 1) body, sig = _unb64(body_b64), _unb64(sig_b64) 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, KeyError): return None def user(self, c: Context) -> AuthUser | None: raw = c.req.cookies.get(self.cookie_name) return self.decode(raw) if raw else None def issue(self, w: Writer, user_id: str) -> None: expires_at = time.time() + self.max_age_seconds set_cookie( w, self.cookie_name, self.encode(user_id, expires_at), max_age=self.max_age_seconds, secure=self.secure, httponly=True, samesite=self.samesite, ) def clear(self, w: Writer) -> None: delete_cookie(w, self.cookie_name, secure=self.secure, httponly=True, samesite=self.samesite) def _b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _unb64(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))The cookie is base64(json).base64(hmac). Anyone can read the JSON; nobody without the secret can change it, because the HMAC would no longer match. hmac.compare_digest keeps the comparison constant-time. The cookie is HttpOnly, so page scripts cannot read it, and SameSite=Lax, so other sites cannot post with it.
2. Two pieces of middleware#
attach_user reads the cookie and puts the result on c.state, refreshing the cookie when it is close to expiry. require_user does the same and redirects to the login page when there is no user. Both take the session and return middleware, which is a function from handler to handler.
import time from stario import Context, Handler, Middleware, Writer from app.sessions import AuthSession def attach_user(session: AuthSession) -> Middleware: def middleware(inner: Handler) -> Handler: async def handler(c: Context, w: Writer) -> None: user = session.user(c) c.state[session.state_key] = user if user is not None and user.expires_at - time.time() < session.refresh_within_seconds: session.issue(w, user.user_id) await inner(c, w) return handler return middleware def require_user(session: AuthSession, *, login_url: str) -> Middleware: def middleware(inner: Handler) -> Handler: async def handler(c: Context, w: Writer) -> None: user = session.user(c) c.state[session.state_key] = user if user is None: w.redirect(login_url, status=303) return await inner(c, w) return handler return middlewareA handler behind either reads c.state["user"] and gets an AuthUser or None.
3. Login and logout#
The login page is a form. The POST reads it, checks the credentials with code you own, issues the cookie, and redirects with a 303 so the browser follows with a GET. Logout clears the cookie and redirects the same way.
from urllib.parse import parse_qs from stario import Context, Writerfrom stario import h from app.sessions import AuthSessionfrom app.urls import HOME, LOGIN, LOGIN_POST, LOGOUTfrom app.users import check_password # your code: (email, password) -> user id or None def login_view(): return h.Form( {"method": "post", "action": LOGIN_POST.href()}, h.Input({"name": "email", "type": "email", "autocomplete": "username"}), h.Input({"name": "password", "type": "password", "autocomplete": "current-password"}), h.Button({"type": "submit"}, "Sign in"), ) async def login_get(c: Context, w: Writer) -> None: if c.state.get("user") is not None: w.redirect(HOME.href(), status=303) return w.html(login_view()) def login_post(session: AuthSession): async def handler(c: Context, w: Writer) -> None: form = parse_qs((await c.req.body()).decode()) email = form.get("email", [""])[0] password = form.get("password", [""])[0] user_id = await check_password(email, password) if user_id is None: w.text("Wrong email or password.", status=401) return session.issue(w, user_id) w.redirect(HOME.href(), status=303) return handler def logout(session: AuthSession): async def handler(c: Context, w: Writer) -> None: session.clear(w) w.redirect(LOGIN.href(), status=303) return handlerThe factories close over the AuthSession, the way Passing dependencies describes: the dependency arrives as an argument and the closure holds it. login_get needs nothing, so it is a plain handler.
4. Wire it in bootstrap#
Routes are values in app/urls.py. LOGIN and LOGIN_POST are two routes on one path.
from stario import Route HOME = Route("GET /")LOGIN = Route("GET /login")LOGIN_POST = Route("POST /login")LOGOUT = Route("POST /logout")DASHBOARD = Route("GET /app/dashboard")bootstrap loads the secret, builds the session, and registers everything. The home page needs attach_user only; the dashboard needs require_user.
import os from stario import App, Span from app.handlers import dashboard, home, login_get, login_post, logoutfrom app.middleware import attach_user, require_userfrom app.sessions import AuthSessionfrom app.urls import DASHBOARD, HOME, LOGIN, LOGIN_POST, LOGOUT 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).") session = AuthSession(secret=secret, secure=True) app.use("/app", require_user(session, login_url=LOGIN.href())) app.add(HOME, home, middleware=[attach_user(session)]) app.add(LOGIN, login_get, middleware=[attach_user(session)]) app.add(LOGIN_POST, login_post(session)) app.add(LOGOUT, logout(session)) app.add(DASHBOARD, dashboard) yieldapp.use("/app", …) protects every route under /app in one line, wherever it sits in bootstrap. Per-route middleware=[…] is for the pages that only need to know who is there. In both forms the first middleware listed is the outermost on the way in, as Routing describes.
By the way#
Rotating
SESSION_SECRETsigns every user out at once, which is also the fastest way to revoke every session.The cookie is signed, not encrypted. Put ids in it, not data you would not show the user.
secure=Truerequires HTTPS, which Deployment puts in front of the app. Leave itFalseonly on localhost.A session cookie does not protect against CSRF by itself.
SameSite=Laxcovers plain navigations; a form that changes state should still carry a token you check.The
w.text(..., status=401)here returns a plain line. Error handling shows how to render it as a page, or you can re-render the form with a message instead.