Django authentication
last modified November 13, 2026 Python 3.15
In this article we add authentication to a Django project. We create users and check their
passwords, see how Django stores and validates passwords, wire up the built-in login and logout views, protect views from
anonymous users with login_required and LoginRequiredMixin, write a sign-up page, and test all of it. The examples use
Django 6.1 and Python 3.15.
Authentication in Django
The package django.contrib.auth is a part of every project generated by
startproject. It provides the User model with the usernames, the hashed passwords, and the flags
is_active, is_staff, and is_superuser; the function authenticate(), which
checks credentials; the functions login() and logout(); and ready-made views and forms for the common pages. After
a successful login, Django stores the id of the user in the session, which is saved in the database and
identified by the cookie sessionid. The AuthenticationMiddleware, which is in the
MIDDLEWARE setting, reads the session on every request and sets request.user to the User
object, or to AnonymousUser when nobody is logged in. In templates the same object is available as
user. Authentication answers the question who the user is; what the user may do is
authorization, which is built on permissions and groups.
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, start the app members, and create the folder demos for demo scripts.
$ mkdir authentication $ cd authentication $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp members $ mkdir demos
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the app to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'members',
]
The listings in this article are of two kinds. A listing with a solid blue border and the label
Project file is a file of the application: models, views, templates, tests, and
settings. A listing with a dashed amber border and the label Demo script is a small
helper in the demos folder; it sets up Django, runs a few lines of code, and prints
the results, so that we can watch the behaviour. Demo scripts are not part of the application;
we run one from the project directory with uv run python -m demos.name.
The app members has a public home page, a sign-up page, and two pages for logged-in users. Three settings tell Django where to send
the users: LOGIN_URL is the page to which it redirects an anonymous user who asks for a protected page,
LOGIN_REDIRECT_URL the page after a login without a next parameter, and
LOGOUT_REDIRECT_URL the page after a logout. They accept URL names. The default of the first is
/accounts/login/, and the default of the second is /accounts/profile/, a page that does not exist in
our project.
LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'members:dashboard' LOGOUT_REDIRECT_URL = 'members:home'
The tables of the auth and sessions apps are created by migrate. The
command createsuperuser creates a user who can log in to the admin site; with
--noinput it takes the password from the environment variable DJANGO_SUPERUSER_PASSWORD, which is
handy in scripts.
$ uv run python manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, sessions Running migrations: Applying contenttypes.0001_initial... OK Applying auth.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying auth.0010_alter_group_name_max_length... OK Applying auth.0011_update_proxy_permissions... OK Applying auth.0012_alter_user_first_name_max_length... OK Applying sessions.0001_initial... OK $ DJANGO_SUPERUSER_PASSWORD=admin-pass-123 uv run python manage.py createsuperuser --noinput --username admin --email admin@example.com Superuser created successfully.
Users and passwords
Users are created with User.objects.create_user(), which hashes the password, and
create_superuser(), which also sets is_staff and is_superuser. The password is never stored
in the clear: the column password contains a string of the form
algorithm$iterations$salt$hash. The method check_password() compares a candidate with it, and
set_password() replaces it. The function authenticate() takes the credentials and
returns the user, or None when they are wrong or the account is inactive.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
User.objects.all().delete()
user = User.objects.create_user('alice', 'alice@example.com', 'correct-horse-battery')
algorithm, iterations, salt, digest = user.password.split('$')
print('stored:', algorithm, iterations, f'salt={len(salt)} chars', f'hash={len(digest)} chars')
print('check_password:', user.check_password('correct-horse-battery'), user.check_password('guess'))
print('flags:', user.is_active, user.is_staff, user.is_superuser, user.last_login)
print('authenticate ok:', authenticate(username='alice', password='correct-horse-battery'))
print('authenticate wrong:', authenticate(username='alice', password='guess'))
User.objects.filter(username='alice').update(is_active=False)
print('authenticate inactive:', authenticate(username='alice', password='correct-horse-battery'))
admin = User.objects.create_superuser('admin', 'admin@example.com', 'admin-pass-123')
print('superuser:', admin.is_staff, admin.is_superuser)
oops = User.objects.create(username='oops', password='plain-text')
print('create() stores the value as it is:', oops.password)
print('check_password:', oops.check_password('plain-text'), '| usable:', oops.has_usable_password())
oops.set_password('now-hashed-properly')
oops.save()
print('after set_password:', oops.password.split('$')[0], oops.check_password('now-hashed-properly'))
User.objects.all().delete()
The script creates a user and prints the parts of the stored password: the algorithm, the number of
iterations, and the lengths of the salt and the hash. Only the right password passes check_password() and
authenticate(), and a user whose is_active is false cannot authenticate even with the right
password. last_login is empty until the user logs in. The last part shows a common mistake: the
manager method create() and the field assignment save the value as it is, without hashing, so the stored
password is the text we gave, and check_password() never accepts it. We must always use
create_user() or set_password().
$ uv run python -m demos.users stored: pbkdf2_sha256 1500000 salt=22 chars hash=44 chars check_password: True False flags: True False False None authenticate ok: alice authenticate wrong: None authenticate inactive: None superuser: True True create() stores the value as it is: plain-text check_password: False | usable: True after set_password: pbkdf2_sha256 True
Password hashing
A hash is a one-way function: from the stored value we cannot compute the password, but we can hash a
candidate in the same way and compare. To make guessing slow, Django uses a deliberately expensive algorithm with a
random salt for each password, so equal passwords have different hashes and precomputed tables are useless. The setting
PASSWORD_HASHERS is a list of the hasher classes: the first is used for new passwords, and the others only
verify the old ones. The default is PBKDF2 with SHA-256, and the number of iterations grows with each Django release.
Argon2, bcrypt, and scrypt are supported as well; Argon2 needs the extra django[argon2]. When a user logs in with a
password that was hashed by an older hasher or with fewer iterations, Django hashes it again with the current settings and saves
the new value; so the passwords improve as the users log in.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.hashers import identify_hasher, make_password
from django.contrib.auth.models import User
User.objects.all().delete()
print('preferred hasher:', settings.PASSWORD_HASHERS[0].rsplit('.', 1)[1])
user = User.objects.create(username='carol', password=make_password('old-password-1', hasher='pbkdf2_sha1'))
print('stored with:', identify_hasher(user.password).algorithm)
authenticate(username='carol', password='wrong')
user.refresh_from_db()
print('after a failed login:', identify_hasher(user.password).algorithm)
authenticate(username='carol', password='old-password-1')
user.refresh_from_db()
print('after a successful login:', identify_hasher(user.password).algorithm)
User.objects.all().delete()
The script stores a password hashed with the old PBKDF2-SHA1 hasher, which is in the list but is not the first one. A failed login leaves the hash as it is, because Django knows the password only when the check succeeds. After the successful login, the stored value uses PBKDF2-SHA256.
$ uv run python -m demos.hashing preferred hasher: PBKDF2PasswordHasher stored with: pbkdf2_sha1 after a failed login: pbkdf2_sha1 after a successful login: pbkdf2_sha256
Password validation
Strong passwords are enforced by the validators of the setting AUTH_PASSWORD_VALIDATORS, which
startproject fills with four of them: the password must not be similar to the user's attributes, it must have at least 8
characters, it must not be on the list of common passwords, and it must not be only digits. The validators run in the forms
of the auth package, such as the sign-up and the password change forms, but not when we call create_user() or
set_password(); we call validate_password() ourselves when we need it.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import (
password_validators_help_texts, validate_password)
from django.core.exceptions import ValidationError
for text in password_validators_help_texts():
print('-', text)
print()
user = User(username='alice', email='alice@example.com')
for password in ('alice2026', '12345678', 'password', 'short', 'purple-monkey-dishwasher'):
try:
validate_password(password, user)
print(f'{password!r}: ok')
except ValidationError as error:
print(f'{password!r}:', error.messages)
The first lines are the help texts of the validators, which forms show to the user. The function gets the password
and, optionally, the user whose attributes are compared. It raises a ValidationError with all messages, so a password such as
12345678 is rejected for two reasons.
$ uv run python -m demos.validators - Your password can’t be too similar to your other personal information. - Your password must contain at least 8 characters. - Your password can’t be a commonly used password. - Your password can’t be entirely numeric. 'alice2026': ['The password is too similar to the username.'] '12345678': ['This password is too common.', 'This password is entirely numeric.'] 'password': ['This password is too common.'] 'short': ['This password is too short. It must contain at least 8 characters.'] 'purple-monkey-dishwasher': ok
Authentication URLs
The package provides class-based views for the whole life cycle of an account, and a URL configuration for them, which we include with one line.
from django.urls import include, path
urlpatterns = [
path('accounts/', include('django.contrib.auth.urls')),
path('', include('members.urls')),
]
The prefix accounts/ is the default of LOGIN_URL. The script lists the views that the
included URL configuration defines.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth import urls
for pattern in urls.urlpatterns:
print(f'{str(pattern.pattern):28} {pattern.name:24} {pattern.callback.view_class.__name__}')
The names of the patterns are used in templates and in reverse(). The views for the password change and the
password reset need no templates of ours when the app django.contrib.admin is installed, because its
templates provide them; the reset sends the link by email (see Django email). The views
LoginView and LogoutView are the ones that we use next.
$ uv run python -m demos.urls login/ login LoginView logout/ logout LogoutView password_change/ password_change PasswordChangeView password_change/done/ password_change_done PasswordChangeDoneView password_reset/ password_reset PasswordResetView password_reset/done/ password_reset_done PasswordResetDoneView reset/<uidb64>/<token>/ password_reset_confirm PasswordResetConfirmView reset/done/ password_reset_complete PasswordResetCompleteView
Login and logout pages
The login view renders the template registration/login.html, which we must write. The view puts the form
AuthenticationForm into the context as form, and the target of the redirect after the login as
next; the hidden field passes it back to the view.
{% extends 'members/base.html' %}
{% block content %}
<h1>Log in</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Log in</button>
</form>
{% endblock %}
When the credentials are right, the view calls login(), which stores the user in the session, and redirects to
next, or to LOGIN_REDIRECT_URL if there is none. Django checks that next points to our own
site, so a link cannot send the user to another domain. A logout is an action that changes the state, so LogoutView accepts only
POST requests; since Django 5.0 a GET request is answered with 405. The log out button of a page is therefore a small form.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}Members{% endblock %}</title>
</head>
<body>
<nav>
<a href="{% url 'members:home' %}">Home</a>
{% if user.is_authenticated %}
<a href="{% url 'members:dashboard' %}">Dashboard</a>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit">Log out ({{ user.username }})</button>
</form>
{% else %}
<a href="{% url 'login' %}">Log in</a>
<a href="{% url 'members:signup' %}">Sign up</a>
{% endif %}
</nav>
{% block content %}{% endblock %}
</body>
</html>
The base template uses the variable user, which the context processor
django.contrib.auth.context_processors.auth adds to every template rendered with a request. The attribute
is_authenticated is true for a logged-in user and false for AnonymousUser; the navigation shows the
login and sign-up links to the visitors, and the dashboard link and the logout button to the users.
Protecting views
The decorator login_required protects a function view. If the user is not authenticated, it redirects to
LOGIN_URL and puts the requested path into the next parameter. For a class-based view,
the mixin LoginRequiredMixin does the same; it must be the leftmost base class, so that its check runs
first. Inside a protected view, request.user is a real user.
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, render
from django.views.generic import TemplateView
def home(request):
return render(request, 'members/home.html')
@login_required
def dashboard(request):
return render(request, 'members/dashboard.html')
class ProfileView(LoginRequiredMixin, TemplateView):
template_name = 'members/profile.html'
The home page is public, and the dashboard and the profile are protected, the first with the decorator and the second with the mixin. The URLs of the app give them the names that the templates use.
from django.urls import path
from . import views
app_name = 'members'
urlpatterns = [
path('', views.home, name='home'),
path('signup/', views.signup, name='signup'),
path('dashboard/', views.dashboard, name='dashboard'),
path('profile/', views.ProfileView.as_view(), name='profile'),
]
The dashboard greets the user by name and prints the date of the registration.
{% extends 'members/base.html' %}
{% block content %}
<h1>Dashboard</h1>
<p>Hello, {{ user.username }}!</p>
<p>Joined: {{ user.date_joined|date:"Y-m-d" }}</p>
{% endblock %}
The two other pages are minimal: the public home page, and the profile page that only a logged-in user can open.
{% extends 'members/base.html' %}
{% block content %}
<h1>Members</h1>
<p>A public page that everybody can see.</p>
{% endblock %}
The home page has a heading and one sentence. The profile page, which the view ProfileView renders, prints the
name of the user.
{% extends 'members/base.html' %}
{% block content %}
<h1>Profile of {{ user.username }}</h1>
{% endblock %}
The next script follows a visitor through the site with the test client. An anonymous request for a protected page is
redirected to the login page, with the path in the next parameter. A wrong password gives the form with the error
for the whole form. After a right one, the client is redirected to the page from next, and the session
has three keys: the id of the user, the dotted path of the backend that authenticated the user, and a hash of the password, which
invalidates the session when the password is changed. The dashboard is now available. The logout accepts only POST, and
after it the session is empty and the dashboard is protected again. The last line of the output is the log message of the rejected GET
request, written to the standard error stream.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.contrib.auth.models import User
User.objects.all().delete()
User.objects.create_user('alice', password='correct-horse-battery')
client = Client()
for url in ('/', '/dashboard/', '/profile/'):
response = client.get(url)
print(f'GET {url}:', response.status_code, response.get('Location', ''))
print('GET /accounts/login/:', client.get('/accounts/login/').status_code)
response = client.post('/accounts/login/', {'username': 'alice', 'password': 'guess'})
print('wrong password:', response.status_code, response.context['form'].non_field_errors())
response = client.post('/accounts/login/', {
'username': 'alice', 'password': 'correct-horse-battery', 'next': '/profile/'})
print('login with next:', response.status_code, response['Location'])
print('session keys:', sorted(client.session.keys()))
response = client.get('/dashboard/')
print('GET /dashboard/:', response.status_code, 'Hello, alice!' in response.content.decode())
print('GET /accounts/logout/:', client.get('/accounts/logout/').status_code)
response = client.post('/accounts/logout/')
print('POST /accounts/logout/:', response.status_code, response['Location'])
print('session keys:', sorted(client.session.keys()))
print('GET /dashboard/:', client.get('/dashboard/').status_code)
User.objects.all().delete()
Note that the home page, which is public, answers with the status 200 and no redirect, whereas the two protected pages send the anonymous client to the login page.
$ uv run python -m demos.flow GET /: 200 GET /dashboard/: 302 /accounts/login/?next=/dashboard/ GET /profile/: 302 /accounts/login/?next=/profile/ GET /accounts/login/: 200 wrong password: 200 <ul class="errorlist nonfield"><li>Please enter a correct username and password. Note that both fields may be case-sensitive.</li></ul> login with next: 302 /profile/ session keys: ['_auth_user_backend', '_auth_user_hash', '_auth_user_id'] GET /dashboard/: 200 True GET /accounts/logout/: 405 POST /accounts/logout/: 302 / session keys: [] GET /dashboard/: 302 Method Not Allowed (GET): /accounts/logout/
Signing up
The auth package has no sign-up view, but it has the form UserCreationForm, a model form for the user
with the fields username, password1, and password2, which checks that the passwords
match, runs the password validators, and hashes the password in save(). The view saves the user and logs them in at
once with login(), so that they do not have to enter the password again.
from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import redirect, render
...
def signup(request):
form = UserCreationForm(request.POST or None)
if request.method == 'POST' and form.is_valid():
user = form.save()
login(request, user)
return redirect('members:dashboard')
return render(request, 'registration/signup.html', {'form': form})
The template renders the form in the same way as the login page.
{% extends 'members/base.html' %}
{% block content %}
<h1>Sign up</h1>
<form method="post">
{% csrf_token %}
{{ form }}
<button type="submit">Create account</button>
</form>
{% endblock %}
The script posts a password that is too common, passwords that do not match, a valid pair, and the same user name for the
second time from another client. The valid post redirects to the dashboard, and the same client can open it, because
login() was called. The user form takes care of the uniqueness of the user name.
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.test import Client
from django.test.utils import setup_test_environment
setup_test_environment()
from django.contrib.auth.models import User
User.objects.all().delete()
client = Client()
weak = {'username': 'bob', 'password1': 'bob12345', 'password2': 'bob12345'}
response = client.post('/signup/', weak)
print('weak:', response.status_code, dict(response.context['form'].errors))
mismatch = {'username': 'bob', 'password1': 'purple-monkey-dishwasher', 'password2': 'purple-monkey'}
response = client.post('/signup/', mismatch)
print('mismatch:', response.status_code, dict(response.context['form'].errors))
good = {'username': 'bob', 'password1': 'purple-monkey-dishwasher',
'password2': 'purple-monkey-dishwasher'}
response = client.post('/signup/', good)
print('valid:', response.status_code, response['Location'])
print('logged in:', client.get('/dashboard/').status_code, User.objects.get().username)
response = Client().post('/signup/', good)
print('duplicate:', response.status_code, dict(response.context['form'].errors))
User.objects.all().delete()
The form reports each problem with the field that it belongs to; the password validators are reported with the
password2 field. Only the valid request creates a user.
$ uv run python -m demos.signup
weak: 200 {'password2': ['This password is too common.']}
mismatch: 200 {'password2': ['The two password fields didn’t match.']}
valid: 302 /dashboard/
logged in: 200 bob
duplicate: 200 {'username': ['A user with that username already exists.']}
Testing authentication
The test client keeps cookies, so it can log in. The method client.login() does the full login with
a password, and client.force_login(user) skips the password check and the slow hashing, which makes it the
better choice for tests that only need a logged-in user. The tests check the redirect of an anonymous user, the
protection of the class-based view, the dashboard of a logged-in user, the next parameter, a rejected password, the logout that
needs POST, and the sign-up, including the hash of the saved password.
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
class AccessTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user('alice', password='correct-horse-battery')
def test_anonymous_user_is_redirected_to_login(self):
url = reverse('members:dashboard')
response = self.client.get(url)
self.assertRedirects(response, f'{reverse("login")}?next={url}')
def test_mixin_protects_the_class_based_view(self):
response = self.client.get(reverse('members:profile'))
self.assertEqual(response.status_code, 302)
def test_logged_in_user_sees_the_dashboard(self):
self.client.force_login(self.user)
response = self.client.get(reverse('members:dashboard'))
self.assertContains(response, 'Hello, alice!')
def test_login_view_honors_next(self):
response = self.client.post(reverse('login'), {
'username': 'alice', 'password': 'correct-horse-battery',
'next': reverse('members:profile')})
self.assertRedirects(response, reverse('members:profile'))
def test_wrong_password_is_rejected(self):
response = self.client.post(reverse('login'), {'username': 'alice', 'password': 'guess'})
self.assertEqual(response.status_code, 200)
self.assertNotIn('_auth_user_id', self.client.session)
def test_logout_needs_post(self):
self.client.force_login(self.user)
self.assertEqual(self.client.get(reverse('logout')).status_code, 405)
self.client.post(reverse('logout'))
self.assertNotIn('_auth_user_id', self.client.session)
class SignupTests(TestCase):
def test_signup_creates_a_hashed_user_and_logs_in(self):
password = 'purple-monkey-dishwasher'
response = self.client.post(reverse('members:signup'), {
'username': 'bob', 'password1': password, 'password2': password})
self.assertRedirects(response, reverse('members:dashboard'))
user = User.objects.get(username='bob')
self.assertTrue(user.password.startswith('pbkdf2_sha256$'))
self.assertEqual(self.client.session['_auth_user_id'], str(user.pk))
The helper assertRedirects follows the redirect and checks the status of the target page too. The user is
created once for the class in setUpTestData(), because hashing a password is slow on purpose.
$ uv run python manage.py test Found 7 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ....... ---------------------------------------------------------------------- Ran 7 tests in 0.937s OK Destroying test database for alias 'default'...
Source
Django documentation: User authentication in Django
Django documentation: Using the Django authentication system
Django documentation: Password management in Django
In this article we have created users, seen how Django hashes and validates passwords, added the login and logout
views, protected views with login_required and LoginRequiredMixin, written a sign-up page, and tested the
flow. Related articles are Django forms, Django views, and
Django class-based views.
Author
List all Django tutorials.