Authentication

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, a suite of 43 tests (manage.py test), a curl session against runserver and the shell snippets all passed, and the output shown is the real output. Nothing on this page needs PostgreSQL or Redis; password-reset mail is captured by the in-memory backend that the test runner installs, and printed to the console by the MAILERS default that Django 6.1’s startproject writes.

Authentication answers "who is this visitor?"; authorisation answers "may they do this?". Django’s django.contrib.auth package covers both, and this page is about the first half: the User model, proving an identity with a username and password, remembering it across requests, the ready-made login, logout and password views, keeping anonymous visitors out of pages, and the same machinery from async code. Permissions, groups and object-level checks are on Permissions & Authorization; swapping the user model and writing your own authentication backend are on Custom User Model & Backends; how passwords are hashed and validated is on Passwords; the session store that every login relies on is on Sessions, Cookies, Messages & Signing; CSRF and the rest of the hardening checklist are on Security.

In a hurry: what to use for what

I need to…​ Use Section

Check a username and password by hand

authenticate(request, username=…​, password=…​); it returns a User or None.

authenticate()

Remember that the visitor is signed in

login(request, user) (stores the user id and a password hash in the session, rotates the session key).

login(): remembering the user

Sign someone out

logout(request) from a view that only accepts POST; the session is flushed.

logout(): POST only

Get a working login page quickly

path("accounts/", include("django.contrib.auth.urls")) plus a registration/login.html template.

The built-in views and URLs

Require a login on one function view

@login_required (works on async def views since 5.1).

Restricting access: login_required

Require a login on one class-based view

LoginRequiredMixin as the leftmost base class.

LoginRequiredMixin and AccessMixin

Require a login on the whole site

LoginRequiredMiddleware (5.1) and @login_not_required on the public views.

LoginRequiredMiddleware: secure by default

Let people create accounts

UserCreationForm (or a subclass) in a view that then calls login().

Sign-up with UserCreationForm

Let people change or reset their password

PasswordChangeView and the four PasswordReset* views from django.contrib.auth.urls.

Changing and resetting passwords

Authenticate from an async view

await aauthenticate(…​), await alogin(…​), await alogout(…​), await request.auser() (5.0).

Async authentication

Write a test that is signed in

self.client.force_login(user); self.client.login(…​) when the login path itself is under test.

Testing authentication

The Bookshelf pieces used on this page

Every example belongs to the Bookshelf project that grows across this section. Two apps are involved: accounts holds the sign-up and hand-written login views, catalog holds the pages that need a login. No new model is needed: authentication works on the User model that ships with Django, and a custom one is the subject of the next page of this group. The examples read the secret key from the environment, so run them with export DJANGO_SECRET_KEY=…​ set to the output of python -c "from django.core.management.utils import get_random_secret_key as g; print(g())".

How django.contrib.auth fits together

The authentication system is a small stack of cooperating parts. Knowing which part does what makes every later section short.

Part What it does

django.contrib.auth and django.contrib.contenttypes in INSTALLED_APPS

The User, Group and Permission models, the password hashers, the views and forms; contenttypes is required for permissions.

SessionMiddleware

Gives every request a request.session; the login is stored there. See the sessions page.

AuthenticationMiddleware

Adds request.user (a lazy object) and request.auser() (an async method, 5.0) to every request.

AUTHENTICATION_BACKENDS

The list of classes that check credentials and load users. The default is ModelBackend, which uses the database.

authenticate(), login(), logout()

The three functions every higher-level piece is built from.

Views, forms, decorators, mixins

LoginView, PasswordResetView and friends; AuthenticationForm, UserCreationForm; login_required, LoginRequiredMixin.

LoginRequiredMiddleware (5.1)

Optional: require a login for every view unless it opts out.

django-admin startproject already lists all of this in INSTALLED_APPS and MIDDLEWARE; the relevant lines are repeated here because the order matters.

Settings that switch authentication on (config/settings.py)
import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "accounts",
    "catalog",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",   # needs SessionMiddleware before it
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

LOGIN_URL = "login"                       # a URL or a URL name: where anonymous visitors are sent
LOGIN_REDIRECT_URL = "catalog:shelf"      # where to go after a login that had no ?next=
LOGOUT_REDIRECT_URL = "login"             # where to go after a logout (None renders logged_out.html)
PASSWORD_RESET_TIMEOUT = 60 * 60          # reset links live one hour instead of the 3-day default

Where authentication sits in the request/response cycle

Authentication is not a step the framework performs for every request by itself; it is two middleware plus whatever each view asks for. AuthenticationMiddleware runs on the way in, builds a lazy request.user and returns immediately. Nothing touches the session store or the database until some code reads an attribute of request.user; then get_user(request) reads the user id and the backend path from the session, asks that backend for the user, and checks the session’s password hash. The check can fail (the user was deleted, the backend left AUTHENTICATION_BACKENDS, the password changed) and the result is then an AnonymousUser. Decorators and mixins run later, inside the view layer, and LoginRequiredMiddleware runs in the process_view hook just before the view.

sequenceDiagram participant B as Browser participant S as SessionMiddleware participant A as AuthenticationMiddleware participant L as LoginRequiredMiddleware (optional) participant V as View (login_required, mixin) participant D as Session store and database B->>S: request with sessionid cookie S->>A: request.session (lazy) A->>L: request.user (lazy, nothing read yet) L->>D: process_view reads request.user D-->>L: user id, backend, hash -> User or AnonymousUser alt anonymous and view not marked login_not_required L-->>B: 302 to LOGIN_URL?next=... else allowed L->>V: call the view V->>D: login_required reads request.user (cached) V-->>B: response end

The first read costs one session lookup and one query; every later read in the same request is cached on the request object. That is why reading request.user in a template (the {{ user }} variable comes from the auth context processor) is free after a view has already checked it.

The User model

The default django.contrib.auth.models.User has the attributes username (150 characters at most, letters, digits and @ . + - _), password (a hash plus metadata, never the raw password), email, first_name, last_name, is_staff (may enter the admin), is_active, is_superuser, last_login, date_joined, and the many-to-many fields groups and user_permissions. There is only one class of user: a "superuser" or a "staff member" is a User with a flag set.

Two small objects tell you whether the visitor is signed in. A real User always has is_authenticated == True and is_anonymous == False; AnonymousUser is the opposite. Both are properties, not methods.

  • request.user.is_authenticated says a user was found for this session. It does not check is_active, permissions or anything else.

  • is_active is a soft-delete switch: set it to False instead of deleting an account, so foreign keys to it keep working. The default ModelBackend refuses inactive users, and so do the default login forms.

  • Never touch user.password directly; use set_password() or the helper create_user().

Creating users from code and from the command line
from django.contrib.auth import get_user_model

User = get_user_model()
ada = User.objects.create_user("ada", "ada@example.com", password_from_somewhere)  # hashes the password
ada.last_name = "Lovelace"
ada.save()
$ export DJANGO_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
$ python manage.py createsuperuser --noinput --username root1 --email root@example.com
Superuser created successfully.
$ python manage.py changepassword root1     # prompts twice, no password on the command line

Always import the model with get_user_model() (and point foreign keys at settings.AUTH_USER_MODEL), never with from django.contrib.auth.models import User, so the code keeps working when the project switches to a custom user model. In a shell session the real output of the checks above was:

True True True pbkdf2_sha256 True      # is_staff, is_superuser, is_active, hash algorithm, has_usable_password()
None                                    # authenticate() with a wrong password
root1                                   # authenticate() with the right one
False !K                                # after set_unusable_password(): has_usable_password() is False
None                                    # an unusable password never authenticates, not even with ""

createsuperuser --noinput reads the password from DJANGO_SUPERUSER_PASSWORD (and the other required fields from DJANGO_SUPERUSER_<FIELD> variables), which keeps the secret out of the shell history and the process list. The password hash format, set_unusable_password() and the hasher settings belong to Passwords.

authenticate()

