Skip to content

Authentication Architecture

useAuth, AuthenticationBackend, AuthenticationMiddleware, scheme handling

Version: 2026-08-11 Audience: Core maintainers, framework architects, security engineers Purpose: Document the authentication gate, backend contract, middleware pipeline, and OpenAPI security scheme emission


Sillo’s authentication system is split into three layers that compose cleanly:

  1. Backends. Read a credential from the request and return an AuthResult.
  2. Middleware: iterates backends on every request, sets ctx.scope["user"].
  3. Route gate (useAuth): per-route enforcement of scheme restrictions, permissions, and optional authentication.

The design principle: the middleware never rejects a request. It always runs the downstream app. Rejection is the route gate’s job. This lets some routes be public while others require authentication, without the middleware needing to know which is which.

flowchart TD
    REQ[Incoming HTTP Request] --> MW[AuthenticationMiddleware]
    MW --> B1[Backend 1]
    B1 -->|fail| B2[Backend 2]
    B2 -->|fail| B3[Backend N]
    B3 -->|fail| UNAUTH[UnauthenticatedUser on scope]
    B1 -->|success| SET[Set scope user/auth/auth_scheme]
    B2 -->|success| SET
    B3 -->|success| SET
    SET --> NEXT[downstream app]
    UNAUTH --> NEXT
    NEXT --> ROUTE[Route Handler]
    ROUTE --> GATE{useAuth gate?}
    GATE -->|no gate| RESP[Response]
    GATE -->|check schemes| SCHEME{Scheme accepted?}
    GATE -->|check perms| PERM{Permission ok?}
    SCHEME -->|no| ERR401[401 AuthenticationFailed]
    SCHEME -->|yes| RESP
    PERM -->|no| ERR403[403 PermissionDenied]
    PERM -->|yes| RESP

classDiagram
    class AuthResult {
        +str identity
        +str scope
        +bool success
    }

    class AuthenticationBackend {
        +str name
        +str description
        +describe() SecurityScheme
        +authenticate(ctx) AuthResult
        +handle_exception(ctx, exc)
    }

    class AuthenticationMiddleware {
        +list~AuthenticationBackend~ backends
        +type user_model
        +authenticate(ctx)
        +__call__(scope, receive, send)
    }

    class useAuth {
        +dict schemes
        +bool all_of
        +list~str~ permissions
        +list~AuthenticationBackend~ backends
        +type user_model
        +bool required
        +authenticate(request) bool
        +security_requirements(available) list
    }

    class AuthException {
        +int status_code
        +str detail
        +dict headers
    }

    class AuthenticationFailed {
        status_code = 401
    }

    class PermissionDenied {
        status_code = 403
    }

    AuthenticationBackend --> AuthResult : returns
    AuthenticationMiddleware --> AuthenticationBackend : iterates
    useAuth --> AuthenticationBackend : optional override
    AuthException <|-- AuthenticationFailed
    AuthException <|-- PermissionDenied
    useAuth ..> AuthException : raises

File: core/sillo/auth/model.py

AuthResult is a plain dataclass returned by every AuthenticationBackend.authenticate implementation. It carries three fields:

FieldTypeMeaning
identitystrThe resolved user identifier (e.g. user ID, email, API key token). Empty string on failure.
scopestrA label identifying the authentication method (e.g. "jwt", "session", "apikey"). Empty string on failure.
successboolTrue means the backend resolved a valid identity; False means the next backend should be tried.
@dataclass
class AuthResult:
identity: str
scope: str
success: bool

Design note: scope is a method label, not an OpenAPI scheme name. The shipped backends now report their name attribute (e.g. "bearerAuth") as the scope, but custom backends may report any string. The LEGACY_SCOPE_ALIASES mapping exists because older code used labels like "jwt" instead of scheme names.


AuthenticationBackend: The Backend Contract

Section titled “AuthenticationBackend: The Backend Contract”

File: core/sillo/auth/backend.py

AuthenticationBackend is the abstract base class for all authentication backends. Subclasses must implement authenticate(). The class provides:

