How authentication works#

django-modern-rest supports different auth workflows.

We support both:

  1. Checking that user requests contain required auth credentials

  2. 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:

  1. SyncAuth for sync controllers

  2. AsyncAuth for async controllers

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 but None, then we consider auth instance to succeed. If it returns None, we try the next one in the chain (if any). If it raises NotAuthenticatedError then 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:

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": []
          }
        ]
      }
    }
  }
}

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-Authenticate header 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:

  • realm names the protection space the credentials are for. RFC 7617 requires it for the Basic scheme. Change it with realm=.

  • charset tells the client which encoding to use for the username and password. UTF-8 is 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

HttpBasicSyncAuth, HttpBasicAsyncAuth

Basic realm="api", charset="UTF-8"

realm=

HeaderJWTSyncAuth, HeaderJWTAsyncAuth

Bearer

auth_scheme=

HeaderTokenSyncAuth, HeaderTokenAsyncAuth

Token, when a prefix= is set

prefix=

CookieJWTSyncAuth, CookieJWTAsyncAuth

none, the token lives in a cookie

-

CookieTokenSyncAuth, CookieTokenAsyncAuth

none, the token lives in a cookie

-

DjangoSessionSyncAuth, DjangoSessionAsyncAuth

none, the session lives in a cookie

-

XSessionTokenSyncAuth, XSessionTokenAsyncAuth

none, X-Session-Token is not an auth scheme

-

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:

  1. django.views.decorators.debug.sensitive_post_parameters() hides the request’s POST data. Apply it with endpoint_decorator()

  2. 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:

HTTP Basic

Support for HTTP’s default basic auth.

HTTP Basic Auth
Django Session

Support for Django’s default auth mechanism.

Django Session Auth
JWT Tokens

Support for JWT tokens based auth.

JWT Auth
Opaque Tokens

Database-backed opaque token auth with revocation support.

Token Auth
Your own auth

Write an auth class for a transport we don’t ship.

Writing your own auth

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: revoked_at is set, token is dead instantly

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 self if the login attempt was successful. Return None if login attempt failed and we need to try another authes. Raise dmr.exceptions.NotAuthenticatedError to immediately fail the login without trying other authes. Raise dmr.response.APIError if 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-Authenticate on 401 responses.

RFC 9110 Section 15.5.2 requires every 401 to carry at least one challenge, but a challenge can only name an HTTP authentication scheme that the client sends in the Authorization header.

Return None when 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 self if the login attempt was successful. Return None if login attempt failed and we need to try another authes. Raise dmr.exceptions.NotAuthenticatedError to immediately fail the login without trying other authes. Raise dmr.response.APIError if 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-Authenticate on 401 responses.

RFC 9110 Section 15.5.2 requires every 401 to carry at least one challenge, but a challenge can only name an HTTP authentication scheme that the client sends in the Authorization header.

Return None when 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.

resolve(auth_cls: type[SyncAuth] | type[AsyncAuth]) SyncAuth | AsyncAuth[source]#

Return the auth instance matching auth_cls.

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-Authenticate on the 401 that exc returns.

RFC 9110 Section 15.5.2 requires every 401 to 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')})#
class dmr.security.AuthenticatedHttpRequest[source]#

Annotation for requests that used auth.

Use it for trusted controllers only.

Added in version 0.7.0.