authenticate(request=None, **credentials) verifies a set of credentials. For the default setup the keyword arguments are username and password. Django walks AUTHENTICATION_BACKENDS in order, passes the credentials to each backend whose authenticate() accepts them, and returns the first User a backend produces. It returns None when no backend accepts the credentials, or when a backend raises PermissionDenied (which stops the search). Three details matter in practice.

  • Pass request as the first argument. It is optional, but backends that rate-limit or audit (django-axes, for example) need it.

  • A successful call also sets user.backend to the dotted path of the backend that approved the user; login() stores it in the session.

  • A failure sends the user_login_failed signal with the credentials, which is the hook for lockouts and audit logs.

Checking credentials by hand (accounts/views.py)
from django.contrib.auth import authenticate, login
from django.shortcuts import redirect, render


def manual_login_raw(request):
    """authenticate() + login() without a form."""
    user = authenticate(
        request,
        username=request.POST.get("username", ""),
        password=request.POST.get("password", ""),
    )
    if user is None:
        return render(request, "registration/login.html", {"error": True}, status=400)
    login(request, user)
    return redirect("catalog:shelf")

authenticate() is the low-level call: it does not validate a form, does not check CSRF and does not log anything in. In real code prefer AuthenticationForm (below) or LoginView, which wrap it with validation and error messages. It also never reveals why it failed: a wrong password, an unknown user and an inactive user all return None, which is exactly what you want to show a visitor (no account enumeration). Its async twin is aauthenticate(), see Async authentication.

login(): remembering the user

login(request, user, backend=None) attaches an authenticated user to the current session. It does these things, in this order:

  1. Computes the user’s session auth hash, an HMAC of the stored password hash.

  2. If the session already holds a different user, or the hash no longer matches, the old session is flushed; otherwise (an anonymous session, the usual case) the session key is cycled. Either way the old session id stops working, which defeats session fixation. Data stored while the visitor was anonymous, such as a cart, survives a cycle.

  3. Stores three values in the session: the user’s primary key, the dotted path of the backend, and the hash.

  4. Sets request.user, rotates the CSRF token, and sends the user_logged_in signal.

The backend is chosen from the backend argument, then from user.backend (set by authenticate()), then from AUTHENTICATION_BACKENDS if it has exactly one entry; with several backends and a user that did not come from authenticate() (a freshly created account, for instance) it raises an error, so pass backend= explicitly. The sign-up view in Sign-up with UserCreationForm does that.

The session hash is what signs every session out when a password changes: on each request get_user() recomputes the hash from the current password and compares it with the stored one, and a mismatch means anonymous. Because the hash is derived from SECRET_KEY, rotating the key signs everyone out unless the old key moves to SECRET_KEY_FALLBACKS (4.1); see the sessions page.

A complete hand-written login view (accounts/views.py)
from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme


def _safe_next(request, default):
    target = request.POST.get("next") or request.GET.get("next") or ""
    allowed = url_has_allowed_host_and_scheme(
        target, allowed_hosts={request.get_host()}, require_https=request.is_secure()
    )
    return target if allowed else default


def manual_login(request):
    """The hand-written equivalent of LoginView (shown for teaching)."""
    if request.method == "POST":
        form = AuthenticationForm(request, data=request.POST)
        if form.is_valid():
            login(request, form.get_user())
            return redirect(_safe_next(request, "catalog:shelf"))
    else:
        form = AuthenticationForm(request)
    return render(request, "registration/login.html", {"form": form, "next": request.GET.get("next", "")})

AuthenticationForm takes the request as its first argument, calls authenticate() in its clean(), rejects inactive users in confirm_login_allowed(), and exposes the user through get_user() once it is valid. The helper safe_next is the important security detail: the next parameter comes from the query string or the form, so it is attacker-controlled, and redirecting to it blindly is an _open redirect (a phishing link to your real login page that lands on https://evil.example/ afterwards). url_has_allowed_host_and_scheme() accepts only relative URLs and URLs on your own host. LoginView does the same check for you.

The template used by both the hand-written and the built-in view is a normal form with a CSRF token:

templates/registration/login.html
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
<h1>Sign in</h1>
{% if next %}
  {% if user.is_authenticated %}
    <p>Your account does not have access to that page. Sign in with another account.</p>
  {% else %}
    <p>Please sign in to continue.</p>
  {% endif %}
{% endif %}
<form method="post">
  {% csrf_token %}
  {{ form.as_div }}
  <input type="hidden" name="next" value="{{ next }}">
  <button type="submit">Sign in</button>
</form>
<p><a href="{% url 'password_reset' %}">Forgot your password?</a></p>
{% endblock %}

The tests that pin the behaviour described above (session key rotation, anonymous data kept, failure signal, open redirect blocked, inactive user):

Testing login (accounts/tests.py)
import secrets

from django.contrib.auth import SESSION_KEY, get_user_model
from django.contrib.auth.signals import user_login_failed
from django.test import TestCase
from django.urls import reverse

User = get_user_model()
PW = secrets.token_urlsafe(16)  # generated at runtime, never a literal
WRONG_PW = secrets.token_urlsafe(16)


class ManualLoginTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    def test_login_rotates_session_key_and_keeps_anonymous_data(self):
        session = self.client.session
        session["cart"] = {"1": 2}
        session.save()
        before = self.client.cookies["sessionid"].value
        r = self.client.post(reverse("manual_login"), {"username": "reader", "password": PW})
        self.assertRedirects(r, reverse("catalog:shelf"), fetch_redirect_response=False)
        after = self.client.cookies["sessionid"].value
        self.assertNotEqual(before, after)                          # cycle_key() on login
        self.assertEqual(self.client.session["cart"], {"1": 2})     # anonymous data survives login
        self.assertEqual(self.client.session[SESSION_KEY], str(self.user.pk))

    def test_bad_password_redisplays_the_form(self):
        failures = []
        user_login_failed.connect(lambda sender, credentials, **kw: failures.append(sorted(credentials)), weak=False)
        r = self.client.post(reverse("manual_login"), {"username": "reader", "password": "wrong"})
        self.assertEqual(r.status_code, 200)
        self.assertContains(r, "Please enter a correct username and password")
        self.assertNotIn(SESSION_KEY, self.client.session)
        self.assertEqual(failures, [["password", "username"]])

    def test_next_is_honoured_only_for_this_host(self):
        ok = self.client.post(reverse("manual_login") + "?next=/reviews/", {"username": "reader", "password": PW})
        self.assertEqual(ok["Location"], "/reviews/")
        self.client.logout()
        evil = self.client.post(
            reverse("manual_login") + "?next=https://evil.example/", {"username": "reader", "password": PW}
        )
        self.assertEqual(evil["Location"], reverse("catalog:shelf"))

logout(): POST only

logout(request) sends user_logged_out (while the user is still known, so receivers can see who left), flushes the session and resets request.user to an anonymous user. It raises nothing when nobody was signed in. Flushing is deliberate: everything in the session is deleted, so the next person who uses the same browser inherits nothing. If you want to leave a message or a flag for the visitor, set it after calling logout().

Logging out changes state (it destroys a session), so it must be a POST with a CSRF token. A plain link would let any page on the internet sign your users out with an <img src="/accounts/logout/"> tag, and prefetching browsers would do it by accident. Django followed this reasoning in two steps: logging out through GET was deprecated in 4.1 and the built-in LogoutView accepts only POST since 5.0, so a GET returns 405 Method Not Allowed. An HTML link becomes a one-button form that is styled to look like a link.

A POST-only logout view and the form that calls it
from django.contrib import messages
from django.contrib.auth import logout
from django.shortcuts import redirect
from django.views.decorators.http import require_POST


@require_POST
def manual_logout(request):
    logout(request)
    messages.info(request, "You have been signed out.")   # after logout(), so the new session keeps it
    return redirect("login")
<form method="post" action="{% url 'logout' %}" style="display:inline">
  {% csrf_token %}
  <button type="submit">Sign out</button>
</form>

The login and logout signals

Three signals in django.contrib.auth.signals let other code react without being called from the views: user_logged_in (arguments sender, request, user), user_logged_out (the same, and user is None if nobody was signed in) and user_login_failed (sender is the module that failed, credentials holds the keyword arguments given to authenticate() with the password already scrubbed by Django, and request may be None). Typical uses are an audit log, a "last seen" stamp and lockout counters. Connect receivers in AppConfig.ready() so they are registered once.

Audit log receivers (accounts/signals.py and accounts/apps.py)
import logging

from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
from django.dispatch import receiver

audit = logging.getLogger("bookshelf.audit")


@receiver(user_logged_in)
def log_login(sender, request, user, **kwargs):
    audit.info("login user=%s ip=%s", user.get_username(), request.META.get("REMOTE_ADDR"))


@receiver(user_logged_out)
def log_logout(sender, request, user, **kwargs):
    audit.info("logout user=%s", getattr(user, "get_username", lambda: "-")())


@receiver(user_login_failed)
def log_failure(sender, credentials, request=None, **kwargs):
    # log the username that was tried, never the password
    audit.warning("login failed username=%r", credentials.get("username"))
from django.apps import AppConfig


class AccountsConfig(AppConfig):
    name = "accounts"

    def ready(self):
        from . import signals  # noqa: F401  (connects the receivers)
Testing the receivers with assertLogs
def test_audit_log(self):
    with self.assertLogs("bookshelf.audit", level="INFO") as cm:
        self.client.post(reverse("login"), {"username": "reader", "password": WRONG_PW})
        self.client.post(reverse("login"), {"username": "reader", "password": PW})
        self.client.post(reverse("logout"))
    self.assertEqual(cm.output, [
        "WARNING:bookshelf.audit:login failed username='reader'",
        "INFO:bookshelf.audit:login user=reader ip=127.0.0.1",
        "INFO:bookshelf.audit:logout user=reader",
    ])

Receivers run synchronously inside the login call, so keep them fast; send slow work (e-mail, webhooks) to a background task. The signals mechanism itself is covered with the other built-in signals elsewhere in this section.

The built-in views and URLs

Writing the views above is a good way to learn, but Django ships class-based views for the whole account lifecycle. They use the stock forms, so you only provide templates. The one line that wires all of them is:

config/urls.py
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("accounts.urls")),                 # signup and the teaching views
    path("members/login/", auth_views.LoginView.as_view(
        redirect_authenticated_user=True, extra_context={"site_title": "Members"}), name="members_login"),
    path("accounts/", include("django.contrib.auth.urls")),      # the eight built-in routes
    path("", include("catalog.urls")),
]