MemberTypePurpose
namestrThe OpenAPI security scheme name. Default "auth".
descriptionstr | NoneProse shown next to the credential in API docs.
describe()→ SecurityScheme | NoneReturns the OpenAPI scheme object, or None to skip documentation.
authenticate(request)→ AuthResultExtract credentials from the request and return an AuthResult.
handle_exception(response, exc)→ NoneCalled when authenticate raises. Default logs at WARNING level.

This is a critical distinction:

  • name (class attribute): the OpenAPI security scheme name. A route gate matches on this. It is set once on the backend class and never changes per request. Example: "bearerAuth", "sessionCookie", "apiKeyHeader".

  • scope (in AuthResult): the authentication method label returned per request. For shipped backends, this equals self.name. For custom backends, it can be any string.

The middleware sets both ctx.scope["auth_scheme"] (= backend.name) and ctx.scope["auth"] (= auth_result.scope). The gate checks both.

Returns an OpenAPI SecurityScheme object (or subclass like HTTPBearer, APIKey) that documents how the backend’s credential appears in the API. Returning None keeps the backend working but excludes it from the documentation.

Each shipped backend overrides describe():

BackendReturns
JWTAuthBackendHTTPBearer(type="http", scheme="bearer", bearerFormat="JWT")
SessionAuthBackendAPIKey(type="apiKey", name=cookie_name, **{"in": "cookie"})
APIKeyAuthBackendAPIKey(type="apiKey", name=header_name, **{"in": "header"})

Called by the middleware when authenticate() raises. The default implementation logs the error at WARNING level and returns, allowing the middleware to continue to the next backend. Override to short-circuit with a 401, emit metrics, or notify an operations channel.


AuthenticationMiddleware: The ASGI Pipeline

Section titled “AuthenticationMiddleware: The ASGI Pipeline”

File: core/sillo/auth/middleware.py

class AuthenticationMiddleware:
def __init__(
self,
user_model: type[BaseUser] = SimpleUser,
backend: AuthenticationBackend | list[AuthenticationBackend] = None,
)
async def __call__(self, scope, receive, send) -> None: ...

Plain raw ASGI, not a BaseMiddleware subclass — it never touches a response, only scope, so there was nothing the dispatch bridge’s request and response machinery bought it. __call__ builds its own HttpContext, calls authenticate(ctx) below to mutate scope, then runs the downstream app directly.

ParameterDefaultPurpose
user_modelSimpleUserThe user model class used to load user objects from identity strings. Must implement BaseUser.load_user(identity).
backendNoneA single backend or list of backends. Normalized to a list internally.
sequenceDiagram
    participant Client
    participant MW as AuthenticationMiddleware
    participant B1 as Backend 1
    participant B2 as Backend 2
    participant Handler as Route Handler

    Client->>MW: HTTP Request
    MW->>B1: authenticate(request)
    B1-->>MW: AuthResult(success=False)
    MW->>B2: authenticate(request)
    B2-->>MW: AuthResult(success=True, identity="42", scope="bearerAuth")
    MW->>MW: scope["user"] = load_user("42")
    MW->>MW: scope["auth"] = "bearerAuth"
    MW->>MW: scope["auth_scheme"] = "bearerAuth"
    MW->>Handler: downstream app(scope, receive, send)
    Handler-->>Client: Response
  1. Iterates backends in order. Processing stops at the first successful AuthResult.
  2. Sets three scope keys on success: "user", "auth", "auth_scheme".
  3. Falls back to UnauthenticatedUser when no backend succeeds. The scope keys "auth" and "auth_scheme" are set to None.
  4. Always runs the downstream app: the middleware never rejects a request. Rejection is the route gate’s responsibility.
  5. Catches backend exceptions and passes them to handle_exception(). The middleware continues to the next backend.

authenticate(ctx) — called from __call__, before the downstream app runs — uses Python’s for...else construct: the else block runs only when the loop completes without break. This means the UnauthenticatedUser fallback is set exactly when no backend succeeds:

