skip to content

How does Django's LoginRequiredMiddleware make an internal tool require login on every page, and how do you keep its health-check endpoint public?

level: middleimportance: should knowfreq 42%

answer

  1. flip the default, then carve out
  2. a flag on the view function
  3. runs before the view, after resolving
  4. the login page must be exempt too

basics

~10 s

LoginRequiredMiddleware (Django 5.1) redirects every unauthenticated request to LOGIN_URL unless the resolved view carries login_not_required. Mark the health check with @login_not_required, or method_decorator on dispatch for a class-based view.

solid answer

~30 s

Added in Django 5.1, `django.contrib.auth.middleware.LoginRequiredMiddleware` goes after `AuthenticationMiddleware` and works in `process_view`: once the URL is resolved it reads `getattr(view_func, "login_required", True)`, and if that is true and `request.user` is anonymous it redirects to the login URL with `?next=`. `@login_not_required` sets that attribute to `False`, so for the health check I decorate the function view, or use `@method_decorator(login_not_required, name="dispatch")` on a class, because `as_view()` copies `dispatch`'s attributes. Django's own `LoginView`, admin login and password-reset views are already exempt; a custom login view must be too, or it loops. The middleware honours `login_url` and `redirect_field_name` from `login_required`, but not from `LoginRequiredMixin`.

code

python · 9 lines
python
from django.http import JsonResponse
from django.contrib.auth.middleware import LoginRequiredMiddleware


class ApiAwareLoginRequiredMiddleware(LoginRequiredMiddleware):
    def handle_no_permission(self, request, view_func):
        if request.path.startswith("/api/"):
            return JsonResponse({"detail": "authentication required"}, status=401)
        return super().handle_no_permission(request, view_func)

go deeper

for a junior

Remember that Django 5.1 added a middleware making every page require login, and that login_not_required marks the public ones.

for a middle

Explain the process_view check on view_func.login_required, the class-based exemption through dispatch, and why the login view must be exempt.

for a senior

Anticipate the operational edges: probes and webhooks getting 302s, mixin login_url being ignored, and a 401 response for API paths via a subclass.

for a principal

Own the exemption policy: keep public views few and reviewable, and decide when a separate authentication path is required for machines.

## Why deny-by-default With the opt-in guards (`login_required`, `LoginRequiredMixin`) every view is public until someone remembers to protect it. For an **internal tool**, where almost nothing should be public, that default is backwards: one forgotten decorator leaks a page. Django 5.1 added `LoginRequiredMiddleware` to invert it. Every view requires a signed-in user unless it is explicitly marked public with `login_not_required`. ## Enabling it ```python # settings.py MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.LoginRequiredMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] LOGIN_URL = "login" ``` It must come **after** `AuthenticationMiddleware`, because it reads `request.user`. ## How it decides The middleware implements `process_view(request, view_func, view_args, view_kwargs)`, which Django calls after URL resolution and before the view: 1. If `getattr(view_func, "login_required", True)` is false, let the request through. 2. If `request.user.is_authenticated`, let it through. 3. Otherwise call `handle_no_permission(request, view_func)`, which redirects to the login URL with the original path under `next`. The login URL is `view_func.login_url` if a `login_required` decorator set one, else `settings.LOGIN_URL`; the field name likewise comes from the decorator or the middleware's `redirect_field_name` attribute (`"next"`). If neither URL is set, it raises `ImproperlyConfigured`. ## Carving out the public views `login_not_required` simply sets `view_func.login_required = False`. For the tool's health check: ```python from django.contrib.auth.decorators import login_not_required from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views import View @login_not_required def healthz(request): return JsonResponse({"status": "ok"}) @method_decorator(login_not_required, name="dispatch") class ReadinessView(View): def get(self, request): return JsonResponse({"ready": True}) ``` For a class-based view, decorating `dispatch` works because `as_view()` copies the attributes of `dispatch` onto the function it returns, and that function is what the middleware inspects. Decorating the class itself would set the flag on the class, which the middleware never sees. Already exempt in Django: `LoginView`, the admin's login view, and the password-reset views. **A custom login view must be exempted too**, or anonymous users are redirected to the login page, which redirects to the login page, forever. ## Edges worth knowing - **Resolution comes first.** A URL that matches no pattern raises a 404 before `process_view` runs, so anonymous users see 404s rather than a login redirect for unknown paths. - **Only views are guarded.** Files served by the web server, outside Django's URLconf, never reach the middleware. - **Mixin settings are ignored.** A class using `LoginRequiredMixin` with its own `login_url` is redirected by the middleware first, to `settings.LOGIN_URL`; the 5.1 release notes state the middleware does not read the mixin's attributes. Use `method_decorator(login_required(login_url=...), name="dispatch")` instead when a view needs a different login page. - **Programmatic clients.** A monitoring probe, webhook receiver or token-authenticated API endpoint has no session user at this stage and will get a 302. Exempt it and authenticate it another way. - **Customising the response.** Subclass the middleware and override `handle_no_permission(request, view_func)`, for instance to return a 401 JSON body for paths under `/api/`. ## Reviewing exemptions | View | Exempt? | Why | |---|---|---| | Health and readiness checks | Yes | Probes have no session | | Login, password reset | Built-in ones already are | Users must reach them signed out | | Webhook receivers | Yes, with their own signature check | Callers are machines | | Everything else | No | Deny by default | Keeping the exemption list short and greppable (`login_not_required`) is the point of the design. ## Testing the deny-by-default setup Because the protection now lives in one place, the tests can be systematic rather than per view: - Iterate over the project's URL patterns (skipping those needing arguments), request each anonymously with the test client, and assert a redirect to `LOGIN_URL` unless the view is on a known public list. - Assert the health-check route returns 200 without a session, so a refactor that drops `login_not_required` breaks the build rather than the load balancer. - Assert the login page itself returns 200 anonymously, which catches the redirect loop. This turns the exemption list into an explicit, reviewed artefact instead of an accident of which views someone remembered to decorate.

  • Why does decorating a class-based view class with @login_not_required fail to exempt it?
    The decorator sets `login_required = False` on whatever it wraps. On a class that is a class attribute, but the middleware inspects the function returned by `as_view()`. `as_view()` copies attributes from `dispatch`, not from the class, so use `method_decorator(login_not_required, name="dispatch")` or wrap `as_view()` in the URLconf.
  • What happens if the project's custom login view is not marked login_not_required?
    An anonymous request to any page is redirected to the login URL, and the request for the login page itself is redirected again, producing a redirect loop the browser eventually aborts. Django's own `LoginView` and admin login already carry the exemption; a hand-written login view must add it.

saying these in an interview costs you the question

  • LoginRequiredMiddleware must come before AuthenticationMiddleware.
  • The middleware uses login_url set on LoginRequiredMixin views.
  • Anonymous users get a login redirect even for URLs that do not exist.
  • Putting @login_not_required on a view class exempts it.
  • LoginRequiredMiddleware has been available since Django 2.0.