django.contrib.auth.urls provides these routes. The names are global (no namespace), which is why templates can say {% url 'login' %}.

URL Name View

accounts/login/

login

LoginView

accounts/logout/

logout

LogoutView (POST only)

accounts/password_change/

password_change

PasswordChangeView (login required)

accounts/password_change/done/

password_change_done

PasswordChangeDoneView

accounts/password_reset/

password_reset

PasswordResetView

accounts/password_reset/done/

password_reset_done

PasswordResetDoneView

accounts/reset/<uidb64>/<token>/

password_reset_confirm

PasswordResetConfirmView

accounts/reset/done/

password_reset_complete

PasswordResetCompleteView

Because LOGIN_URL defaults to /accounts/login/, mounting the include at accounts/ makes login_required work with no setting at all. Mount it elsewhere and set LOGIN_URL (a path, or a URL name as in this project). If you want only some of the routes, or different paths, reference the views individually with auth_views.PasswordChangeView.as_view(…​).

Django provides no templates for LoginView, so registration/login.html is yours to write. For the other views there is a trap worth knowing: django.contrib.admin ships plain templates under registration/ for the logout page and for every password change and reset page (logged_out.html, password_change_form.html, password_reset_confirm.html, and so on). Because the admin is installed in most projects, those pages "just work", with the admin’s styling, and they are used whenever no template of the same name comes earlier in the search order (a project-level templates/registration/ directory listed in DIRS wins over any app’s templates). Override every page your visitors see, or they will meet the admin’s look. The e-mail subject template, registration/password_reset_subject.txt, comes from django.contrib.auth itself.

LoginView options

Every view takes its options as as_view() keyword arguments or as class attributes when you subclass it.

Option Meaning

template_name

Template to render, default registration/login.html. It receives form, next, site and site_name.

authentication_form

The form class to use, default AuthenticationForm. A custom one must accept request and provide get_user().

next_page

Where to go after login; default LOGIN_REDIRECT_URL (default value /accounts/profile/, which probably does not exist in your project, so set it). Added in 4.0 together with get_default_redirect_url().

redirect_field_name

Name of the GET/POST field carrying the target, default next. A valid next overrides next_page.

redirect_authenticated_user

False by default; True redirects an already-signed-in visitor away from the login page.

success_url_allowed_hosts

Extra hosts, besides this one, that are safe redirect targets after login. Empty by default.

extra_context

A dictionary merged into the template context.

Two cautions on redirect_authenticated_user=True. First, other sites can then probe whether a visitor is signed in to yours, by requesting your login page as an image and watching whether it redirects (a "social media fingerprinting" leak); serve images and the favicon from a different domain if that matters to you. Second, combined with @permission_required without raise_exception=True it creates a redirect loop for a signed-in user who lacks the permission: the view bounces them to login, login bounces them back. The login view also raises ValueError if the redirect target equals the login page itself.

The view’s behaviour is exactly the hand-written view’s: a GET shows the form, a valid POST calls login() and redirects to the safe next or LOGIN_REDIRECT_URL, an invalid POST shows the form again. It is also decorated with sensitive_post_parameters() (the password is scrubbed from error reports), csrf_protect, never_cache, and login_not_required (so it stays reachable under LoginRequiredMiddleware: secure by default).

LogoutView, logout_then_login and redirect_to_login

LogoutView accepts POST only and then redirects to next_page, or to LOGOUT_REDIRECT_URL, or, if that is None (the default), renders registration/logged_out.html. Its options are next_page, template_name, redirect_field_name, extra_context and success_url_allowed_hosts. logout_then_login(request, login_url=None) is a function that logs out and redirects to the login page (it is not wired to a default URL). redirect_to_login(next, login_url=None, redirect_field_name="next") builds the redirect a protected page should return, and is what the decorators use.

The password views' options

The password views share the template-and-form pattern. Every one accepts template_name and extra_context; the interesting extras are:

View Extra options

PasswordChangeView

form_class (default PasswordChangeForm, must accept user), success_url (default password_change_done).

PasswordResetView

form_class (PasswordResetForm), email_template_name, html_email_template_name (to send a multipart HTML mail), subject_template_name, extra_email_context, from_email (default DEFAULT_FROM_EMAIL), token_generator, success_url.

PasswordResetConfirmView

form_class (SetPasswordForm), token_generator, post_reset_login (sign the user in after a successful reset, default False), post_reset_login_backend (needed if there are several backends), reset_url_token (default "set-password"), success_url.

The details of the flows follow in Changing and resetting passwords.

Testing the built-in routes and views (accounts/tests.py)
class BuiltInViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    def test_urls(self):
        names = ["login", "logout", "password_change", "password_change_done", "password_reset",
                 "password_reset_done", "password_reset_complete"]
        self.assertEqual([reverse(n) for n in names], [
            "/accounts/login/", "/accounts/logout/", "/accounts/password_change/",
            "/accounts/password_change/done/", "/accounts/password_reset/",
            "/accounts/password_reset/done/", "/accounts/reset/done/"])
        self.assertEqual(reverse("password_reset_confirm", args=["MQ", "abc-def"]), "/accounts/reset/MQ/abc-def/")

    def test_login_view_uses_login_redirect_url_and_next(self):
        r = self.client.post(reverse("login"), {"username": "reader", "password": PW})
        self.assertRedirects(r, reverse("catalog:shelf"), fetch_redirect_response=False)
        self.client.logout()
        r = self.client.post(reverse("login"), {"username": "reader", "password": PW, "next": "/reviews/"})
        self.assertEqual(r["Location"], "/reviews/")

    def test_login_view_blocks_open_redirect(self):
        r = self.client.post(reverse("login"), {"username": "reader", "password": PW, "next": "//evil.example/"})
        self.assertEqual(r["Location"], reverse("catalog:shelf"))

    def test_logout_view_post_only(self):
        self.client.force_login(self.user)
        self.assertEqual(self.client.get(reverse("logout")).status_code, 405)
        r = self.client.post(reverse("logout"))
        self.assertRedirects(r, reverse("login"), fetch_redirect_response=False)

    def test_redirect_authenticated_user(self):
        self.client.force_login(self.user)
        r = self.client.get(reverse("members_login"))
        self.assertRedirects(r, reverse("catalog:shelf"), fetch_redirect_response=False)
        self.assertEqual(self.client.get(reverse("login")).status_code, 200)   # plain view still shows the form