async def authenticate(self, ctx) -> None:
for backend in self.backends:
try:
auth_result = await backend.authenticate(ctx)
if auth_result.success:
ctx.scope["user"] = await self.user_model.load_user(auth_result.identity)
ctx.scope["auth"] = auth_result.scope
ctx.scope["auth_scheme"] = backend.name
break
except Exception as e:
backend.handle_exception(ctx, e)
continue
else:
ctx.scope["user"] = UnauthenticatedUser()
ctx.scope["auth"] = None
ctx.scope["auth_scheme"] = None

File: core/sillo/auth/use_auth.py

class useAuth:
def __init__(
self,
permissions: list[str] | None = None,
backends: list[AuthenticationBackend] | None = None,
user_model: type[BaseUser] | None = None,
required: bool = True,
schemes: list[str] | dict[str, list[str]] | None = None,
all_of: bool = False,
)
ParameterDefaultPurpose
schemesNoneOpenAPI security scheme names accepted for this route. List or mapping with OAuth2 scopes.
all_ofFalseRequire every scheme together (AND) vs any one (OR).
permissionsNonePermission strings checked via user.has_permission().
backendsNoneOverride the middleware’s backends for this route.
user_modelNoneUser model for loading identities when backends are overridden. Defaults to SimpleUser.
requiredTrueWhen False, unauthenticated requests pass through with UnauthenticatedUser.

The schemes parameter accepts two forms and normalizes to {name: oauth_scopes}:

# List form — no OAuth2 scopes
useAuth(schemes=["bearerAuth", "sessionCookie"])
# → {"bearerAuth": [], "sessionCookie": []}
# Mapping form — with OAuth2 scopes
useAuth(schemes={"oauth2": ["read:widgets"]})
# → {"oauth2": ["read:widgets"]}
flowchart TD
    A[authenticate called] --> B{backends overridden?}
    B -->|yes| C[_authenticate_with_backends]
    B -->|no| D[Read user from scope]
    C --> D
    D --> E{user authenticated?}
    E -->|no| F{required?}
    F -->|yes| G[raise AuthenticationFailed]
    F -->|no| H[return True: anonymous pass-through]
    E -->|yes| I{schemes configured?}
    I -->|yes| J[_check_schemes]
    I -->|no| K{permissions configured?}
    J --> K
    K -->|yes| L[Check each permission]
    K -->|no| M[return True]
    L --> N{all permissions ok?}
    N -->|yes| M
    N -->|no| O[raise PermissionDenied]

The order of checks is: backends → authentication → schemes → permissions.

Verifies that the request authenticated through an accepted scheme. Uses the accepted_identifiers and request_identifiers helper functions to handle legacy scope aliases:

from sillo import HttpContext
def _check_schemes(self, ctx: HttpContext) -> None:
accepted = accepted_identifiers([*self.schemes])
if request_identifiers(ctx).isdisjoint(accepted):
raise AuthenticationFailed

Both ctx.scope["auth_scheme"] and ctx.scope["auth"] are consulted. For shipped backends these are always equal; they differ only for custom backends that report a scope but never set a name.

When the gate has its own backends list, it iterates them in order (same pattern as the middleware) and overrides ctx.scope["user"], ["auth"], and ["auth_scheme"] on success. Backend exceptions are silently caught so the next backend gets a chance. If all fail and required is True, raises AuthenticationFailed.

Generates the OpenAPI security field for the route:

Gate ConfigurationOpenAPI Output
schemes=[a, b][{a: []}, {b: []}] (either)
schemes=[a, b], all_of=True[{a: [], b: []}] (both)
required=FalseExtra {} alternative appended
No schemes, available givenEvery available scheme (either)
No schemes, nothing availableNone

File: core/sillo/auth/exceptions.py

classDiagram
    class HTTPException {
        +int status_code
        +str detail
        +dict headers
    }

    class AuthException {
        +int status_code
        +str detail
        +dict headers
    }

    class AuthenticationFailed {
        status_code = 401
        detail = "Authentication failed"
    }

    class PermissionDenied {
        status_code = 403
        detail = "Permission denied"
    }

    HTTPException <|-- AuthException
    AuthException <|-- AuthenticationFailed
    AuthException <|-- PermissionDenied

Base class for all auth-related exceptions. Inherits from HTTPException so instances can be directly converted to HTTP responses. The headers parameter defaults to an empty dict.

