How authentication works#
django-modern-rest supports different auth workflows.
We support both:
Checking that user requests contain required auth credentials
Boilerplate code for views that provide credentials for users
Enabling auth#
Let’s start with how auth can be enabled and how it works.
There are two main base classes for auth:
Warning
Sync controllers can’t directly use async auth. And async controllers can’t directly use sync auth.
All auth - that we are going to use - will be instances of these two classes (and their subclasses).
All of them have unified API:
__init__method contains configuration that can be changed per instance__call__()does all the heavy lifting. If__call__returns anything butNone, then we consider auth instance to succeed. If it returnsNone, we try the next one in the chain (if any). If it raisesNotAuthenticatedErrorthen we immediately stop and return the error response. Async auth has async__call__, sync auth has sync one.security_schemes()provides OpenAPI spec to define this auth method in the spec.security_requirement()provides OpenAPI spec to indicate what kind of auth will be required for each endpoint using this auth.
Some classes provide configuration to be adjusted when creating instances.
For example: HeaderJWTSyncAuth
contains multiple options in its __init__ method.
There are 4 ways to provide auth classes for an endpoint:
1from dmr import Controller, modify
2from dmr.plugins.pydantic import PydanticSerializer
3from dmr.security.django_session import DjangoSessionSyncAuth
4
5
6class APIController(Controller[PydanticSerializer]):
7 @modify(auth=[DjangoSessionSyncAuth()])
8 def get(self) -> str:
9 return 'authed'
10
Run result
$ curl http://127.0.0.1:8000/api/example/ -D - -X GET
HTTP/1.1 401 Unauthorized
date: Mon, 07 Sep 2026 05:19:32 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language, Cookie
Content-Language: en
Content-Length: 58
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
{"detail":[{"msg":"Not authenticated","type":"security"}]}
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"title": "Loc",
"type": "array"
},
"msg": {
"title": "Msg",
"type": "string"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"title": "Detail",
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
}
},
"securitySchemes": {
"csrf": {
"description": "CSRF protection",
"in": "cookie",
"name": "csrftoken",
"type": "apiKey"
},
"django_session": {
"description": "Reusing standard Django auth flow for API",
"in": "cookie",
"name": "sessionid",
"type": "apiKey"
}
}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/apicontroller/": {
"get": {
"deprecated": false,
"operationId": "getApicontrollerApiApicontroller",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when auth was not successful"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when CSRF check failed"
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
},
"security": [
{
"csrf": [],
"django_session": []
}
]
}
}
}
}
1from dmr import Controller
2from dmr.plugins.pydantic import PydanticSerializer
3from dmr.security.jwt import HeaderJWTAsyncAuth
4
5
6class APIController(Controller[PydanticSerializer]):
7 auth = (HeaderJWTAsyncAuth(),)
8
9 async def get(self) -> str:
10 return 'authed'
11
Run result
$ curl http://127.0.0.1:8000/api/example/ -D - -X GET
HTTP/1.1 401 Unauthorized
date: Mon, 07 Sep 2026 05:19:33 GMT
server: uvicorn
WWW-Authenticate: Bearer
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 58
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
{"detail":[{"msg":"Not authenticated","type":"security"}]}
OpenAPI Schema
Preview openapi.json
{
"components": {
"schemas": {
"ErrorDetail": {
"description": "Base schema for error details description.",
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "integer"
},
{
"type": "string"
}
]
},
"title": "Loc",
"type": "array"
},
"msg": {
"title": "Msg",
"type": "string"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"msg"
],
"title": "ErrorDetail",
"type": "object"
},
"ErrorModel": {
"description": "Default error response schema.\n\nCan be customized.\nSee :ref:`customizing-error-messages` for more details.",
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ErrorDetail"
},
"title": "Detail",
"type": "array"
}
},
"required": [
"detail"
],
"title": "ErrorModel",
"type": "object"
}
},
"securitySchemes": {
"jwt": {
"bearerFormat": "JWT",
"description": "JWT token auth",
"scheme": "Bearer",
"type": "http"
}
}
},
"info": {
"title": "Django Modern Rest",
"version": "0.1.0"
},
"openapi": "3.2.0",
"paths": {
"/api/apicontroller/": {
"get": {
"deprecated": false,
"operationId": "getApicontrollerApiApicontroller",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when auth was not successful",
"headers": {
"WWW-Authenticate": {
"description": "Challenges that the client can use to authenticate this request",
"required": true,
"schema": {
"type": "string"
}
}
}
},
"406": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when provided `Accept` header cannot be satisfied"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorModel"
}
}
},
"description": "Raised when returned response does not match the response schema"
}
},
"security": [
{
"jwt": []
}
]
}
}
}
}
Set auth setting
to enable auth for all controllers.
1>>> from dmr.settings import Settings, DMR_SETTINGS
2>>> from dmr.security.django_session import DjangoSessionSyncAuth
3
4>>> DMR_SETTINGS = {Settings.auth: [DjangoSessionSyncAuth()]}
When your project mixes sync and async endpoints,
use SyncOrAsyncAuth in settings:
>>> from dmr.settings import Settings
>>> from dmr.security import SyncOrAsyncAuth
>>> from dmr.security.http import HttpBasicAsyncAuth, HttpBasicSyncAuth
>>> DMR_SETTINGS = {
... Settings.auth: [
... SyncOrAsyncAuth(
... HttpBasicSyncAuth(),
... HttpBasicAsyncAuth(),
... ),
... ],
... }
Note
SyncOrAsyncAuth is only allowed
in DMR_SETTINGS. Using it on a controller or endpoint
raises EndpointMetadataError.
Providing several auth instances means that at least one of them must succeed.
Disabling auth#
It is a common practice to define a global auth protocol
in settings and then disable auth per specific endpoints
like /registration and /login.
To do so, set auth=None for the specific
endpoints / controllers that should not have auth.
Setting None as auth in any place will always disable
all auth in further layers.
Note
We don’t allow setting Settings.auth to None,
because it will globally disable all auth with no ways to re-enable it.
WWW-Authenticate challenges#
Added in version 0.15.0.
RFC 9110
says that a 401 response must tell the client how to authenticate:
The server generating a 401 response MUST send a
WWW-Authenticateheader field containing at least one challenge applicable to the target resource.
So, we add that header to every 401 that
NotAuthenticatedError produces.
It does not matter where the error came from: the auth chain running out
of options, an auth instance rejecting the credentials,
or your own endpoint raising it by hand.
With HttpBasicSyncAuth enabled, a 401 looks
like this:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
WWW-Authenticate: Basic realm="api", charset="UTF-8"
A challenge is a scheme name followed by its auth params. Here Basic
is the scheme, and it carries two params:
realmnames the protection space the credentials are for. RFC 7617 requires it for theBasicscheme. Change it withrealm=.charsettells the client which encoding to use for the username and password.UTF-8is its only allowed value, and it matches what we decode the credentials as, so we always send it. See RFC 7617.
When an endpoint has several auth instances, we join their challenges into a single header value, because RFC 9110 allows a challenge list:
WWW-Authenticate: Basic realm="api", charset="UTF-8", Bearer
Note
Both auth params and challenges are comma-separated, so such a list
is ambiguous to parse on its own. Clients resolve it by looking for
a token with no = in it - Bearer above starts a new challenge,
while charset="UTF-8" is another param of Basic.
What is supported#
A challenge can only name an HTTP authentication scheme that the client
is supposed to send in the Authorization header.
Auth that reads credentials from a cookie or from a custom header
has nothing to put there, so it sends no challenge at all:
Auth |
Challenge |
Configurable |
|---|---|---|
|
|
|
|
|
|
|
|
|
none, the token lives in a cookie |
- |
|
none, the token lives in a cookie |
- |
|
none, the session lives in a cookie |
- |
|
none, |
- |
The header-based classes only send a challenge when they actually read
the Authorization header. Point them at a header of your own, and the
challenge goes away, because there is no way to ask a client
for X-Api-Auth in a standard challenge:
>>> from dmr.security.jwt import HeaderJWTSyncAuth
>>> HeaderJWTSyncAuth().www_authenticate_challenge
'Bearer'
>>> HeaderJWTSyncAuth(auth_header='X-Api-Auth').www_authenticate_challenge
The same applies to HeaderTokenSyncAuth,
which defaults to a prefix-less X-API-Token header:
without a scheme prefix there is no scheme name to build a challenge from.
Disabling it#
Pass www_authenticate=False to any auth that supports a challenge:
>>> from dmr.security.jwt import HeaderJWTSyncAuth
>>> HeaderJWTSyncAuth(www_authenticate=False).www_authenticate_challenge
Warning
Browsers show their own native login prompt when they see a Basic
challenge on a 401. If your API is called from a browser and you do
not want that popup, turn the challenge off for HTTP Basic auth.
Note
We only add this header to
NotAuthenticatedError responses.
If you build a 401 yourself with APIError,
pass the header yourself via its headers= argument.
The header is added by global_error_handler(),
so replacing that handler is how you change or drop this behavior
for the whole project.
Security of auth views#
Views that issue or accept credentials get extra protection out of the box. All the auth views we ship already do all of the below, you only need this when you write your own auth views.
Never cache credentials#
Every response that carries credentials
must set the Cache-Control: no-store header,
so it is never written to any shared or local cache.
Use NO_STORE_HEADERS for that,
it also documents the header in the OpenAPI schema:
@modify(headers=NO_STORE_HEADERS)
Note
Only the successful response gets this header, because only it carries credentials. Error responses of auth views are not affected.
Keep credentials out of error reports#
Django hides sensitive data from tracebacks that are shown to admins in error reporting middlewares:
django.views.decorators.debug.sensitive_post_parameters()hides the request’sPOSTdata. Apply it withendpoint_decorator()django.views.decorators.debug.sensitive_variables()hides local variables of a single function
>>> from django.views.decorators.debug import (
... sensitive_post_parameters,
... sensitive_variables,
... )
>>> from dmr import Body, Controller, modify
>>> from dmr.decorators import endpoint_decorator
>>> from dmr.plugins.pydantic import PydanticSerializer
>>> from dmr.security import NO_STORE_HEADERS
>>> class MyLoginController(Controller[PydanticSerializer]):
... @sensitive_variables()
... @endpoint_decorator(sensitive_post_parameters())
... @modify(headers=NO_STORE_HEADERS)
... def post(self, parsed_body: Body[dict[str, str]]) -> str:
... return self.login(parsed_body)
...
... def login(self, parsed_body: dict[str, str]) -> str:
... return 'Logged in!'
Warning
@sensitive_variables() must be the topmost decorator,
otherwise the wrappers below it will still show
the parsed request body in the traceback.
Note
Django protects sync and async functions differently.
A sync @sensitive_variables() also hides local variables
of everything the decorated function calls,
which is why login above needs no decorator of its own.
Async functions do not have a shared call stack,
so every coroutine that keeps credentials in its local variables
needs its own decorator. This includes your own overrides,
like convert_auth_payload, which receives the raw password.
Permissions#
Many similar frameworks also include different abstractions
for defining permissions classes, like:
guards=[UserHasPermissions('delete')] or IsSuperUser(), etc.
We don’t do that on purpose. This is not a framework logic, this is your business logic. It should be placed inside your code, not ours.
Making proper abstractions inside your own code base will allow you to:
Make it super specific for your usecase
Make it optimized
Make it clean and consistent with other business rules you will have
Yes, these permissions can look cool in a framework on paper, but they do not serve a good purpose in large codebases in reality.
Focus on your domain, not on framework.
Next up#
Select auth backend that fits your needs:
Support for HTTP’s default basic auth.
Support for Django’s default auth mechanism.
Support for JWT tokens based auth.
Database-backed opaque token auth with revocation support.
Write an auth class for a transport we don’t ship.
JWT vs Opaque Tokens#
Both are valid token-based auth strategies. The right pick mostly comes down to how you feel about revocation vs a database lookup on every request.
JWT |
Opaque Token |
|
|---|---|---|
Storage |
Stateless, no database lookup |
Row in the database, looked up per request |
Revocation |
Hard: valid until expiry, needs a blocklist to revoke early |
Easy: |
Token size |
Larger, carries claims in the payload |
Small, just a random string |
Per-request cost |
Signature verification, no I/O |
One DB read per request, plus an optional write if last-use tracking is enabled |
Good fit for |
High-throughput / distributed services where a DB round-trip per request is too costly |
APIs that need instant logout, audit trails, or per-token metadata |
If you need instant revocation or per-token state (last used, scopes, device info), use Opaque Tokens. If you need to skip a database lookup on every request and can tolerate tokens staying valid until they expire, use JWT.
API Reference#
- class dmr.security.SyncAuth[source]#
Sync auth base class for sync endpoints.
All auth must support initialization without any required parameters. Auth can have non-required parameters with defaults.
- abstractmethod __call__(endpoint: Endpoint, controller: Controller[BaseSerializer]) Self | None[source]#
Put your auth business logic here.
Return
selfif the login attempt was successful. ReturnNoneif login attempt failed and we need to try another authes. Raisedmr.exceptions.NotAuthenticatedErrorto immediately fail the login without trying other authes. Raisedmr.response.APIErrorif you want to change the return code, for example, when some data is missing or has wrong format.
- provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) list[ResponseSpec]#
Provides responses that can happen when user is not authed.
- abstract property security_requirement: dict[str, list[str]]#
Provides a security schema usage requirement.
- abstract property security_schemes: dict[str, SecurityScheme | Reference]#
Provides a security schema definition.
- abstract property www_authenticate_challenge: str | None#
Challenge to advertise in
WWW-Authenticateon401responses.RFC 9110 Section 15.5.2 requires every
401to carry at least one challenge, but a challenge can only name an HTTP authentication scheme that the client sends in theAuthorizationheader.Return
Nonewhen there is nothing to advertise: auth that reads credentials from a cookie or from a header of its own cannot express itself as a challenge.Added in version 0.15.0.
- class dmr.security.AsyncAuth[source]#
Async auth base class for async endpoints.
All auth must support initialization without any required parameters. Auth can have non-required parameters with defaults.
- abstractmethod async __call__(endpoint: Endpoint, controller: Controller[BaseSerializer]) Self | None[source]#
Put your auth business logic here.
Return
selfif the login attempt was successful. ReturnNoneif login attempt failed and we need to try another authes. Raisedmr.exceptions.NotAuthenticatedErrorto immediately fail the login without trying other authes. Raisedmr.response.APIErrorif you want to change the return code, for example, when some data is missing or has wrong format.
- provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) list[ResponseSpec]#
Provides responses that can happen when user is not authed.
- abstract property security_requirement: dict[str, list[str]]#
Provides a security schema usage requirement.
- abstract property security_schemes: dict[str, SecurityScheme | Reference]#
Provides a security schema definition.
- abstract property www_authenticate_challenge: str | None#
Challenge to advertise in
WWW-Authenticateon401responses.RFC 9110 Section 15.5.2 requires every
401to carry at least one challenge, but a challenge can only name an HTTP authentication scheme that the client sends in theAuthorizationheader.Return
Nonewhen there is nothing to advertise: auth that reads credentials from a cookie or from a header of its own cannot express itself as a challenge.Added in version 0.15.0.
- final class dmr.security.SyncOrAsyncAuth(_sync_auth: SyncAuth, _async_auth: AsyncAuth)[source]#
Auth that selects between a sync and async instance.
Use in global settings to apply a single auth rule to both sync and async endpoints. Not allowed on controller or endpoint level.
Added in version 0.11.0.
- dmr.security.request_auth(request: HttpRequest, *, strict: Literal[True]) SyncAuth | AsyncAuth[source]#
- dmr.security.request_auth(request: HttpRequest, *, strict: bool = False) SyncAuth | AsyncAuth | None
Return the auth instance that was used to auth this request.
When strict is passed and request has no auth, we raise
AttributeError.
- dmr.security.add_www_authenticate(exc: NotAuthenticatedError, auth: Sequence[SyncAuth | AsyncAuth] | None) None[source]#
Advertise auth in
WWW-Authenticateon the401that exc returns.RFC 9110 Section 15.5.2 requires every
401to carry at least one challenge. Headers that exc already has always win, so an auth class can raise with a challenge of its own.
- dmr.security.NO_STORE_HEADERS = mappingproxy({'Cache-Control': NewHeader(description='Credentials must not be stored in any cache.', deprecated=False, example=None, value='no-store')})#