The same flow against a running server, with real cookies and a CSRF token (the password comes from the environment, never from the command line history):

$ B=http://127.0.0.1:8000 ; J=cookies.txt ; rm -f $J
$ curl -s -i -b $J -c $J "$B/shelf/" | grep -i "^HTTP\|^Location"
HTTP/1.1 302 Found
Location: /accounts/login/?next=/shelf/
$ curl -s -b $J -c $J "$B/accounts/login/?next=/reviews/" -o /dev/null           # sets the csrftoken cookie
$ T=$(grep csrftoken $J | awk '{print $7}')
$ curl -s -i -b $J -c $J -H "Referer: $B/accounts/login/" \
    -d "username=ada&password=$BOOKSHELF_PW&next=/reviews/&csrfmiddlewaretoken=$T" "$B/accounts/login/" | grep -i "^HTTP\|^Location"
HTTP/1.1 302 Found
Location: /reviews/
$ curl -s -b $J "$B/reviews/"
Your reviews
$ curl -s -i -b $J "$B/accounts/logout/" | grep -i "^HTTP\|^Allow"
HTTP/1.1 405 Method Not Allowed
Allow: POST, OPTIONS
$ curl -s -i -X POST -b $J "$B/accounts/logout/" | grep -i "^HTTP"                  # POST without a CSRF token
HTTP/1.1 403 Forbidden

The session cookie is HttpOnly, so JavaScript cannot read it; the CSRF cookie is not, by design, so a script can send it back in a header (see Security).

Restricting access: login_required

The raw check

The most explicit way is to test request.user.is_authenticated yourself and redirect to the login page, carrying the current path along. This is what the decorator does, so use it only when you need a custom response (a JSON 401, for example).

from django.conf import settings
from django.shortcuts import redirect


def my_view(request):
    if not request.user.is_authenticated:
        return redirect(f"{settings.LOGIN_URL}?next={request.path}")
    ...

Careful: settings.LOGIN_URL may be a URL name (as in this project), and then this f-string is wrong; use redirect_to_login(request.get_full_path()) from django.contrib.auth.views, which resolves names and builds the query string correctly.

The decorator

catalog/views.py
from django.contrib.auth.decorators import login_not_required, login_required, user_passes_test
from django.http import HttpResponse, JsonResponse


@login_not_required
def book_list(request):
    return HttpResponse("Public catalogue")


@login_required
def shelf(request):
    return HttpResponse(f"Shelf of {request.user.get_username()}")


@user_passes_test(lambda u: u.is_staff, login_url="login")
def staff_report(request):
    return HttpResponse("Staff report")


@login_required
async def async_shelf(request):
    user = await request.auser()
    return JsonResponse({"shelf_of": user.get_username()})

login_required(redirect_field_name="next", login_url=None) lets a signed-in user through and otherwise redirects to login_url (default settings.LOGIN_URL) with the current path in the query string, for example /accounts/login/?next=/shelf/. Use redirect_field_name to rename the parameter (and then read the same name in your login template), and login_url to send a particular view elsewhere. Used bare, it works on a plain function or on an async def view, since 5.1; on earlier versions an async view needed a different approach. Notes:

  • It does not check is_active itself; the default backends already make inactive users anonymous.

  • user_passes_test(test_func, login_url=None, redirect_field_name="next") is the general form: the callable receives the user and returns True to allow. It does not check that the user is not anonymous; the staff test above is safe only because AnonymousUser.is_staff is False, and a test such as lambda u: u.email.endswith(…​) would need its own guard. Passing redirect_field_name=None drops the next parameter, useful when the redirect target is not a login page.

  • For staff-only views of the admin kind, django.contrib.admin.views.decorators.staff_member_required exists.

  • Permission-based access (permission_required, PermissionRequiredMixin) is covered on Permissions & Authorization. When you combine raise_exception=True with a login prompt, stack @login_required outside it.

LoginRequiredMixin and AccessMixin

For class-based views, LoginRequiredMixin plays the role of the decorator. It is built on AccessMixin, whose attributes configure how denial is handled; UserPassesTestMixin and PermissionRequiredMixin use the same ones.

Attribute or method Meaning

login_url, get_login_url()

Where anonymous users are sent; None falls back to settings.LOGIN_URL.

redirect_field_name, get_redirect_field_name()

Name of the query parameter (default next); return None for no parameter.

raise_exception

True raises PermissionDenied (a 403 page) instead of redirecting anonymous users.

permission_denied_message, get_permission_denied_message()

Message handed to the 403 handler.

handle_no_permission()

Does the actual work: raise or redirect. Override it for custom behaviour (a JSON response, for instance).

A signed-in user who is denied (for example by a permission check) always gets a 403, never a redirect to the login page; only anonymous users are redirected, which prevents a loop.

catalog/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse
from django.views import View


class ReviewListView(LoginRequiredMixin, View):
    redirect_field_name = "return_to"

    def get(self, request):
        return HttpResponse("Your reviews")

The order of base classes decides whether the check runs. Python looks for dispatch() from left to right, and the mixin’s dispatch() is where the login check lives and then calls super().dispatch(). Put the mixin first and it runs before the view logic; put it after View and View.dispatch() is found first, the mixin is never consulted, and the page is open to the world. The same happens if you override dispatch() in your view and do work before calling super().dispatch(): that code then runs for anonymous visitors. One of the books this page was checked against shows a dispatch()-first pattern; the corrected form always calls super() first.

class WrongOrderView(View, LoginRequiredMixin):  # BUG: View.dispatch() wins, the login check never runs
    def get(self, request):
        return HttpResponse("secret, but open to everybody")


class RightOrderView(LoginRequiredMixin, View):
    def get(self, request):
        return HttpResponse("secret")
Testing the decorators and the mixin (catalog/tests.py)
class AccessTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)
        cls.staff = User.objects.create_user("editor", "editor@example.com", PW, is_staff=True)

    def test_login_required_redirects_with_next(self):
        r = self.client.get(reverse("catalog:shelf"))
        self.assertEqual((r.status_code, r["Location"]), (302, "/accounts/login/?next=/shelf/"))
        self.client.force_login(self.user)
        self.assertContains(self.client.get(reverse("catalog:shelf")), "Shelf of reader")

    def test_mixin_uses_custom_redirect_field_name(self):
        r = self.client.get(reverse("catalog:reviews"))
        self.assertEqual(r["Location"], "/accounts/login/?return_to=/reviews/")

    def test_user_passes_test(self):
        self.client.force_login(self.user)
        r = self.client.get(reverse("catalog:staff_report"))
        self.assertEqual(r["Location"], "/accounts/login/?next=/staff-report/")
        self.client.force_login(self.staff)
        self.assertEqual(self.client.get(reverse("catalog:staff_report")).status_code, 200)

    def test_mixin_must_be_leftmost(self):
        self.assertEqual(self.client.get("/wrong-order/").status_code, 200)  # the bug: anonymous gets in
        self.assertEqual(self.client.get("/right-order/").status_code, 302)

LoginRequiredMiddleware: secure by default

Decorators and mixins are opt-in: forget one and a page is public. LoginRequiredMiddleware, added in Django 5.1, flips the default. With it installed, every view requires a login, and a view is public only if it is marked @login_not_required. Place it after AuthenticationMiddleware. It also appears in the middleware reference; this section is the authentication-side view.

Enabling the middleware (config/settings.py)
MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.auth.middleware.LoginRequiredMiddleware",
    # ...
]
LOGIN_URL = "login"