Raised when authentication fails: no valid credentials, scheme mismatch, or all backends failed. Default detail: "Authentication failed".

Raised when an authenticated user lacks a required permission. Default detail: "Permission denied".

An async error handler registered with the framework to convert AuthException instances into JSON responses:

from sillo import HttpContext, json
async def AuthErrorHandler(ctx: HttpContext, exc: HTTPException):
return json(exc.detail, status_code=exc.status_code, headers=exc.headers)

Each backend’s describe() method returns an OpenAPI SecurityScheme (or None). The framework collects these during route iteration and emits them under components.securitySchemes in the generated OpenAPI document.

flowchart LR
    B1[JWTAuthBackend] -->|describe| S1[HTTPBearer]
    B2[SessionAuthBackend] -->|describe| S2[APIKey cookie]
    B3[APIKeyAuthBackend] -->|describe| S3[APIKey header]
    S1 --> DOC[OpenAPI Document]
    S2 --> DOC
    S3 --> DOC
    GATE[useAuth.security_requirements] --> DOC

The route gate’s security_requirements() method generates the per-route security field. This ensures the gate and the document cannot disagree. A route gated on schemes=["bearerAuth"] will always document bearerAuth as its security requirement.

Backendnamedescribe() Output
JWTAuthBackend"bearerAuth"HTTPBearer(type="http", scheme="bearer", bearerFormat="JWT")
SessionAuthBackend"sessionCookie"APIKey(type="apiKey", name="session_id", **{"in": "cookie"})
APIKeyAuthBackend"apiKeyHeader"APIKey(type="apiKey", name="X-API-Key", **{"in": "header"})

File: core/sillo/auth/use_auth.py (lines 58 to 87)

LEGACY_SCOPE_ALIASES = {
"jwt": "bearerAuth",
"session": "sessionCookie",
"apikey": "apiKeyHeader",
}

The shipped backends used to report AuthResult.scope as "jwt", "session", or "apikey". They now report their scheme name instead ("bearerAuth", etc.). The aliases exist so that:

  1. A gate written as useAuth(schemes=["jwt"]) still works against a backend that now reports "bearerAuth".
  2. A custom backend that returns AuthResult(scope="jwt") still satisfies a gate saying "bearerAuth".

Returns the set of identifiers a gate written against names should accept. Both the value as written and its modern spelling:

accepted_identifiers(["jwt"])
# → {"jwt", "bearerAuth"}

Returns the identifiers the request authenticated under. Both auth_scheme and auth from the request scope:

from sillo import HttpContext
def request_identifiers(ctx: HttpContext):
return {ctx.scope.get("auth_scheme"), ctx.scope.get("auth")}

The authentication system sets three keys in ctx.scope:

KeySet ByTypeMeaning
"user"Middleware or gateUserProtocol instanceThe authenticated user, or UnauthenticatedUser()
"auth"Middleware or gatestr | NoneThe AuthResult.scope value (method label)
"auth_scheme"Middleware or gatestr | NoneThe backend.name value (scheme name)

These are set by the middleware on every request. If the gate has custom backends, it overwrites them on success.


The middleware always runs the downstream app, even when no backend succeeds. This is intentional: the middleware does not know which routes require authentication. The useAuth gate makes that decision. This allows public routes to coexist with authenticated routes in the same application.

Setting ctx.scope["user"] to None would require every handler to check for None before accessing user.is_authenticated. UnauthenticatedUser satisfies the UserProtocol interface with is_authenticated = False, so handlers can always call ctx.user.is_authenticated without null checks.

If backend A raises an unexpected exception (database down, network timeout), the middleware should try backend B rather than failing the entire request. The handle_exception hook lets backends log or metric the failure without blocking the chain.

Some routes are better with authentication but work without it (e.g. a feed that shows personalized content for logged-in users but public content otherwise). required=False lets the gate pass through anonymous users while still running scheme and permission checks for authenticated ones.

The middleware’s for...else is Python’s less-known control flow: the else runs when the loop finishes without break. This is exactly the “no backend succeeded” case, and it avoids needing a separate found = False flag.


