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 |
|
|
Remember that the visitor is signed in |
|
|
Sign someone out |
|
|
Get a working login page quickly |
|
|
Require a login on one function view |
|
|
Require a login on one class-based view |
|
|
Require a login on the whole site |
|
|
Let people create accounts |
|
|
Let people change or reset their password |
|
|
Authenticate from an async view |
|
|
Write a test that is signed in |
|
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 |
|---|---|
|
The |
|
Gives every request a |
|
Adds |
|
The list of classes that check credentials and load users. The default is |
|
The three functions every higher-level piece is built from. |
Views, forms, decorators, mixins |
|
|
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.
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.
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_authenticatedsays a user was found for this session. It does not checkis_active, permissions or anything else. -
is_activeis a soft-delete switch: set it toFalseinstead of deleting an account, so foreign keys to it keep working. The defaultModelBackendrefuses inactive users, and so do the default login forms. -
Never touch
user.passworddirectly; useset_password()or the helpercreate_user().
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
requestas 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.backendto the dotted path of the backend that approved the user;login()stores it in the session. -
A failure sends the
user_login_failedsignal with the credentials, which is the hook for lockouts and audit logs.
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:
-
Computes the user’s session auth hash, an HMAC of the stored password hash.
-
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.
-
Stores three values in the session: the user’s primary key, the dotted path of the backend, and the hash.
-
Sets
request.user, rotates the CSRF token, and sends theuser_logged_insignal.
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.
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:
{% 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):
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.
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.
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)
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.
Source: Django: Login and logout signals
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:
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")),
]
Source: Django: Authentication views
django.contrib.auth.urls provides these routes. The names are global (no namespace), which is why templates can say
{% url 'login' %}.
| URL | Name | View |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 to render, default |
|
The form class to use, default |
|
Where to go after login; default |
|
Name of the GET/POST field carrying the target, default |
|
|
|
Extra hosts, besides this one, that are safe redirect targets after login. Empty by default. |
|
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 |
|---|---|
|
|
|
|
|
|
The details of the flows follow in Changing and resetting passwords.
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
Source: Django: The test client and Authentication views
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
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_activeitself; 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 returnsTrueto allow. It does not check that the user is not anonymous; the staff test above is safe only becauseAnonymousUser.is_staffisFalse, and a test such aslambda u: u.email.endswith(…)would need its own guard. Passingredirect_field_name=Nonedrops thenextparameter, 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_requiredexists. -
Permission-based access (
permission_required,PermissionRequiredMixin) is covered on Permissions & Authorization. When you combineraise_exception=Truewith a login prompt, stack@login_requiredoutside 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 |
|---|---|
|
Where anonymous users are sent; |
|
Name of the query parameter (default |
|
|
|
Message handed to the 403 handler. |
|
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.
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")
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.
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_urlandredirect_field_nameof a@login_requireddecorator on the view, but it ignores the same attributes onLoginRequiredMixin(the middleware only sees the function thatas_view()returns). In the tests,/reviews/withredirect_field_name = "return_to"is redirected withnext, notreturn_to. -
Django’s own
LoginView,PasswordResetView,PasswordResetDoneView,PasswordResetConfirmView,PasswordResetCompleteViewand the admin login are already marked public.LogoutViewandPasswordChangeVieware 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.
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
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.
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.
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})
{% 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.
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.
{% 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 %}
Source: Django: PasswordChangeView
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:
-
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 subclassPasswordResetForm; 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). -
password_reset_done: "check your inbox". -
password_reset_confirm: the link in the e-mail carriesuidb64(the user id) and a token. On the firstGETthe view validates the token, stores it in the session and redirects to the same URL with the token replaced byset-password(reset_url_token), so the real token never appears in a page that aRefererheader could leak. The template getsvalidlinkandform. -
password_reset_complete: "your password was changed", with a link to sign in (or sign the user in directly withpost_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.
{% 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.
{% 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:
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 currentUseror anAnonymousUser; useuser.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.
<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.useris lazy and synchronous: reading an attribute of it from anasync defview hits the database and raisesSynchronousOnlyOperation. Useuser = await request.auser(). -
request.auseris 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 throughsync_to_asyncfor busy servers or do it in a background task. -
@login_required,@permission_requiredand@user_passes_testdecorateasync defviews 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.
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.
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 acceptsbackend=). Use it for every test that is not about logging in. -
self.client.login(username=…, password=…)goes throughauthenticate()and the real backends, and returnsFalseon 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").
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_URLleft at/accounts/profile/, a page that does not exist: the first login ends in a 404. Set it. -
LOGIN_URLset to a name that is not in the URLconf, or thedjango.contrib.auth.urlsinclude not mounted:login_requiredredirects into a 404. -
LoginRequiredMixinnot leftmost, or code beforesuper().dispatch(): the check is skipped. -
Redirecting to a raw
nextvalue: an open redirect. Useurl_has_allowed_host_and_scheme()or the built-in views. -
redirect_authenticated_user=Truewithpermission_requiredand noraise_exception=True: a redirect loop. -
LoginRequiredMiddlewareon and the sign-up view, the health check, the webhook or the token API not markedlogin_not_required. -
Expecting the middleware to honour
LoginRequiredMixin.login_urlorredirect_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 raisesTypeError. -
Reading
request.userattributes in an async view instead ofawait request.auser(). -
Exposing
is_staff,is_superuseror"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_TIMEOUTleft at three days, orALLOWED_HOSTS = ["*"], so a forgedHostheader can poison the reset link. -
Reading
from django.contrib.auth.models import Userin reusable code instead ofget_user_model(). -
Hard-coded test passwords and
SECRET_KEYin the repository: read them from the environment or generate them at runtime. -
Writing the
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECUREandSESSION_COOKIE_HTTPONLYsettings off: runpython manage.py check --deploybefore releases. -
Changing
AUTH_USER_MODELafter the first migration without a plan: do it beforemigrateon day one.
Where the books are outdated
| You may read | Today |
|---|---|
|
Deprecated in 1.11 and removed in 2.1. Use the class-based |
|
Became a property in 1.10 and the old call syntax stopped working in 2.0; write |
|
Removed in 2.0; the behaviour is always on and |
A logout link such as |
Logging out through |
|
Removed in 4.0; use |
|
Still works, but |
Every view needs |
|
|
Replaced by |
A |
The mixin must be leftmost and |
Wrapping every auth call in |
|
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. |
|
Django 6.1 new projects get |
What changed recently
| Version | Change |
|---|---|
1.11 |
|
3.1 |
|
4.0 |
|
4.1 |
Logging out via |
4.2 |
|
5.0 |
|
5.1 |
|
5.2 (LTS) |
|
6.0 |
PBKDF2 default iterations raised to 1,200,000. |
6.1 |
The |