ZetCode

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.

config/settings.py
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.

config/settings.py (authentication settings)
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.

demos/users.py
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.

demos/hashing.py
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.

demos/validators.py
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.

config/urls.py
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.

demos/urls.py
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.

members/templates/registration/login.html
{% 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.

members/templates/members/base.html
<!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.

members/views.py (protected views)
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.

members/urls.py
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.

members/templates/members/dashboard.html
{% 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.

members/templates/members/home.html
{% 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.

members/templates/members/profile.html
{% 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.

demos/flow.py
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.

members/views.py (signup)
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.

members/templates/registration/signup.html
{% 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.

demos/signup.py
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.

members/tests.py
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.