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.

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

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

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

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