How it behaves, as verified in the tests below:

  • It runs in process_view, after URL resolution, so it knows which view will run and can read the markers the decorators leave on it.

  • An anonymous request to an unmarked view is redirected to the login URL with the original path in ?next=.

  • It honours the login_url and redirect_field_name of a @login_required decorator on the view, but it ignores the same attributes on LoginRequiredMixin (the middleware only sees the function that as_view() returns). In the tests, /reviews/ with redirect_field_name = "return_to" is redirected with next, not return_to.

  • Django’s own LoginView, PasswordResetView, PasswordResetDoneView, PasswordResetConfirmView, PasswordResetCompleteView and the admin login are already marked public. LogoutView and PasswordChangeView are not: an anonymous request for them is sent to login, which is harmless.

  • Your own public pages (the book list, the sign-up form, static and media routes if Django serves them, webhooks, health checks, token-authenticated API endpoints) must be marked, or they redirect to a login page that nobody can reach.

  • It answers "is someone signed in?", never "may they?". Per-view authorisation checks are still needed.

from django.contrib.auth.decorators import login_not_required
from django.urls import path

from accounts import views as account_views
from catalog import views

urlpatterns = [
    path("", login_not_required(views.book_list)),
    path("accounts/signup/", login_not_required(account_views.signup)),
]

For class-based views apply the decorator to dispatch with method_decorator(login_not_required, name="dispatch"), or wrap as_view() in the URLconf as above. Subclass the middleware to change where anonymous requests go: override get_login_url(view_func) and get_redirect_field_name(view_func), or the redirect_field_name attribute. Returning None from get_redirect_field_name() means no query parameter.

A customised middleware (catalog/middleware.py)
from django.contrib.auth.middleware import LoginRequiredMiddleware


class BookshelfLoginRequiredMiddleware(LoginRequiredMiddleware):
    """Send anonymous visitors of catalog views to a dedicated page, and never add ?next=."""

    def get_login_url(self, view_func):
        return "/welcome/" if view_func.__module__.startswith("catalog") else super().get_login_url(view_func)

    def get_redirect_field_name(self, view_func):
        return None
Testing the middleware (catalog/tests.py)
from django.test import TestCase, override_settings

BASE_MW = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]


@override_settings(MIDDLEWARE=BASE_MW + ["django.contrib.auth.middleware.LoginRequiredMiddleware"])
class MiddlewareTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    def test_everything_needs_login_except_marked_views(self):
        self.assertEqual(self.client.get("/").status_code, 200)               # @login_not_required
        for url in ("/reviews/", "/staff-report/", "/accounts/signup/", "/accounts/password_change/"):
            with self.subTest(url=url):
                self.assertEqual(self.client.get(url).status_code, 302)
        for name in ("login", "password_reset", "password_reset_done", "password_reset_complete"):
            with self.subTest(name=name):
                self.assertEqual(self.client.get(reverse(name)).status_code, 200)

    def test_mixin_attributes_are_ignored_by_the_middleware(self):
        r = self.client.get("/reviews/")
        self.assertEqual(r["Location"], "/accounts/login/?next=/reviews/")

    def test_authenticated_users_pass(self):
        self.client.force_login(self.user)
        self.assertEqual(self.client.get("/reviews/").status_code, 200)


@override_settings(MIDDLEWARE=BASE_MW + ["catalog.middleware.BookshelfLoginRequiredMiddleware"])
class CustomMiddlewareTests(TestCase):
    def test_custom_login_url_without_next(self):
        r = self.client.get("/shelf/")
        self.assertEqual((r.status_code, r["Location"]), (302, "/welcome/"))
        r = self.client.get("/accounts/password_change/")   # not a catalog view: default LOGIN_URL, still no ?next=
        self.assertEqual(r["Location"], "/accounts/login/")

Under the middleware, accounts/signup/ was redirected in the test because the sign-up view had not been marked: the exact mistake the bullet list above warns about. Note that the test replaces MIDDLEWARE completely; in a real project insert the class into the list you already have. A DRF or Ninja API that authenticates with tokens has its own authentication classes; exempt those endpoints with login_not_required and let the API framework answer 401.

Sign-up with UserCreationForm

Django has no built-in sign-up view, only a form. UserCreationForm (in django.contrib.auth.forms) has the fields username, password1 and password2; it checks that the passwords match, runs the AUTH_PASSWORD_VALIDATORS, hashes the password with set_password() and, unlike its parent, refuses a username that differs from an existing one only in case (Reader versus reader). Since Django 4.2 its parent BaseUserCreationForm is the recommended base when you customise the form, and UserCreationForm also saves many-to-many fields of a custom user model. AdminUserCreationForm (5.1) adds a usable_password choice for the admin.

The form works on the user model through Meta.model. With the default User that is automatic; with a custom user model you must set model yourself (see the next page of this group). Always list fields explicitly, never "all": a form that exposes is_staff or is_superuser is a privilege-escalation hole.

accounts/forms.py
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm


class SignUpForm(UserCreationForm):
    email = forms.EmailField(required=True)

    class Meta(UserCreationForm.Meta):
        model = get_user_model()
        fields = ("username", "email")

    def clean_email(self):
        email = self.cleaned_data["email"].strip()
        if get_user_model().objects.filter(email__iexact=email).exists():
            raise forms.ValidationError("An account with this address already exists.", code="duplicate_email")
        return email

Email on the default User is not unique, so the form checks it itself, case-insensitively. For a unique constraint that the database enforces, and for email-as-username, use a custom user model.

accounts/views.py
from django.contrib import messages
from django.contrib.auth import login
from django.shortcuts import redirect, render

from .forms import SignUpForm


def signup(request):
    if request.user.is_authenticated:
        return redirect("catalog:shelf")
    if request.method == "POST":
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user, backend="django.contrib.auth.backends.ModelBackend")
            messages.success(request, f"Welcome, {user.get_username()}!")
            return redirect("catalog:shelf")
    else:
        form = SignUpForm()
    return render(request, "accounts/signup.html", {"form": form})
templates/accounts/signup.html
{% extends "base.html" %}
{% block title %}Sign up{% endblock %}
{% block content %}
<h1>Create your account</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Sign up</button>
</form>
{% endblock %}

A user that form.save() just created has not passed through authenticate(), so it has no backend attribute; that is why login() is given backend=. With a single backend in AUTHENTICATION_BACKENDS the argument is optional. Logging the new user in immediately is a product decision; many sites instead send a confirmation e-mail first and keep the account inactive (is_active = False) until the link is followed, using signed tokens from django.core.signing. For e-mail verification, social login and multi-factor authentication, django-allauth (65.19.7 on 2026-10-03, which declares support for Django 6.1) packages all of it; it is introduced on Custom User Model & Backends, and the protocol theory is on OAuth flows overview.

The form-rendering helpers used in the templates (as_div, field groups) are described on Form Rendering & Widgets; the form lifecycle itself on Forms.

Testing sign-up (accounts/tests.py)
NUMERIC_PW = "".join(secrets.choice("0123456789") for _ in range(12))   # fails the "entirely numeric" validator


class SignUpTests(TestCase):
    def test_signup_creates_user_hashes_password_and_logs_in(self):
        r = self.client.post(reverse("signup"), {
            "username": "newreader", "email": "new@example.com", "password1": PW, "password2": PW})
        self.assertRedirects(r, reverse("catalog:shelf"), fetch_redirect_response=False)
        user = User.objects.get(username="newreader")
        self.assertTrue(user.check_password(PW))
        self.assertNotEqual(user.password, PW)
        self.assertTrue(user.password.startswith("pbkdf2_sha256$1500000$"))
        self.assertEqual(self.client.session[SESSION_KEY], str(user.pk))

    def test_case_insensitive_duplicate_usernames_are_rejected(self):
        User.objects.create_user("Reader", "a@example.com", PW)
        r = self.client.post(reverse("signup"), {
            "username": "reader", "email": "b@example.com", "password1": PW, "password2": PW})
        self.assertContains(r, "A user with that username already exists.")

    def test_numeric_password_rejected_by_validators(self):
        r = self.client.post(reverse("signup"), {
            "username": "x1", "email": "x@example.com", "password1": NUMERIC_PW, "password2": NUMERIC_PW})
        self.assertContains(r, "This password is entirely numeric.")
        self.assertFalse(User.objects.filter(username="x1").exists())

    def test_duplicate_email_rejected(self):
        User.objects.create_user("a", "dup@example.com", PW)
        r = self.client.post(reverse("signup"), {
            "username": "b", "email": "DUP@example.com", "password1": PW, "password2": PW})
        self.assertContains(r, "already exists")

