lauren-guards: batteries-included authentication & authorization guards for the lauren web framework.
Documentation: https://lauren-framework.github.io/lauren-guards/
Source Code: https://github.com/lauren-framework/lauren-guards
lauren-guards is an authentication and authorization add-on for the
lauren Python web framework. Every guard is a factory function
that returns a class satisfying lauren.GuardProtocol — drop the result directly into
@use_guards(...) and lauren's startup validator checks the wiring before the first
request.
The key features are:
- Six authentication guards: HTTP Basic, Bearer Token, API Key, JWT (HS / RS / ES + JWKS auto-rotation), OAuth 2.0 Introspection (RFC 7662), Session Cookie.
- Three authorization guards:
require_authenticated,require_roles,require_scopes. - Two cross-cutting guards: CSRF (double-submit cookie), IP allowlist (CIDR ranges +
optional trusted-proxy
X-Forwarded-For). - Password utilities:
BcryptHasher,Argon2Hasher, andgenerate_token()for cryptographically-secure random IDs. - Sessions:
InMemorySessionStore+ aSessionStoreprotocol for plugging in Redis or Postgres in production. @publicdecorator: opt individual routes out of guard protection without changing the controller or guard configuration.- Startup-validated: all factories are decorated with
@injectable(scope=SINGLETON)so misconfigurations fail atLaurenFactory.create(...), not at runtime.
Python 3.11, 3.12, 3.13, and 3.14 are supported. Requires lauren ≥ 1.0.0.
$ pip install lauren-guardsOptional extras for heavier dependencies:
$ pip install "lauren-guards[jwt]" # adds PyJWT + cryptography (jwt_bearer)
$ pip install "lauren-guards[http]" # adds httpx (oauth2_introspection, JWKS URL)
$ pip install "lauren-guards[bcrypt]" # adds bcrypt (BcryptHasher)
$ pip install "lauren-guards[argon2]" # adds argon2-cffi (Argon2Hasher)
$ pip install "lauren-guards[all]" # all of the aboveThe full documentation is published to GitHub Pages and covers every guard, the principal record, sessions, password hashing, and the complete API reference:
- Home — https://lauren-framework.github.io/lauren-guards/
- Getting Started — https://lauren-framework.github.io/lauren-guards/getting-started/
- Reference — https://lauren-framework.github.io/lauren-guards/reference/
from lauren import LaurenFactory, controller, get, post, module, use_guards, Json
from lauren_guards import AuthUser, bearer_token, require_roles, require_scopes, public
async def verify_token(token: str) -> AuthUser | None:
# Replace with a real database / cache lookup.
if token == "good-token":
return AuthUser(id="u-42", roles=("user",), scopes=("items.read", "items.write"))
return None
BearerGuard = bearer_token(verify=verify_token)
@use_guards(BearerGuard)
@controller("/items")
class ItemController:
@get("/")
@use_guards(require_scopes("items.read"))
async def list_items(self) -> dict:
return {"items": []}
@post("/")
@use_guards(require_scopes("items.write"))
async def create_item(self) -> dict:
return {"created": True}, 201
@get("/admin")
@use_guards(require_roles("admin"))
async def admin_view(self) -> dict:
return {"access": "granted"}
@get("/status")
@public # exempt from BearerGuard
async def status(self) -> dict:
return {"status": "ok"}
@module(controllers=[ItemController])
class AppModule:
pass
app = LaurenFactory.create(AppModule, docs_url="/docs")$ uvicorn main:app --reload
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: [lauren] startup complete: 1 module, 1 controller, 4 routes$ curl http://127.0.0.1:8000/items/status
{"status": "ok"}
$ curl http://127.0.0.1:8000/items/
{"detail": "Unauthorized"} # 401 — missing token
$ curl http://127.0.0.1:8000/items/ -H "Authorization: Bearer good-token"
{"items": []} # 200 — authenticated| Guard | Use when… | Extras |
|---|---|---|
bearer_token |
Opaque server-issued tokens (sessions, API tokens). | — |
basic_auth |
Simple admin endpoints protected by username/password. | — |
api_key |
Service-to-service API keys via header or query param. | — |
jwt_bearer |
Self-contained JWTs (HS/RS/ES + JWKS auto-rotation). | [jwt] |
oauth2_introspection |
Opaque OAuth 2.0 tokens validated via RFC 7662. | [http] |
session_cookie |
Browser sessions backed by SessionStore. |
— |
require_authenticated |
Any handler that just needs somebody logged in. | — |
require_roles |
RBAC: gate by named roles (admin, ops, …). |
— |
require_scopes |
OAuth-style scopes (items.read, users.write). |
— |
csrf |
State-changing endpoints behind cookie auth. | — |
ip_allowlist |
Internal endpoints behind a known proxy / VPN. | — |
from lauren_guards import jwt_bearer
# Symmetric HMAC (HS256) — for services that share a secret.
HsGuard = jwt_bearer(secret="super-secret", algorithms=["HS256"])
# Asymmetric with auto-fetched JWKS (Auth0, Cognito, Keycloak, etc.).
RsGuard = jwt_bearer(
jwks_url="https://example.auth0.com/.well-known/jwks.json",
algorithms=["RS256"],
issuer="https://example.auth0.com/",
audience="https://api.example.com",
jwks_cache_seconds=300,
)jwt_bearer accepts exactly one of secret=, public_key=, or jwks_url=; pass
algorithms= (default ("HS256",)) to restrict which signing algorithms the guard
accepts. Roles and scopes are extracted from configurable claims (role_claim=,
scope_claim= — a string key or a callable for nested paths like Keycloak's
realm_access.roles).
from lauren import LaurenFactory
from lauren_guards import AuthUser, basic_auth, basic_auth_challenge_handler, BcryptHasher
hasher = BcryptHasher()
async def verify(username: str, password: str) -> AuthUser | None:
user = await db.find_user(username)
if user is None or not hasher.verify(password, user.password_hash):
return None
return AuthUser(id=user.id, roles=user.roles)
BasicGuard = basic_auth(verify=verify)
app = LaurenFactory.create(
AppModule,
global_exception_handlers=[basic_auth_challenge_handler],
)basic_auth_challenge_handler attaches WWW-Authenticate: Basic realm="..." to every
401 response so browsers show the native credential dialog. The realm advertised is the
one configured on the guard that produced the 401.
from lauren import Response
from lauren_guards import InMemorySessionStore, session_cookie, sign_cookie
store = InMemorySessionStore()
SESSION_SECRET = "my-signing-secret"
SessGuard = session_cookie(store=store, secret=SESSION_SECRET)
# In a login handler — create the session and set the cookie.
async def login(username: str) -> Response:
session = await store.create(user_id=username, data={"roles": ("user",)}, ttl_seconds=3600)
signed = sign_cookie(session.id, secret=SESSION_SECRET)
return Response.json({"ok": True}).with_cookie(
"lauren_session", signed,
http_only=True, secure=True, same_site="lax",
)store.create(...) returns a Session record — use session.id when signing the cookie.
session_cookie verifies the HMAC signature on every request before asking the store for
the session, so a client can't forge a session id without the server-side secret.
Swap InMemorySessionStore for a Redis-backed implementation in multi-worker production
by implementing the three-method SessionStore protocol (create, get, delete).
from lauren_guards import csrf
CsrfGuard = csrf(cookie_name="csrf_token", header_name="x-csrf-token")Double-submit-cookie pattern: the server issues a token in a cookie; the client must
echo it as a header on state-changing requests. Mismatched or absent pairs are rejected.
GET, HEAD, OPTIONS, and TRACE are exempt by default (override with
safe_methods=); guard state-changing methods behind cookie auth with this.
from lauren_guards import ip_allowlist
InternalGuard = ip_allowlist(
allow=["10.0.0.0/8", "192.168.1.0/24"],
)allow (keyword-only) accepts CIDR strings; bare host IPs are auto-promoted to /32 or
/128. Behind a load balancer, pass trusted_proxies=[...] (the proxy's CIDR ranges) so
the guard walks X-Forwarded-For and uses the first untrusted hop as the client IP —
without it, the guard silently uses the direct ASGI peer.
Opt individual routes out of a controller-level guard without changing the guard or controller configuration:
from lauren_guards import public
@use_guards(BearerGuard)
@controller("/api")
class ApiController:
@get("/status")
@public # exempt — no token needed
async def health(self) -> dict:
return {"status": "ok"}
@get("/profile")
async def profile(self) -> dict: # requires token
return {"user": "..."}@public marks the handler with the IS_PUBLIC_KEY metadata flag and swaps in a
NullGuard that always allows; guards that cooperate with the flag (via
ctx.get_metadata(IS_PUBLIC_KEY, False)) skip authentication on that route.
Every authentication guard writes an AuthUser to request.state.user. Authorization
guards read it. It is a slots dataclass (mutable):
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class AuthUser:
id: str # stable principal identifier
roles: tuple[str, ...] = () # RBAC role strings
scopes: tuple[str, ...] = () # OAuth-style scope strings
claims: dict[str, Any] = field(default_factory=dict) # full credential payload
credential_type: str = "unknown" # "bearer" | "jwt" | "basic" | …Read the authenticated user in any handler:
from lauren import Request
@get("/me")
async def me(self, request: Request) -> dict:
user = request.state.user
return {"id": user.id, "roles": list(user.roles)}Guards run in the order they appear in @use_guards(...), outermost first — and
class-level guards always run before route-level ones:
@use_guards(jwt_bearer(secret="..."), require_scopes("admin"))
@controller("/admin")
class AdminController:
@get("/users")
@use_guards(ip_allowlist(allow=["10.0.0.0/8"]))
async def list_users(self) -> dict: ...First-listed guards run first (outermost); class-level guards always run before route-level
ones. The effective chain for GET /admin/users is:
jwt_bearer— validates the JWT and populatesrequest.state.userrequire_scopes("admin")— checks the user's scopesip_allowlist— checks the source IP
The rule of thumb for what a guard should do on failure:
- Missing or malformed credential → raise
UnauthorizedError(401) - Authenticated but not permitted → raise
ForbiddenError(403)
All guards in this package raise these errors — they never silently return False,
because lauren treats a False return as a generic rejection without the structured
detail payload that UnauthorizedError / ForbiddenError carry.
$ uv tool install prek # one-time
$ prek install # wires up the git hook
$ nox # lint + tests (166 passing) + typecheckThis project is licensed under the terms of the MIT license.