Starlette SessionMiddleware signed cookies with itsdangerous: working versions (tested 2026-10-15)
Add cookie sessions to a Starlette app via SessionMiddleware, then test login, tampering, logout, expiry and JSON safety fully offline over httpx ASGITransport.
passed6 passed · 0 failed2026-10-15 21:56 UTC- Status
- passed
- Last passed
- 2026-10-15 21:56 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38813530570
The code
Exactly the files that passed. Your agent can pull them with get_recipe("starlette-itsdangerous-sessions") (install), or see them on GitHub.
starlette==1.8.0 itsdangerous==2.2.0 httpx==0.28.1 pytest==9.1.1 pytest-asyncio==1.4.0
import json
from base64 import b64decode, b64encode
from itsdangerous import TimestampSigner
from starlette.applications import Starlette
from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route
async def login(request):
request.session["user"] = request.path_params["user"]
return JSONResponse({"ok": True})
async def me(request):
return JSONResponse({"user": request.session.get("user")})
async def logout(request):
request.session.clear()
return JSONResponse({"ok": True})
async def stash_non_json(request):
request.session["tags"] = {"a", "b"}
return JSONResponse({"ok": True})
def create_app(secret_key="dev-secret", max_age=14 * 24 * 3600):
app = Starlette(
routes=[
Route("/login/{user}", login),
Route("/me", me),
Route("/logout", logout),
Route("/stash-non-json", stash_non_json),
]
)
app.add_middleware(SessionMiddleware, secret_key=secret_key, max_age=max_age)
return app
def encode_session(session, secret_key, timestamp=None):
# same wire format SessionMiddleware produces: base64(json) + sig
data = b64encode(json.dumps(session).encode("utf-8"))
signer = TimestampSigner(str(secret_key))
if timestamp is not None:
signer.get_timestamp = lambda: timestamp
return signer.sign(data).decode("utf-8")
import json
import time
from base64 import b64decode
import httpx
import pytest
from itsdangerous import TimestampSigner
from app import create_app, encode_session
def client_for(app):
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.fixture
def app():
return create_app(secret_key="s3cr3t")
async def test_session_roundtrip(app):
async with client_for(app) as client:
assert (await client.get("/login/alice")).status_code == 200
assert client.cookies.get("session")
assert (await client.get("/me")).json() == {"user": "alice"}
async def test_session_is_signed_not_encrypted(app):
async with client_for(app) as client:
await client.get("/login/alice")
raw = client.cookies["session"]
payload = TimestampSigner("s3cr3t").unsign(raw, max_age=60)
assert json.loads(b64decode(payload)) == {"user": "alice"}
async def test_tampered_cookie_is_ignored(app):
async with client_for(app) as client:
r = await client.get("/me", headers={"Cookie": "session=not-a-real-signature"})
assert r.json() == {"user": None}
async def test_non_json_value_raises(app):
async with client_for(app) as client:
with pytest.raises(TypeError):
await client.get("/stash-non-json")
async def test_logout_clears_cookie(app):
async with client_for(app) as client:
await client.get("/login/alice")
assert (await client.get("/me")).json() == {"user": "alice"}
await client.get("/logout")
assert client.cookies.get("session") is None
assert (await client.get("/me")).json() == {"user": None}
async def test_max_age_expiry():
app = create_app(secret_key="s3cr3t", max_age=60)
old = encode_session({"user": "alice"}, "s3cr3t", timestamp=int(time.time()) - 3600)
async with client_for(app) as client:
r = await client.get("/me", headers={"Cookie": f"session={old}"})
assert r.json() == {"user": None}
[pytest] pythonpath = . asyncio_mode = auto
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Starlette multipart form uploads parsed with python-multipart, tested offline
Parse multipart text fields and file uploads in Starlette with python-multipart, then exercise the app fully in-process through httpx.ASGITransport with no network.
Starlette routes and middleware tested offline with httpx ASGITransport
A Starlette app with BaseHTTPMiddleware and lifespan state, exercised in-process via httpx ASGITransport without a real server.
aiolimiter AsyncLimiter guarding httpx AsyncClient offline
Throttle an httpx AsyncClient with aiolimiter's leaky-bucket AsyncLimiter and test the burst, serialized concurrency, error and event-loop reuse gotchas fully offline with httpx.MockTransport.
authlib OAuth2 client credentials flow + httpx mocked by respx
An async authlib OAuth2 client that fetches, caches and auto-refreshes client_credentials tokens over httpx, tested fully offline with respx route mocks.
All starlette recipesAll itsdangerous recipesAll httpx recipes