The test confirms the default PBKDF2 setting of Django 6.1 (1,500,000 iterations; the count is raised in every release, and the stored hash records the count it was made with, so old hashes keep working and are upgraded at the next login).

Changing and resetting passwords

Changing your own password

PasswordChangeView (name password_change) shows PasswordChangeForm, which asks for the old password and the new one twice, validates the new one with the configured validators, and saves. The view requires a login. Its sibling PasswordChangeDoneView shows a confirmation. After a successful change the view calls update_session_auth_hash() for you: it updates the session’s stored hash and rotates the session key, so the user who changed the password stays signed in, while every other session of that user (another browser, a stolen cookie) stops working at its next request, because the hash derived from the new password no longer matches theirs. A custom password-change view must call update_session_auth_hash(request, user) itself; forgetting it logs the user out of their own session.

templates/registration/password_change_form.html
{% extends "base.html" %}
{% block content %}
<h1>Change your password</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Change password</button>
</form>
{% endblock %}

The matching password_change_done.html only needs a heading and a sentence. In the project of this page, the other templates below follow the same shape.

The reset flow, step by step

The reset flow takes four screens, and each has a view and a URL name from django.contrib.auth.urls:

sequenceDiagram participant U as Visitor participant R as password_reset participant M as Mailer participant C as password_reset_confirm U->>R: POST email R->>M: send link only if the user exists, is active and has a usable password R-->>U: 302 to password_reset_done (same answer for unknown addresses) M-->>U: email with /accounts/reset/uid/token/ U->>C: GET the link C-->>U: 302 to /accounts/reset/uid/set-password/ (token moved into the session) U->>C: POST new_password1, new_password2 C-->>U: 302 to password_reset_complete
  1. password_reset: the visitor enters an e-mail address. Django sends a message only if a user with that address exists, is active and has a usable password. In every other case it sends nothing and still redirects to the "done" page, so the form cannot be used to learn which addresses are registered. (To show a different message you would subclass PasswordResetForm; that reopens the enumeration leak, so think first.) Sending mail costs time, and that timing difference can still reveal whether the address exists; for sensitive sites send the mail from a background task (see the tasks and email pages of this section).

  2. password_reset_done: "check your inbox".

  3. password_reset_confirm: the link in the e-mail carries uidb64 (the user id) and a token. On the first GET the view validates the token, stores it in the session and redirects to the same URL with the token replaced by set-password (reset_url_token), so the real token never appears in a page that a Referer header could leak. The template gets validlink and form.

  4. password_reset_complete: "your password was changed", with a link to sign in (or sign the user in directly with post_reset_login = True).

The token is a keyed hash of the user’s primary key, password hash, e-mail address and last-login time plus a timestamp, made with SECRET_KEY. Nothing is stored server-side. Consequences you can rely on: the token dies as soon as the password changes (so it is single use), as soon as the user logs in, and after PASSWORD_RESET_TIMEOUT seconds (default 259200, three days; this project uses one hour). The old PASSWORD_RESET_TIMEOUT_DAYS setting was removed in 4.0. Shortening the timeout does not make guessing harder, because tokens are unguessable regardless; it limits the damage from an old mailbox.

templates/registration/password_reset_email.html and password_reset_subject.txt
{% autoescape off %}Hello {{ user.get_username }},

Someone asked to reset the password of your Bookshelf account. Follow the link below within the hour:

{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}

If this was not you, ignore this message.
{% endautoescape %}
{% autoescape off %}Reset your Bookshelf password{% endautoescape %}

The e-mail templates receive email, user, domain, site_name, protocol, uid, token (plus anything in extra_email_context), and the subject must be a single line. Autoescaping is turned off because this is plain text. domain and protocol come from the sites framework when it is installed, and otherwise from the request, which means the Host header; keep ALLOWED_HOSTS strict, or a forged Host header can poison the link in a reset e-mail.

templates/registration/password_reset_confirm.html
{% extends "base.html" %}
{% block content %}
{% if validlink %}
  <h1>Choose a new password</h1>
  <form method="post">
    {% csrf_token %}
    {{ form.as_div }}
    <button type="submit">Save new password</button>
  </form>
{% else %}
  <h1>Link expired</h1>
  <p>This reset link is invalid or was already used. <a href="{% url 'password_reset' %}">Request a new one.</a></p>
{% endif %}
{% endblock %}

How the message is delivered depends on the mail configuration: in development the console backend prints it (Django 6.1’s startproject writes MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}; on 5.2 the equivalent is EMAIL_BACKEND), and in tests the in-memory outbox collects it. MAILERS replaces the EMAIL_* settings, which now emit deprecation warnings; mixing both raises ImproperlyConfigured. The mailing details belong to the email page of this section.

$ curl -s -i -b $J -c $J -H "Referer: $B/" -d "email=ada@example.com&csrfmiddlewaretoken=$T" "$B/accounts/password_reset/" | grep -i "^HTTP\|^Location"
HTTP/1.1 302 Found
Location: /accounts/password_reset/done/

and the server console shows the message (abridged):

Content-Transfer-Encoding: quoted-printable
MIME-Version: 1.0
Subject: Reset your Bookshelf password
From: webmaster@localhost
To: ada@example.com

Hello ada,

Someone asked to reset the password of your Bookshelf account. Follow the link below within the hour:
Testing the change and reset flows (accounts/tests.py)
import re
from datetime import datetime, timedelta
from unittest import mock

from django.contrib.auth.tokens import default_token_generator
from django.core import mail


class PasswordFlowTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    def test_change_keeps_this_session_and_kills_others(self):
        other = self.client_class()
        other.force_login(self.user)
        self.client.force_login(self.user)
        new_pw = secrets.token_urlsafe(16)
        r = self.client.post(reverse("password_change"), {
            "old_password": PW, "new_password1": new_pw, "new_password2": new_pw})
        self.assertRedirects(r, reverse("password_change_done"), fetch_redirect_response=False)
        self.assertEqual(self.client.get(reverse("catalog:shelf")).status_code, 200)   # still signed in
        self.assertEqual(other.get(reverse("catalog:shelf")).status_code, 302)         # other browser signed out

    def test_full_reset_flow(self):
        r = self.client.post(reverse("password_reset"), {"email": "reader@example.com"})
        self.assertRedirects(r, reverse("password_reset_done"), fetch_redirect_response=False)
        self.assertEqual(len(mail.outbox), 1)
        link = re.search(r"http://testserver(/accounts/reset/\S+)", mail.outbox[0].body).group(1)
        r = self.client.get(link)          # redirects to the same URL with the token replaced by "set-password"
        self.assertEqual(r.status_code, 302)
        self.assertTrue(r["Location"].endswith("/set-password/"))
        r = self.client.get(r["Location"])
        self.assertContains(r, "Choose a new password")
        new_pw = secrets.token_urlsafe(16)
        r = self.client.post(r.request["PATH_INFO"], {"new_password1": new_pw, "new_password2": new_pw})
        self.assertRedirects(r, reverse("password_reset_complete"), fetch_redirect_response=False)
        self.user.refresh_from_db()
        self.assertTrue(self.user.check_password(new_pw))
        self.assertContains(self.client.get(link, follow=True), "Link expired")        # single use

    def test_unknown_email_looks_identical_and_sends_nothing(self):
        r = self.client.post(reverse("password_reset"), {"email": "nobody@example.com"})
        self.assertRedirects(r, reverse("password_reset_done"), fetch_redirect_response=False)
        self.assertEqual(mail.outbox, [])

    def test_inactive_user_gets_no_email(self):
        User.objects.filter(pk=self.user.pk).update(is_active=False)
        self.client.post(reverse("password_reset"), {"email": "reader@example.com"})
        self.assertEqual(mail.outbox, [])

    def test_token_expires(self):
        self.client.post(reverse("password_reset"), {"email": "reader@example.com"})
        link = re.search(r"http://testserver(/accounts/reset/\S+)", mail.outbox[0].body).group(1)
        later = datetime.now() + timedelta(hours=2)    # the configured timeout is one hour
        with mock.patch.object(default_token_generator, "_now", return_value=later):
            self.assertContains(self.client.get(link, follow=True), "Link expired")

    def test_token_dies_when_password_changes(self):
        self.client.post(reverse("password_reset"), {"email": "reader@example.com"})
        link = re.search(r"http://testserver(/accounts/reset/\S+)", mail.outbox[0].body).group(1)
        self.user.set_password(secrets.token_urlsafe(16))
        self.user.save()
        self.assertContains(self.client.get(link, follow=True), "Link expired")