ComponentFileLines
AuthResultcore/sillo/auth/model.py1-35
AuthenticationBackendcore/sillo/auth/backend.py1-144
AuthenticationMiddlewarecore/sillo/auth/middleware.py1-168
useAuthcore/sillo/auth/use_auth.py1-390
LEGACY_SCOPE_ALIASEScore/sillo/auth/use_auth.py62-66
accepted_identifierscore/sillo/auth/use_auth.py69-87
request_identifierscore/sillo/auth/use_auth.py90-97
AuthExceptioncore/sillo/auth/exceptions.py16-68
AuthenticationFailedcore/sillo/auth/exceptions.py71-114
PermissionDeniedcore/sillo/auth/exceptions.py117-161
AuthErrorHandlercore/sillo/auth/exceptions.py164-197
Package exportscore/sillo/auth/__init__.py1-69

AuthenticationBackend: Full Source Walkthrough

Section titled “AuthenticationBackend: Full Source Walkthrough”

The AuthenticationBackend class at core/sillo/auth/backend.py is intentionally minimal. Here is every line of the class with annotations:

from sillo import HttpContext
class AuthenticationBackend:
# Class-level attribute — the OpenAPI security scheme name.
# Subclasses override this to declare their scheme identity.
# The default "auth" is a placeholder; every shipped backend
# sets its own value.
name: str = "auth"
# Optional prose for the OpenAPI document. Set per-instance
# in __init__ so two backends of the same class can have
# different descriptions (e.g. "User tokens" vs "Admin tokens").
description: str | None = None
def describe(self) -> SecurityScheme | None:
# Returns None by default — a backend with nothing to
# document (health-check bypass, custom credential that
# OpenAPI cannot express) stays working and is simply
# omitted from the document.
return None
async def authenticate(self, ctx: HttpContext) -> AuthResult:
# Abstract — subclasses must override. The NotImplementedError
# message includes the class name for debugging.
raise NotImplementedError(
f"{type(self).__name__} must implement authenticate()"
)
def handle_exception(self, response: BaseResponse, exc: Exception) -> None:
# Default: log at WARNING and return. The middleware
# continues to the next backend. Override to short-circuit,
# emit metrics, or notify ops.
logger.warning("Auth backend %s failed: %s", type(self).__name__, exc)

A complete custom backend that authenticates via a signed timestamp header:

import hmac
import time
from sillo.auth.backend import AuthenticationBackend
from sillo.auth.model import AuthResult
from sillo import HttpContext
class SignedTimestampBackend(AuthenticationBackend):
name = "signedTimestamp"
def __init__(self, secret: str, max_age: int = 300):
self.secret = secret
self.max_age = max_age
async def authenticate(self, ctx: HttpContext) -> AuthResult:
header = ctx.headers.get("X-Signature")
if not header:
return AuthResult(success=False, identity="", scope="")
try:
timestamp_str, signature = header.split(":", 1)
timestamp = int(timestamp_str)
except (ValueError, AttributeError):
return AuthResult(success=False, identity="", scope="")
# Reject stale signatures
if abs(time.time() - timestamp) > self.max_age:
return AuthResult(success=False, identity="", scope="")
expected = hmac.new(
self.secret.encode(),
timestamp_str.encode(),
"sha256"
).hexdigest()
if not hmac.compare_digest(signature, expected):
return AuthResult(success=False, identity="", scope="")
return AuthResult(success=True, identity=str(timestamp), scope=self.name)

AuthenticationMiddleware: Complete Internal Flow

Section titled “AuthenticationMiddleware: Complete Internal Flow”

Here is the full authenticate method with every branch annotated — the half of __call__ that runs before the downstream app:

