Skip to content

How authentication works in sillo — the two-layer model (AuthenticationMiddleware resolves who, useAuth enforces policy), backends, the identity-to-user flow, composing strategies, custom backends, and error handling. Starts with the smallest working integration.

sillo authentication has a clean two-layer shape:

  1. AuthenticationMiddleware runs once per request and answers who is this caller? It attaches a user object to request.user. It never rejects a request.
  2. useAuth is a per-route gate that answers is this user allowed to call this handler? It runs just before your handler and raises 401/403 on failure.

The middleware is backend-driven: each backend knows how to read exactly one credential type — a JWT, a session cookie, or an API key. You compose backends to support several auth strategies in one app.

This is the whole thing: a JWT backend that resolves request.user, and a protected /me route.

app.py
from sillo import silloApp
from sillo.record import setup_record, DatabaseConfig
from sillo.auth import AuthenticationMiddleware, useAuth
from sillo.auth.jwt_auth import JWTAuthBackend
from sillo.users import User
app = silloApp()
# database (creates the "users" table)
db = setup_record(app, DatabaseConfig.sqlite("app.db"), model_modules=["myapp.models"])
# resolve request.user from a bearer token
app.use(AuthenticationMiddleware(
user_model=User,
backend=JWTAuthBackend(secret_key="change-me", identifier="sub"),
))
@app.get("/me", auth=useAuth())
async def me(request, response):
return {"id": request.user.identity, "email": request.user.email}
myapp/models.py
from sillo.users import User
# create a user once, then issue a token for it
user = await User.objects.create_user(
email="alice@example.com", username="alice", password="StrongP@ss1",
)
token = TokenForUser(user, secret="change-me").token_pair()["access_token"]
# GET /me with Authorization: Bearer <token> → request.user is alice

Two things to internalize from this snippet:

  • identifier="sub" is required for sillo-issued tokens. TokenForUser writes the user id into the JWT sub claim, and the backend reads the claim named by identifier. The backend defaults to identifier="id", which won’t match — leaving you with an empty identity and an unauthenticated user. Always pin identifier="sub" unless you issue tokens with a different claim.
  • useAuth() with no args means “any authenticated user.” Without it, the handler runs even for anonymous callers (with AnonymousUser as request.user). The middleware resolves; the gate enforces.

Layer 1 — AuthenticationMiddleware resolves the user

Section titled “Layer 1 — AuthenticationMiddleware resolves the user”

For every request, the middleware walks its backends in order and stops at the first that succeeds:

request
→ backend 1 (e.g. JWT) → success? use it
→ backend 2 (e.g. session) → success? use it
→ ...
→ none succeed → request.user = AnonymousUser

On success a backend returns an AuthResult(identity, scope). The middleware then calls user_model.load_user(identity) to build the full user object and stores it on request.scope["user"]. It also stores the backend’s scope string (e.g. "jwt", "session", "apikey") on request.scope["auth"].

So the central arrow in sillo auth is: credential → identity string → load_userrequest.user. Everything else is a way of producing that identity.

useAuth runs after the middleware, right before your handler. It inspects request.user and request.scope["auth"] and decides yes/no:

@app.get("/me", auth=useAuth()) # any authenticated user
@app.get("/api", auth=useAuth(scopes=["jwt"])) # only JWT callers
@app.get("/users", auth=useAuth(permissions=["read:users"]))
@app.get("/feed", auth=useAuth(required=False)) # runs either way
async def handler(request, response): ...

On failure it raises AuthenticationFailed (401) or PermissionDenied (403). See Protecting Routes for the full gate reference.

Each backend stamps a scope on request.scope["auth"]. That string is what useAuth(scopes=[...]) checks against:

Backendrequest.scope["auth"]
JWTAuthBackend"jwt"
SessionAuthBackend"session"
APIKeyAuthBackend"apikey"

scopes=[] (the default) accepts any method. scopes=["jwt"] restricts a route to JWT callers — self-documenting and a useful guard against the wrong credential type reaching a handler.

Pass a list to accept more than one credential type. Order matters — the first backend that succeeds wins:

app.use(AuthenticationMiddleware(
user_model=User,
backend=[
JWTAuthBackend(secret_key=JWT_SECRET, identifier="sub"),
SessionAuthBackend(),
],
))

A request with a valid bearer token authenticates as "jwt"; one with only a session cookie as "session"; one with neither gets AnonymousUser and request.scope["auth"] is None. Combining JWT + session is common for an app that serves both a browser UI and a JSON API.

StrategyCredentialBest for
JWTAuthorization: Bearer <token>SPAs, mobile, stateless APIs
Sessionsigned cookie (via SessionMiddleware)server-rendered web apps
API keyX-API-Key headerserver-to-server, programmatic access

You are not limited to one — compose them as above. Each has its own page:

Every user_model satisfies the UserProtocol contract: is_authenticated, identity, display_name, has_permission, and a load_user(identity) classmethod. sillo ships a ready User model (Record/Tortoise-backed, built on UserBaseModel), a SimpleUser for tests, and AnonymousUser as the unauthenticated sentinel. The identity the middleware hands to load_user is a string (the backend’s choice — for JWT it’s the sub claim; for session, the stored user id; for API keys, the user_id).