The expiry test patches the generator’s private clock now because the token only stops validating when the elapsed seconds _exceed the timeout; that is a test technique, not an API to use in application code. Passwords themselves, the validators that SetPasswordForm and UserCreationForm apply, and the hashers are covered on Passwords.

Authentication data in templates

With the default TEMPLATES setup, the context processor django.contrib.auth.context_processors.auth adds two variables to every template rendered with a request context (render(), class-based views, TemplateResponse):

  • {{ user }} is the current User or an AnonymousUser; use user.is_authenticated.

  • {{ perms }} is a permission wrapper: {% if perms.catalog %} asks whether the user has any permission in the app, and {% if perms.catalog.change_book %} asks for one permission. The model-permission system is explained on Permissions & Authorization.

templates/base.html (navigation part)
<nav>
  <a href="{% url 'catalog:book_list' %}">Books</a>
  {% if user.is_authenticated %}
    <span>Signed in as {{ user.get_username }}</span>
    <form method="post" action="{% url 'logout' %}" style="display:inline">
      {% csrf_token %}
      <button type="submit">Sign out</button>
    </form>
  {% else %}
    <a href="{% url 'login' %}">Sign in</a>
    <a href="{% url 'signup' %}">Sign up</a>
  {% endif %}
</nav>

user is not available in a template rendered by Template.render(Context(…​)) without a RequestContext. A name that you define yourself (a loop variable called user, a view that passes user=…​) shadows the processor’s value, a classic source of "I am logged in but the page says I am not". Give your own variable a different name, such as profile_user.

Async authentication

Django 5.0 added a mirror of the whole login API with an a prefix, so async views never have to wrap authentication in sync_to_async() by hand: aauthenticate(), alogin(), alogout(), aget_user(), aupdate_session_auth_hash(), the await request.auser() method that AuthenticationMiddleware installs, and the password checks acheck_password() (on the user, and django.contrib.auth.hashers.acheck_password()). The user manager has acreate_user() and acreate_superuser(), and the permission methods have async forms (ahas_perm() and friends). Backends may be sync, in which case Django runs them in a worker thread; the built-in ModelBackend has async methods of its own.

Things to remember in async views:

  • request.user is lazy and synchronous: reading an attribute of it from an async def view hits the database and raises SynchronousOnlyOperation. Use user = await request.auser().

  • request.auser is an async callable; await it once and keep the result.

  • Hashing is CPU-bound. acheck_password() runs the hasher in a thread. user.set_password(…​) has no async twin: it blocks the event loop for the length of a hash (tens to hundreds of milliseconds with the default iteration count), so run it through sync_to_async for busy servers or do it in a background task.

  • @login_required, @permission_required and @user_passes_test decorate async def views as of Django 5.1.

  • Auth middleware and the session work in both modes; a sync middleware in an async stack adds a thread hop for each request. See Middleware for the cost.

accounts/views.py (async views)
from asgiref.sync import sync_to_async
from django.contrib.auth import aauthenticate, alogin, aupdate_session_auth_hash
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.http import JsonResponse
from django.views.decorators.http import require_POST


@require_POST
async def async_login(request):
    user = await aauthenticate(
        request,
        username=request.POST.get("username", ""),
        password=request.POST.get("password", ""),
    )
    if user is None:
        return JsonResponse({"detail": "invalid credentials"}, status=400)
    await alogin(request, user)
    return JsonResponse({"username": user.get_username()})


async def async_whoami(request):
    user = await request.auser()
    if not user.is_authenticated:
        return JsonResponse({"detail": "not signed in"}, status=401)
    return JsonResponse({"username": user.get_username(), "staff": user.is_staff})


@require_POST
async def async_change_password(request):
    user = await request.auser()
    if not user.is_authenticated:
        return JsonResponse({"detail": "not signed in"}, status=401)
    if not await user.acheck_password(request.POST.get("old_password", "")):
        return JsonResponse({"detail": "wrong password"}, status=400)
    new_password = request.POST.get("new_password", "")
    try:
        await sync_to_async(validate_password)(new_password, user)
    except ValidationError as exc:
        return JsonResponse({"detail": exc.messages}, status=400)
    user.set_password(new_password)  # CPU-bound hashing: it blocks the event loop for a moment
    await user.asave(update_fields=["password"])
    await aupdate_session_auth_hash(request, user)  # keep this session, rotate its key
    return JsonResponse({"detail": "changed"})

These views accept POST and CSRF-protect like any other; a JavaScript client must send the CSRF token in the X-CSRFToken header. validate_password is synchronous because validators may query the database (the user-similarity validator reads attributes, and custom validators often hit a table), so it is wrapped.

Testing the async views with AsyncClient (accounts/tests.py)
from django.test import AsyncClient


class AsyncAuthTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    async def test_async_login_whoami_and_shelf(self):
        c = AsyncClient()
        r = await c.get(reverse("async_whoami"))
        self.assertEqual(r.status_code, 401)
        r = await c.post(reverse("async_login"), {"username": "reader", "password": WRONG_PW})
        self.assertEqual(r.status_code, 400)
        r = await c.post(reverse("async_login"), {"username": "reader", "password": PW})
        self.assertEqual(r.json(), {"username": "reader"})
        r = await c.get(reverse("async_whoami"))
        self.assertEqual(r.json(), {"username": "reader", "staff": False})
        r = await c.get(reverse("catalog:async_shelf"))
        self.assertEqual(r.json(), {"shelf_of": "reader"})

    async def test_async_login_required_redirects(self):
        r = await AsyncClient().get(reverse("catalog:async_shelf"))
        self.assertEqual((r.status_code, r["Location"]), (302, "/accounts/login/?next=/async-shelf/"))

    async def test_async_password_change_keeps_this_session(self):
        c = AsyncClient()
        await c.alogin(username="reader", password=PW)
        other = AsyncClient()
        await other.alogin(username="reader", password=PW)
        new_pw = secrets.token_urlsafe(16)
        r = await c.post(reverse("async_change_password"), {"old_password": PW, "new_password": new_pw})
        self.assertEqual(r.json(), {"detail": "changed"})
        self.assertEqual((await c.get(reverse("async_whoami"))).status_code, 200)
        self.assertEqual((await other.get(reverse("async_whoami"))).status_code, 401)
        bad = await c.post(reverse("async_change_password"), {"old_password": new_pw, "new_password": "12345678"})
        self.assertEqual(bad.status_code, 400)

The async test client mirrors the sync one: AsyncClient.alogin(), aforce_login() and alogout() exist, and the test methods are plain async def functions that Django’s TestCase runs for you. Channels consumers and other non-view async code use the same functions, or Channels' own AuthMiddlewareStack, which is outside the scope of this page.

Testing authentication

Three helpers cover almost every test:

  • self.client.force_login(user) creates a session for the user without checking a password (fast; it picks the first backend, or accepts backend=). Use it for every test that is not about logging in.

  • self.client.login(username=…​, password=…​) goes through authenticate() and the real backends, and returns False on failure. Use it when the credentials path is what you test.

  • self.client.logout() clears the session.