from sillo import HttpContext
async def authenticate(self, ctx: HttpContext) -> None:
# Branch 1: Try each backend in order
for backend in self.backends:
try:
auth_result = await backend.authenticate(ctx)
if auth_result.success:
# SUCCESS PATH:
# 1. Load user object from identity string
# 2. Set all three scope keys
# 3. Break out of the loop
ctx.scope["user"] = await self.user_model.load_user(
auth_result.identity
)
ctx.scope["auth"] = auth_result.scope
ctx.scope["auth_scheme"] = backend.name
break
except Exception as e:
# FAILURE PATH (exception):
# 1. Let the backend log/metric the failure
# 2. Continue to the next backend
backend.handle_exception(ctx, e)
continue
else:
# FAILURE PATH (no backend succeeded):
# The for...else block runs only when the loop
# completes without break — i.e., no backend succeeded.
ctx.scope["user"] = UnauthenticatedUser()
ctx.scope["auth"] = None
ctx.scope["auth_scheme"] = None
# Nothing is returned: authentication never answers a request on its
# own, it only decides who made it. __call__ runs the downstream app
# unconditionally once this returns.
from sillo import HttpContext
async def authenticate(self, ctx: HttpContext) -> bool:
# Step 1: If the gate has custom backends, run them first.
# This overrides whatever the middleware set on the scope.
if self.backends is not None:
await self._authenticate_with_backends(ctx)
# Step 2: Check if the user is authenticated.
# Reads from ctx.scope["user"] — either set by the
# middleware or by the custom backends above.
user = ctx.scope.get("user")
if not user or not user.is_authenticated:
if self.required:
raise AuthenticationFailed
# required=False: anonymous pass-through
return True
# Step 3: Check scheme restrictions.
# Only runs if the gate declared specific schemes.
if self.schemes:
self._check_schemes(ctx)
# Step 4: Check permissions.
# Runs for every permission in the list.
if self.permissions:
for perm in self.permissions:
if not user.has_permission(perm):
raise PermissionDenied
return True

The _authenticate_with_backends method in detail:

Section titled “The _authenticate_with_backends method in detail:”
from sillo import HttpContext
async def _authenticate_with_backends(self, ctx: HttpContext) -> None:
user_model = self._resolve_user_model()
for backend in self.backends:
try:
result = await backend.authenticate(ctx)
if result.success:
# Override the scope with the gate's backend result
ctx.scope["user"] = await user_model.load_user(result.identity)
ctx.scope["auth"] = result.scope
ctx.scope["auth_scheme"] = backend.name
return
except Exception:
# Silently catch — try the next backend
continue
# All backends failed
if self.required:
raise AuthenticationFailed
from sillo.auth import AuthenticationMiddleware, JWTAuthBackend
from sillo.users import User
app.use(AuthenticationMiddleware(
user_model=User,
backend=JWTAuthBackend(secret_key="my-secret-key"),
))
from sillo.auth import AuthenticationMiddleware, JWTAuthBackend, SessionAuthBackend
app.use(AuthenticationMiddleware(
user_model=User,
backend=[
JWTAuthBackend(secret_key="my-secret-key"),
SessionAuthBackend(),
],
))
from sillo.auth import AuthenticationMiddleware, JWTAuthBackend, APIKeyAuthBackend
app.use(AuthenticationMiddleware(
user_model=User,
backend=[
JWTAuthBackend(secret_key="my-secret-key"),
APIKeyAuthBackend(header_name="X-API-Key", verify_with_manager=True),
],
))
from sillo import HttpContext
@app.get("/health")
async def health(ctx: HttpContext):
return {"status": "ok"}
from sillo import HttpContext
@app.get("/profile", auth=useAuth())
async def profile(ctx: HttpContext):
return {"user": ctx.user.display_name}
from sillo import HttpContext
@app.get("/api/data", auth=useAuth(schemes=["bearerAuth"]))
async def api_data(ctx: HttpContext):
return {"data": "..."}
from sillo import HttpContext
@app.post("/admin/users", auth=useAuth(permissions=["manage_users"]))
async def manage_users(ctx: HttpContext):
return {"status": "ok"}
from sillo import HttpContext
@app.get("/feed", auth=useAuth(required=False))
async def feed(ctx: HttpContext):
if ctx.user.is_authenticated:
return {"feed": "personalized"}
return {"feed": "public"}
from sillo import HttpContext
@app.post("/webhook", auth=useAuth(backends=[SignedTimestampBackend(secret="...")]))
async def webhook(ctx: HttpContext):
return {"received": True}
from sillo import HttpContext
@app.get("/resource", auth=useAuth(schemes=["bearerAuth", "sessionCookie"]))
async def resource(ctx: HttpContext):
return {"data": "..."}
from sillo import HttpContext
@app.delete("/critical", auth=useAuth(schemes=["bearerAuth", "apiKeyHeader"], all_of=True))
async def critical(ctx: HttpContext):
return {"deleted": True}

