Skip to main content

lauren-guards: batteries-included authentication & authorization guards for the lauren web framework.

CI Package version Supported Python versions License Ruff


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, and generate_token() for cryptographically-secure random IDs.
  • Sessions: InMemorySessionStore + a SessionStore protocol for plugging in Redis or Postgres in production.
  • @public decorator: 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 at LaurenFactory.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:

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:

  1. jwt_bearer — validates the JWT and populates request.state.user
  2. require_scopes("admin") — checks the user's scopes
  3. ip_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)

Source distribution for lauren-guards 0.1.1
File Size Uploaded
lauren_guards-0.1.1.tar.gz 74.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lauren-guards 0.1.1
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page