Generated routes#
A configured local-auth profile contributes a native Litestar route tree. The routes are ordinary handlers on ordinary controllers, so they appear in your OpenAPI document alongside your own, and the same schema plugins and render plugins apply to them.
Which routes exist depends on the profile. A session profile has no token routes, a token profile has no session routes, and registration routes exist only when a registration policy allows them.
Session and hybrid profiles require application-owned Litestar session
middleware and exactly one native or external CSRF implementation. The local
profile receives neither a session backend nor a session store; configure
CookieBackendConfig or ServerSideSessionConfig directly on
Litestar.
Wire format#
By default, every request and response body in the generated tree uses
snake_case members, spelled exactly as the Python attribute is spelled, and
rejects a member it does not model. A body carrying an unrecognized field is a
400, not a silently discarded key.
Rejecting the member is what keeps a stale or misspelled optional field from
resolving to its default. A client sending returnTo where the schema
declares return_to gets an error naming the field, rather than a successful
request that quietly redirected somewhere else.
Choosing the casing#
Two settings on SecurityConfig decide how the
generated bodies are spelled. If your API is camelCase, say so once and the
whole /auth tree follows:
from litestar_security import SecurityConfig
config = SecurityConfig(local_auth=local_auth, wire_rename="camel")
Setting |
Meaning |
|---|---|
|
|
|
|
The choice reaches the request body, the response body, and the OpenAPI schema together, so a client generated from the document is already speaking the right convention. It does not change which routes exist, what they require, or what their component type names are: a casing change moves members, never the type names your generated client is compiled against.
A callable receives the Python attribute name and returns the wire name:
config = SecurityConfig(local_auth=local_auth, wire_rename=lambda name: name.upper())
Names that are not ours to rename#
A few members are fixed by the specification that defines them, and no casing setting reaches them:
the JWKS document (RFC 7517) and the protected-resource metadata document (RFC 9728), which are published as-is;
the OIDC back-channel logout body, whose single member is the one the identity provider sends;
the token response (RFC 6749, section 5.1) —
access_token,refresh_token,expires_in, andtoken_type.
The error body is left alone for a different reason: it is rendered by your application’s exception handling rather than by these routes, so renaming it in the document would describe a body the route never sends.
Tag groups#
Generated operations are filed under ten tags rather than one, so the rendered document separates the ways to sign in from the flows that repair an account nobody can sign in to. Each group is addressed in configuration by a stable key that does not change when its display name does:
Key |
Tag |
Operations |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Descriptions for every group a configured feature generates routes for are contributed to your OpenAPI config. Declaring a tag of the same name yourself keeps your description: the operations still land in that group, described the way you chose.
from litestar.openapi import OpenAPIConfig
from litestar.openapi.spec import Tag
openapi_config = OpenAPIConfig(
title="Example",
version="1.0",
tags=[Tag(name="Local sessions", description="Sign-in for the web client.")],
)
The defaults are also available directly, keyed by the stable key, which is useful for ordering them among your own:
from litestar_security import ROUTE_TAGS
ROUTE_TAGS["local.sessions"].name # "Local sessions"
Documentation metadata#
Tag names, tag descriptions, operation identifiers, and route names are the
application’s to set. Pass a RouteDocs to any
feature configuration:
from litestar_security import RouteDocs
from litestar_security.accounts import LocalAuth
local_auth = LocalAuth.session(
accounts=accounts,
secrets=secrets,
binding=binding,
docs=RouteDocs(
tags={"local.sessions": "Sign-in", "local.passwords": "Account recovery"},
tag_descriptions={"local.sessions": "Sign-in for the web client."},
),
)
Routes regroup under the new name, and the renamed group carries either your
description or the built-in one. Renaming two groups to the same name merges
them into one group deliberately. A key that names no group raises
ImproperlyConfiguredException at configuration time, so a typo never
silently does nothing.
MFAConfig and PasskeyConfig generate one shared route bundle, and the
step_up group belongs to both, so when both features are configured they
must carry the same RouteDocs.
Renaming operations#
operation_id and route_name take a callable receiving the built-in value
and returning the replacement, so a naming convention is one function rather
than an override per route. Under litestar-vite’s TypeGenConfig the
operation identifier becomes the generated TypeScript client’s function name,
which is usually why an application wants to change it:
from litestar_security import RouteDocs
docs = RouteDocs(operation_id=lambda name: name[0].lower() + name[1:])
# LocalSessionLogin -> localSessionLogin, PasskeyList -> passkeyList
The built-in identifiers are noun-first, so LocalSessionLogin and
LocalSessionList sort next to each other in a generated client. A verb-first
convention is the same shape of transform:
_VERBS = {"Login", "Logout", "List", "Revoke", "Remove", "Refresh"}
def verb_first(name: str) -> str:
for verb in _VERBS:
if name.endswith(verb):
return verb + name[: -len(verb)]
return name
docs = RouteDocs(operation_id=verb_first)
# LocalSessionLogin -> LoginLocalSession
Two routes resolving to the same operation identifier or the same route name
after a transform raise ImproperlyConfiguredException when the routes are
built. Litestar notices a duplicate operation identifier only when the OpenAPI
schema is first generated, and a duplicate route name silently misdirects
route_reverse, so both are rejected up front instead.
Documentation is never policy#
Nothing reachable through RouteDocs changes what a route requires. Renaming
a tag, rewriting an operation identifier, or replacing a route name leaves the
authentication requirement, the guards, the CSRF enforcement, and the rate
limits of every generated route exactly as they were. A route’s protection is
selected by its configuration, never by how it is documented.
Operation identifiers#
Every generated operation declares an explicit operationId, so a generated
client keeps stable method names across releases. The identifiers are the same
whichever transport profile is active, with one exception: a hybrid profile
serves password change on two paths, so the bearer variant is
LocalTokenPasswordChange while LocalPasswordChange stays on
/password/change.
Documented failures#
Generated operations declare the failures a client has to handle, not only the success case:
Status |
Meaning |
|---|---|
|
The request is invalid, or a token was rejected. Rejection reasons are deliberately not distinguished. |
|
Authentication is required, or the presented credential no longer satisfies the account security epoch. |
|
A password was verified but a configured second factor is still owed.
The typed |
|
The operation exceeded its rate limit. Carries |
|
An application-supplied dependency was unavailable. Security decisions fail closed, so this never means the request was allowed. |
What an error body looks like#
Two different things produce a non-success status, and they produce different bodies. The distinction is raised versus returned, not error versus success.
A raised status — 400, 401, 429, 503, and the OAuth
409 — never reaches the handler’s return value. It travels through the
application’s exception handling, so the body is
RouteError: the status repeated inside the payload,
a human-readable detail, and extra when the failure carries structured
context. A request-validation failure always carries one, listing the members
it rejected.
{"status_code": 401, "detail": "Authentication required."}
A returned status is a value the handler produced, so its schema is the
handler’s own type and no exception handling touches it. That covers every
2xx, the typed 403 second-factor challenge above, and the 409
conflict raised when a change would remove an account’s last login method.
Registering your own error format on the application reaches the generated routes, including the OAuth ones:
Litestar(exception_handlers={HTTPException: my_error_format}, ...)
When that format changes the body schema or media type, declare both on the security configuration so generated clients receive the same contract:
from litestar_security import RaisedErrorSchema, SecurityConfig, SecurityPlugin
SecurityPlugin(
SecurityConfig(
raised_error_schema=RaisedErrorSchema(
schema=ApplicationError,
media_type="application/vnd.example.error+json",
),
),
)
The declaration only restates statuses generated handlers raise. Typed values they return, including second-factor challenges and conflicts, retain their own schemas. It also suppresses the customized-response-class warning because the application has supplied the missing OpenAPI contract; runtime exception handling remains entirely application-owned.
Problem details#
An application can install Litestar’s problem-details plugin and ask it to convert every HTTP exception:
from litestar.plugins.problem_details import ProblemDetailsConfig, ProblemDetailsPlugin
Litestar(
plugins=[
SecurityPlugin(config),
ProblemDetailsPlugin(ProblemDetailsConfig(enable_for_all_http_exceptions=True)),
],
)
Every raised status then arrives as application/problem+json carrying
ProblemDetail, and the published document says so.
Note what Litestar’s conversion actually emits, which is not the RFC 9457
five-member shape: the raised explanation moves to title, detail falls
back to the HTTP reason phrase, structured context carries through as
extra, and type and instance are never produced.
{"status": 401, "title": "Authentication required.", "detail": "Unauthorized"}
ProblemDetailsPlugin() on its own converts nothing. Its default
configuration registers a handler for ProblemDetailsException alone, which
the generated routes never raise, so the responses and the document both stay
exactly as they were. Returned statuses are unaffected in either mode.
If the application installs a response class of its own — its own, or one a
presentation plugin contributes — the generated routes warn once at startup,
naming the class. The documented schemas describe what the handlers return, and
a response class that reshapes the body makes them inaccurate. It is a warning
rather than an error: a customized response class is legitimate. A complete
RaisedErrorSchema declaration suppresses the warning and restates every
raised denial using its schema and media type.
Enumeration resistance shows up in the schema as well. Recovery, verification,
and registration answer 202 with the same body for every identifier, so a
client cannot tell an existing account from an absent one by reading the
response.
Notification destinations reject control characters before the application
delivery command is created. In particular, a recovery or verification request
containing CRLF characters still receives the same 202 response but emits
no notification, preventing mail-header injection without becoming an account
enumeration signal.
The same guarantee extends to timing, and part of it is a store obligation.
On recovery request and verification resend, an eligible account commits
through issue() while any
other identifier performs one equivalent durable round trip through
issue_absent(), which must
cost the same and commit nothing — a store that answers quickly for unknown
accounts makes a present account measurably slower to probe. Registration
carries the matching obligation on
register(): a taken and a
new identifier must cost the same.
Turning them off#
Pass register_routes=False to build the services without the route tree, and
mount your own controllers against local_auth.local_auth_service:
local_auth = LocalAuth.tokens(
accounts=accounts,
secrets=secrets,
key_ring=key_ring,
token_audience="local-client",
register_routes=False,
)
No tag descriptions are contributed in that case, because no operations are generated to file under them.
MFA, passkeys, and step-up#
When MFAConfig.require_at_login=True, a successful password login can
return the documented 403 instead of establishing its transport. The
challenge is reveal-once, bound to the returned account_id and client,
and expires after five minutes by default (never more than ten minutes). A
found challenge is burned before its account, epoch, expiry, or client binding
is checked; retrying a completion after any failed reveal attempt therefore
requires a new password login.
Session-capable profiles add POST /auth/login/mfa with operation ID
LocalSessionMFALogin. It establishes the native session and is CSRF
protected. Token-capable profiles add POST /auth/token/mfa with operation
ID LocalTokenMFALogin and issue the access/refresh pair. Both accept the
typed completion body: challenge, account_id, method, code,
and optional method_id. They accept totp and recovery-code methods;
TOTP requires the method_id returned when that factor was enrolled. There
is no factor-discovery port, so clients must retain that identifier.
MFAConfig and PasskeyConfig add a second route bundle under the same
/auth prefix. It contains TOTP enrollment and activation, recovery-code
replacement, passkey registration and authentication, safe credential
inventory and removal, and POST /auth/step-up/{purpose}. Generated MFA
routes require an explicit recovery-code pepper ring and login-method store;
passkey routes require the same shared viability boundary. Startup rejects a
route configuration that could activate a factor without recording it for
final-method-safe removal. Factor creation, login-method registration, and the
durable event are one application-store atomic operation.
Step-up grants are short-lived, single-use values returned in JSON. The stored record contains only a digest and is bound to the authenticated principal, current security epoch, exact purpose, and current session or token transport. A grant for one operation cannot authorize another operation.
The {purpose} path segment is deny-by-default. Every purpose a generated
route consumes maps to an explicit allowlist of factors strong enough to
authorize it — password or passkey re-verification, never a second submission
of the factor being managed — and a purpose outside that map, or a factor the
purpose does not allow, receives the same sanitized 401 as a wrong
credential. An unrecognized purpose never mints a grant.
All secret- and challenge-bearing responses, including the login 403, set
Cache-Control: no-store and Pragma: no-cache. Generated schemas describe
the typed JSON models without embedding sample TOTP secrets, recovery codes,
browser credential responses, or public verification keys.
Passkey authentication options include a reveal-once binding alongside the
browser options. Return that value unchanged in the verification request; it is
redacted from representations and binds the public ceremony without relying on
an existing cookie or token.
Passkey authentication establishes the local transport selected by the
application profile. Session-capable profiles compile the unsafe verification
route with CSRF enforcement; token-only profiles do not require a browser CSRF
cookie. A hybrid profile exposes distinct
/passkeys/authentication/session/verify and
/passkeys/authentication/tokens/verify routes so CSRF policy is fixed by the
route rather than selected by an untrusted request field.
The synchronous WebAuthn adapter runs through a bounded worker limiter and
timeout. Attestation defaults to none. Requesting direct attestation and
assigning the hardware-backed trait requires an application-supplied
AttestationTrustMapper. Its format-specific PEM roots are passed into
cryptographic attestation verification before its policy can approve the
verified AAGUID and format. The verified format must select a configured root
set and the attestation statement must contain a certificate chain; packed
self-attestation is never promoted to hardware-backed assurance.
Successful passkey assurance is preserved by both transports. Sessions store
normalized evidence in their versioned payload, while local access tokens carry
strict amr, auth_time, and security-trait claims that the local bearer
verifier reconstructs as evidence. Refresh-family state preserves the same
secret-free evidence so rotation cannot renew its original freshness.
Set register_routes=False independently on either feature configuration to
keep its service available for an application-owned controller without
registering its generated handlers.