The gate is designed to be subclassed for custom authorization logic:

from sillo import HttpContext
class OrgAuth(useAuth):
def __init__(self, org_id_param: str, **kwargs):
super().__init__(**kwargs)
self.org_id_param = org_id_param
async def authenticate(self, ctx: HttpContext) -> bool:
# Run the base authentication first
if not await super().authenticate(ctx):
return False
# Add custom authorization check
org_id = ctx.path_params.get(self.org_id_param)
if not org_id:
raise AuthenticationFailed("Missing organization ID")
if not ctx.user.belongs_to_org(org_id):
raise PermissionDenied("Not a member of this organization")
return True

Usage:

from sillo import HttpContext
@app.get("/orgs/{org_id}/members", auth=OrgAuth(org_id_param="org_id"))
async def org_members(ctx: HttpContext):
...

Register the auth error handler during application setup:

from sillo.auth.exceptions import AuthException, AuthErrorHandler
app.add_error_handler(AuthException, AuthErrorHandler)

This ensures that AuthenticationFailed and PermissionDenied exceptions produce JSON responses:

// 401
{"detail": "Authentication failed"}
// 403
{"detail": "Permission denied"}

File: core/sillo/auth/__init__.py

The auth package uses deferred() to avoid importing Tortoise ORM or PyJWT at module load time:

from sillo._internals.lazy import deferred
__getattr__ = deferred(
__name__,
{
"apikey": ".apikey",
"jwt_auth": ".jwt_auth",
"session_auth": ".session_auth",
"APIKeyAuthBackend": ".apikey",
"JWTAuthBackend": ".jwt_auth",
"SessionAuthBackend": ".session_auth",
"create_jwt": ".jwt_auth",
"decode_jwt": ".jwt_auth",
},
)

This means import sillo.auth does NOT require:

  • tortoise-orm (needed by session/JWT/API key models)
  • PyJWT (needed by JWT backend)

These are loaded on first access. The import paths and __all__ are unchanged.

The authentication system is fully async and safe for concurrent requests:

  • AuthenticationBackend.authenticate() is async: it can await database queries, HTTP calls, etc.
  • AuthenticationMiddleware creates no shared mutable state: each request gets its own scope
  • useAuth instances are created once at route registration time and reused: they are read-only after construction
  • The for...else pattern in the middleware is safe because each request runs in its own coroutine
import pytest
from sillo.auth.model import AuthResult
@pytest.mark.asyncio
async def test_custom_backend():
backend = MyCustomBackend(secret="test")
# Mock ctx with valid credential
ctx = MockRequest(headers={"X-Token": "valid-token"})
result = await backend.authenticate(ctx)
assert result.success is True
assert result.identity == "expected-user-id"
assert result.scope == "myCustom"
@pytest.mark.asyncio
async def test_useAuth_required():
gate = useAuth(required=True)
ctx = MockRequest(scope={"user": UnauthenticatedUser()})
with pytest.raises(AuthenticationFailed):
await gate.authenticate(ctx)
@pytest.mark.asyncio
async def test_useAuth_optional():
gate = useAuth(required=False)
ctx = MockRequest(scope={"user": UnauthenticatedUser()})
result = await gate.authenticate(ctx)
assert result is True
  1. Backend ordering matters. Place the most likely-to-succeed backend first. JWT before session before API key is typical for web apps.

  2. load_user is called once per request (on success). If it involves a database query, consider caching the result in the request scope.

  3. handle_exception should be fast. It runs in the hot path for every failed backend. Avoid expensive operations (network calls, heavy logging).

  4. security_requirements is called during OpenAPI generation, not per request. It can be slower without impacting runtime performance.

  5. The for...else pattern has zero overhead compared to a found flag: Python optimizes the loop exit path.