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

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, and 3.13 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

Example

Create it

from pydantic import BaseModel

from lauren import LaurenFactory, controller, get, post, module, use_guards, Path, 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,
)

HTTP Basic with WWW-Authenticate

from lauren import LaurenFactory
from lauren_guards import 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.

Session cookies

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, 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",
    )

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.

IP allowlist

from lauren_guards import ip_allowlist

InternalGuard = ip_allowlist(
    "10.0.0.0/8",
    "192.168.1.0/24",
)

@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": "..."}

The AuthUser record

Every authentication guard writes an AuthUser to request.state.user. Authorization guards read it. It is a frozen dataclass:

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:

@use_guards(jwt_bearer(...), require_scopes("admin"))
@controller("/admin")
class AdminController:
    @get("/users")
    @use_guards(ip_allowlist("10.0.0.0/8"))
    async def list_users(self) -> dict: ...

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("10.0.0.0/8") — checks the source IP

Returning False from any guard yields 403 Forbidden. Raising UnauthorizedError yields 401 Unauthorized. The rule of thumb:

  • Missing or malformed credential → raise UnauthorizedError (401)
  • Authenticated but not permitted → return False (403)

Development

$ uv tool install prek      # one-time
$ prek install              # wires up the git hook
$ nox                       # lint + tests (152 passing) + typecheck

License

This project is licensed under the terms of the MIT license.

Release files for lauren-guards 0.1.0

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.0
File Size Uploaded
lauren_guards-0.1.0.tar.gz 71.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lauren-guards 0.1.0
File Interpreter ABI Platform
lauren_guards-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 111.0 kB

Release files / lauren_guards-0.1.0.tar.gz

Download URL lauren_guards-0.1.0.tar.gz
Size 71.7 kB
Tags Source
SHA-256 checksum
How to use checksums
13afdb638a38ef4fda8e50eabda196576e4720d52a7f7f2b67e33c3a79f800eb
BLAKE2b-256 checksum
How to use checksums
1a8eba15880c80177c7ab09d7c1513ada6ab032573a88dd08cd8876df83e0b37
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 21, 2026.

Transparency log

Release files / lauren_guards-0.1.0-py3-none-any.whl

Download URL lauren_guards-0.1.0-py3-none-any.whl
Size 39.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
963add1546bfc060e73a502b5d8072141aa444d7f7070d7b03016a7ad0694184
BLAKE2b-256 checksum
How to use checksums
8b57b5205f939fcb2edcbc9f4bfa1ed040a9eea0e82bd248b11b56cb386b594f
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 21, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

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