Create users in setUpTestData() with create_user() so passwords are hashed. Do not write literal passwords in tests that a secret scanner will flag; a runtime-generated value such as secrets.token_urlsafe(16) (as above) is both safe and obviously not a credential. Assert the redirect target with assertRedirects(…​, fetch_redirect_response=False) or by reading the Location header, so a test cannot pass by following a redirect into an unrelated 200. One book’s pattern of asserting 200 on a POST that really returns 302 is a test that passes for the wrong reason. Test a GET to logout (expect 405), an anonymous request to every protected view (expect 302 or 403), and the unhappy paths: wrong password, inactive user, expired token, other-user access. The speed tip: tests that create many users can set PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] in a test-only settings module, never in production.

Inactive users and backends

A user with is_active=False cannot log in with the default setup, and the reason is subtle: ModelBackend.authenticate() itself returns None for an inactive user (through user_can_authenticate()), so AuthenticationForm reports the generic "Please enter a correct username and password" message, not "This account is inactive". The confirm_login_allowed() hook of the form, which raises "This account is inactive.", only becomes reachable when the backend lets inactive users through: AllowAllUsersModelBackend in AUTHENTICATION_BACKENDS. Then you decide the policy in a form that overrides confirm_login_allowed() (for example "inactive means e-mail not yet confirmed, show a hint").

Letting inactive users reach the form check (accounts/tests.py)
from django.test import override_settings


class InactiveUserTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("reader", "reader@example.com", PW)

    def test_inactive_user_cannot_log_in(self):
        User.objects.filter(pk=self.user.pk).update(is_active=False)
        r = self.client.post(reverse("manual_login"), {"username": "reader", "password": PW})
        self.assertContains(r, "Please enter a correct username and password")   # ModelBackend returns None

    @override_settings(AUTHENTICATION_BACKENDS=["django.contrib.auth.backends.AllowAllUsersModelBackend"])
    def test_allow_all_users_backend_reaches_the_form_check(self):
        User.objects.filter(pk=self.user.pk).update(is_active=False)
        r = self.client.post(reverse("manual_login"), {"username": "reader", "password": PW})
        self.assertContains(r, "This account is inactive.")

Writing your own backend (an e-mail login, an LDAP or SSO bridge, REMOTE_USER) is on Custom User Model & Backends. Note also that has_perm() and the admin return False for inactive users even for superusers.

Pitfalls checklist

  • A logout link (<a href="/logout/">): since 5.0 it returns 405, and even where it works it is a CSRF-able state change. Use a POST form.

  • LOGIN_REDIRECT_URL left at /accounts/profile/, a page that does not exist: the first login ends in a 404. Set it.

  • LOGIN_URL set to a name that is not in the URLconf, or the django.contrib.auth.urls include not mounted: login_required redirects into a 404.

  • LoginRequiredMixin not leftmost, or code before super().dispatch(): the check is skipped.

  • Redirecting to a raw next value: an open redirect. Use url_has_allowed_host_and_scheme() or the built-in views.

  • redirect_authenticated_user=True with permission_required and no raise_exception=True: a redirect loop.

  • LoginRequiredMiddleware on and the sign-up view, the health check, the webhook or the token API not marked login_not_required.

  • Expecting the middleware to honour LoginRequiredMixin.login_url or redirect_field_name: it only reads decorator attributes.

  • request.user.is_authenticated() (called as a method) from old code: it is a property and the call raises TypeError.

  • Reading request.user attributes in an async view instead of await request.auser().

  • Exposing is_staff, is_superuser or "all" in a sign-up or profile form.

  • A custom password-change view that forgets update_session_auth_hash(): the user is signed out of their own session.

  • Relying on the admin’s registration/* templates by accident: the visitor-facing pages then look like the admin.

  • PASSWORD_RESET_TIMEOUT left at three days, or ALLOWED_HOSTS = ["*"], so a forged Host header can poison the reset link.

  • Reading from django.contrib.auth.models import User in reusable code instead of get_user_model().

  • Hard-coded test passwords and SECRET_KEY in the repository: read them from the environment or generate them at runtime.

  • Writing the SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE and SESSION_COOKIE_HTTPONLY settings off: run python manage.py check --deploy before releases.

  • Changing AUTH_USER_MODEL after the first migration without a plan: do it before migrate on day one.

Where the books are outdated

You may read Today

url(r"^login/$", auth_views.login) and the function-based login, logout, password_change, password_reset* views

Deprecated in 1.11 and removed in 2.1. Use the class-based LoginView, LogoutView and password views, or include("django.contrib.auth.urls"), with path() (url() itself is gone since 4.0).

{% if user.is_authenticated() %} or request.user.is_authenticated()

Became a property in 1.10 and the old call syntax stopped working in 2.0; write user.is_authenticated.

SessionAuthenticationMiddleware must be added to invalidate sessions after a password change

Removed in 2.0; the behaviour is always on and update_session_auth_hash() keeps the changing user signed in.

A logout link such as <a href="{% url 'logout' %}"> ending in a redirect

Logging out through GET was deprecated in 4.1 and removed in 5.0; LogoutView is POST only, so the link now returns 405.

PASSWORD_RESET_TIMEOUT_DAYS

Removed in 4.0; use PASSWORD_RESET_TIMEOUT in seconds (3.1).

UserCreationForm as the base for every custom registration form

Still works, but BaseUserCreationForm (4.2) is now the recommended base; the form also stores many-to-many fields of a custom user.

Every view needs @login_required, or a hand-written "login everywhere" middleware

LoginRequiredMiddleware and @login_not_required (5.1) implement the secure default.

MIDDLEWARE_CLASSES listing the auth middleware

Replaced by MIDDLEWARE in 1.10 and removed in 2.0.

A dispatch() override that checks the user before the mixin, or a mixin placed after View

The mixin must be leftmost and super().dispatch() must run first; see LoginRequiredMixin and AccessMixin.

Wrapping every auth call in sync_to_async for async views

aauthenticate, alogin, alogout, request.auser(), acheck_password (5.0) and async-capable decorators (5.1).

PBKDF2 with 36,000 iterations, or SHA-1/MD5 hashers, as defaults

PBKDF2-SHA256 is the default with 1,500,000 iterations in 6.1 (raised every release; 600,000 in 4.2, 1,000,000 in 5.2); the SHA-1 and unsalted MD5/SHA-1 hashers were removed in 5.1. See the passwords page.

EMAIL_BACKEND settings in a new project

Django 6.1 new projects get MAILERS; EMAIL_BACKEND and the other EMAIL_* settings still work but warn.

What changed recently

Version Change

1.11

LoginView and LogoutView supersede the function-based views; post_reset_login for PasswordResetConfirmView; the reset token moves into the session; success_url_allowed_hosts.

3.1

PASSWORD_RESET_TIMEOUT replaces PASSWORD_RESET_TIMEOUT_DAYS; reset tokens and the session hash use SHA-256.

4.0

LoginView.next_page and get_default_redirect_url(); PASSWORD_RESET_TIMEOUT_DAYS removed.

4.1

Logging out via GET deprecated; SECRET_KEY_FALLBACKS; RemoteUserBackend.configure_user() can synchronise attributes.

4.2

BaseUserCreationForm is the recommended base; UserCreationForm saves many-to-many fields.

5.0

LogoutView is POST only; the async API (aauthenticate, alogin, alogout, aget_user, aupdate_session_auth_hash, request.auser(), acheck_password).

5.1

LoginRequiredMiddleware and login_not_required; login_required, permission_required and user_passes_test decorate async views; AdminUserCreationForm and unusable-password support in the admin.

5.2 (LTS)

shell auto-imports the auth models; the fallback to request.user in login() is deprecated.

6.0

PBKDF2 default iterations raised to 1,200,000.

6.1

The login() and alogin() fallbacks to request.user and request.auser() when user is None are removed (always pass the user); default PBKDF2 iterations 1,500,000; permissions are renamed with their models, and Permission.user_perm_str was added; the admin login view follows next for signed-in users; under ASGI RemoteUserMiddleware stops prefixing HTTP_ to custom header names; new projects get MAILERS.