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.

python
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Literal
 
import stario.responses as responses
from stario import Context, Handler, Middleware, Writer
from 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 middleware

Handler 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.

python
import stario.responses as responses
from stario import Context, Writer
 
from myapp.sessions import AuthSession, AuthUser
from 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.

python
import os
 
from stario import App, Span
 
from myapp.handlers import dashboard, login_get, login_post
from myapp.sessions import AuthSession
from 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))
    yield

A practical way to apply authentication in one place is app.use on a path prefix—register middleware before routes on that prefix:

python
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.state is per request; middleware that resolves the cookie runs before handlers that read c.state[auth_session.state_key].

  • Secrets — never commit SESSION_SECRET; rotating it invalidates existing cookies until users sign in again.

  • HTTPS — set secure=True on 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 POST forms (tokens, SameSite, or idempotent patterns), or broader threat modeling—see Responses — Cookies for SameSite and HttpOnly.