See Users & User Models for the built-in User, building custom users, permissions, and password hashing.

A backend is just authenticate(request) -> AuthResult. Return AuthResult(success=True, identity=..., scope=...) to accept, or AuthResult(success=False, ...) to decline (so the next backend gets a turn). This is how you’d add, say, a Bearer-token-vs-API-key-within-one-header scheme, or an OAuth introspection backend:

from sillo.auth.backends.base import AuthenticationBackend
from sillo.auth.model import AuthResult
class HeaderBackend(AuthenticationBackend):
async def authenticate(self, request):
token = request.headers.get("X-Service-Token")
if not token:
return AuthResult(success=False, identity="", scope="")
# ... verify token, resolve a user id ...
return AuthResult(success=True, identity=str(user_id), scope="service")

Register it like any built-in: AuthenticationMiddleware(user_model=User, backend=HeaderBackend()). The scope string "service" then becomes available to useAuth(scopes=["service"]).

Both auth failures surface as HTTP exceptions you can catch and reformat like any other:

from sillo.auth.exceptions import AuthenticationFailed, PermissionDenied
@app.add_exception_handler(AuthenticationFailed)
async def on_401(request, response, exc):
return response.json({"error": "unauthorized"}, status_code=401)
ConditionStatusException
No user + required=True401AuthenticationFailed
Scope mismatch401AuthenticationFailed
Permission denied403PermissionDenied
credential (token / cookie / key)
│ AuthenticationMiddleware
├─ backend resolves identity "1" (scope "jwt"/"session"/"apikey")
├─ user_model.load_user("1") ──► request.user (loaded User)
useAuth() ── checks is_authenticated / scope / permissions ──► 401 / 403 / handler

If you keep that diagram in mind, every other auth feature in sillo is just a different way of producing the credential or the identity on the left.

The decision is driven by what the client is, not by preference.

A browser application you also build. Cookie sessions. The browser manages storage, HttpOnly keeps the credential away from JavaScript, and revocation is immediate with a server-side store. The cost is CSRF exposure, which is a solved problem.

A mobile app or a third-party API client. Bearer tokens. Nothing is automatic, which removes CSRF entirely, and the client controls storage. The cost is revocation — a stateless token is valid until it expires unless you keep a denylist.

Machine-to-machine. API keys. Long-lived, scoped, revocable, and issued per integration rather than per user.

Someone else’s identity provider. OAuth2 or OIDC. You stop storing passwords, which is the single largest reduction in risk available, and you take on redirect-flow complexity.

Most applications end up with two: sessions for the browser, tokens or keys for the API. That is fine as long as each endpoint is explicit about which it accepts — an endpoint accepting either has the CSRF exposure of whichever the caller used.

Never store a password recoverably. Hash with bcrypt, scrypt, or Argon2 — never a general-purpose hash, however many rounds. See Hashing helpers.

Never reveal which half of a credential was wrong. “No such user” and “wrong password” together are a user-enumeration oracle. One message for both, and the same response time — an early return on a missing user is measurably faster and is itself the oracle.

Never trust a client-supplied identity. A user id in a header, a body field, or a query parameter is a value the client chose. Identity comes from a verified credential and nowhere else.

Never leave authentication endpoints unlimited. Login, password reset, and token refresh are where credential stuffing lands. Rate limit by IP and by account, and lock or delay after repeated failures. See Rate Limiting.

Never log credentials. A password in a request body, a token in a URL, an Authorization header in a debug dump — each is a credential with a timestamp on it.

Two things happen at login that are easy to omit and hard to notice.

Regenerate the session identifier. Reusing the pre-login id means an attacker who could set the victim’s cookie beforehand shares the session afterwards. This is session fixation, and it is still common.

Record enough to revoke. Storing the session with its user id, issue time, and a device label lets you build “sign out everywhere”, which users expect and which is impossible after the fact if you did not store it.

Expire on two clocks — an idle timeout for abandoned sessions on shared machines, and an absolute lifetime that bounds how long a stolen session is useful. Neither alone is sufficient.

Three tests per protected endpoint, and the middle one is the one that finds bugs: anonymous is rejected, the wrong user is rejected, the right user succeeds.

Build the credentials through your real login path rather than by constructing a session or signing a token in the test. A helper that fabricates a valid token will keep passing after the verification code breaks, which is precisely backwards.

For the negative cases, cover the specific failures: an expired credential, one signed with the wrong key, one for a different audience, and a malformed one. Each takes a different branch in the verifier, and a verifier that accepts any of them is broken in a way the happy path cannot reveal.

Changing how users authenticate is a migration, not a deploy. The pattern that works: accept both old and new credentials for a period, issue only new ones, measure how many old ones are still in use, then stop accepting them.

For a password-hash change — bcrypt to Argon2, or a cost increase — you cannot rehash without the plaintext, which you only have at login. Verify against the old hash, and on success rehash with the new parameters and store it. After enough logins, the remainder are dormant accounts that can be forced through a reset.

Pick the credential type from the client, not from preference. Hash passwords with a password hash. Never let identity come from something the caller supplied. Rate limit the login path. Regenerate session identifiers on privilege change. And test the negative cases, because the happy path passes without them.