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.
Requirements
Python 3.11, 3.12, 3.13, and 3.14 are supported. Requires lauren ≥ 1.0.0.
Installation
$ pip install lauren-guards
Optional 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 above
Documentation
The 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/
Example
Create it
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")
Run it
$ 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
Check it
$ 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 catalog
| 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. | — |
Examples
JWT Bearer with JWKS rotation
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).
HTTP Basic with WWW-Authenticate
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.
Session cookies
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).
CSRF protection
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.
IP allowlist
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.
@public routes
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.
The AuthUser record
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)}
Guard composition
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.
Development
$ uv tool install prek # one-time
$ prek install # wires up the git hook
$ nox # lint + tests (166 passing) + typecheck
License
This project is licensed under the terms of the MIT license.
Release files for lauren-guards 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lauren_guards-0.1.1.tar.gz | 74.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lauren_guards-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 115.1 kB
Release files / lauren_guards-0.1.1.tar.gz
| Download URL | lauren_guards-0.1.1.tar.gz |
|---|---|
| Size | 74.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b2f8ab2ac0a7baa097270365fcd904809e1f031ecb03647e2b9bf6d29309a2aa
|
|
BLAKE2b-256 checksum How to use checksums |
5b08755bc34d52f47aa509c99241ae47bd91f79e8405105d7239c45a25bc3779
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.
Transparency logRelease files / lauren_guards-0.1.1-py3-none-any.whl
| Download URL | lauren_guards-0.1.1-py3-none-any.whl |
|---|---|
| Size | 40.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ede972069be68f82b6d0083c6e7a6c3de80fbadb4536a5234c647f50a4bb603d
|
|
BLAKE2b-256 checksum How to use checksums |
c0924caa96a667ed906d444cf87252ec432fd41571ed0b58fbc527065826fe93
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 22, 2026.
Transparency log