Authentication & Permissions
Members of the Jaipur City Library should log in to see their loans, and only librarians should see the stock report. Django ships a complete authentication system in django.contrib.auth: a User model, secure password hashing, login and logout views, password change and reset, plus permissions and groups for deciding who may do what.
In this lesson you will create users, check passwords, plug in Django's ready-made login views, protect pages with @login_required, build a sign-up page, and restrict a page to staff with permissions. You will also see why logout is a POST request and why new projects should start with a custom user model.
Users and passwords
The built-in User model has username, email, first_name, last_name, password, and the flags is_active, is_staff and is_superuser. Create users with create_user(), which hashes the password; never assign user.password = "..." directly.
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
priya = User.objects.create_user("priya", "priya@example.com", "Hawa-Mahal-77")
print(priya.password[:30] + "...")
print(priya.is_staff, priya.is_superuser, priya.check_password("wrong"))
print(authenticate(username="priya", password="Hawa-Mahal-77"))
print(authenticate(username="priya", password="hawa-mahal-77"))pbkdf2_sha256$1500000$Y3kcBhOf... False False False priya None
The stored value names the algorithm (PBKDF2 with SHA-256), the number of iterations and a random salt, followed by the hash. Django 6.1 raised the default to 1,500,000 iterations, which makes stolen hashes slow to crack; when a user logs in, Django quietly re-hashes old passwords with the new setting. authenticate() returns the user when the username and password match, and None otherwise (passwords are case-sensitive).
Login and logout with built-in views
Django provides class-based views for login, logout, password change and password reset. Include them all with one line:
# config/urls.py
urlpatterns = [
# ...
path("accounts/", include("django.contrib.auth.urls")),
path("accounts/", include("accounts.urls")),
]That adds these URL names: login, logout, password_change, password_change_done, password_reset, password_reset_done, password_reset_confirm and password_reset_complete. You supply the templates; the login view looks for registration/login.html:
<!-- templates/registration/login.html -->
{% extends "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 %}Tell Django where to send people in settings.py:
LOGIN_URL = "login" # where @login_required sends visitors
LOGIN_REDIRECT_URL = "accounts:dashboard" # after logging in
LOGOUT_REDIRECT_URL = "catalog:index" # after logging outLogging out must be a POST request with a CSRF token. Since Django 5.0, GET /accounts/logout/ returns 405 Method Not Allowed, because a GET logout could be triggered by any image tag on another site. Use a small form:
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button>Log out</button>
</form>Protecting views
# accounts/views.py
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required, permission_required
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)
messages.success(request, f"Welcome, {user.username}!")
return redirect("accounts:dashboard")
return render(request, "registration/signup.html", {"form": form})
@login_required
def dashboard(request):
return render(request, "accounts/dashboard.html")
@permission_required("catalog.change_book", raise_exception=True)
def stock_report(request):
return render(request, "accounts/stock.html")@login_required redirects anonymous visitors to the login page and remembers where they wanted to go in ?next=. UserCreationForm asks for a username and the password twice and runs the password validators. login(request, user) starts a session for the new user.
In templates, user and perms are always available thanks to context processors:
<p>Hello, {{ user.get_username }}!</p>
{% if perms.catalog.change_book %}<a href="{% url 'accounts:stock' %}">Stock report</a>{% endif %}Here is the whole flow, driven by the test client:
GET /accounts/dashboard/ (anonymous) -> 302 /accounts/login/?next=/accounts/dashboard/ POST /accounts/login/ (priya) -> 302 /accounts/dashboard/ GET /accounts/dashboard/ -> 200 Hello, priya! (no stock link) GET /accounts/stock/ -> 403 GET /accounts/logout/ -> 405 POST /accounts/logout/ -> 302 /books/ POST /accounts/signup/ (password 12345678) -> 200 This password is too common. This password is entirely numeric. POST /accounts/signup/ (strong password) -> 302 /accounts/dashboard/
Permissions and groups
For every model, Django creates four permissions: add_book, change_book, delete_book and view_book, written in full as "catalog.change_book". Give permissions to groups, then put users in groups, so you manage roles rather than individual people:
from django.contrib.auth.models import Group, Permission
librarians, _ = Group.objects.get_or_create(name="Librarians")
librarians.permissions.add(Permission.objects.get(codename="change_book"))
priya.groups.add(librarians)
priya = User.objects.get(pk=priya.pk) # reload: permissions are cached on the object
print(priya.has_perm("catalog.change_book"), priya.has_perm("catalog.delete_book"))True False
After joining the group, Priya's dashboard shows the stock link and /accounts/stock/ returns 200 instead of 403. Superusers pass every permission check. You can manage groups in the admin too. For rules that depend on the object ("members may edit only their own reviews"), check in the view: if review.member != request.user: raise PermissionDenied.
Start with a custom user model
Changing the user model in the middle of a project is painful, because many tables point to it. The Django documentation recommends that every new project starts with a custom user model, even an empty one, so you can add fields like a phone number later:
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
phone = models.CharField(max_length=10, blank=True)
# config/settings.py
AUTH_USER_MODEL = "accounts.User"Set AUTH_USER_MODEL before the first migrate. Elsewhere, refer to the user model through settings.AUTH_USER_MODEL in foreign keys and get_user_model() in code, never by importing User directly.
Common mistakes
- Setting
user.password = "secret". That stores plain text and logging in fails; usecreate_user()orset_password(). - A logout link (
<a href>). Since Django 5.0 it gives 405; use a POST form with{% csrf_token %}. - Hiding a link but not protecting the view.
{% if perms... %}only hides the link; the view still needs@permission_requiredor a check. - Switching to a custom user model late. Decide on day one; set
AUTH_USER_MODELbefore the first migration. - Trusting
nextblindly in your own login code. Redirecting to any URL innextlets attackers send users to fake sites; Django'sLoginViewalready checks it is a safe, same-site URL.
Key points
django.contrib.authprovides users, hashed passwords, sessions, login/logout views, permissions and groups.- Create users with
create_user(); check credentials withauthenticate(); start a session withlogin(). include("django.contrib.auth.urls")adds login, logout and password views; templates live inregistration/.@login_requiredand@permission_requiredprotect views; logout must be POST.- Give permissions to groups, and start every new project with a custom user model in
AUTH_USER_MODEL.
Exercise
Add a "My profile" page at /accounts/profile/ that only logged-in users can open. It should show the username, the date they joined (user.date_joined with the date filter) and the names of the groups they belong to, or "No groups" if there are none. Then add a link to it on the dashboard.
Show solution
# accounts/views.py
@login_required
def profile(request):
groups = request.user.groups.values_list("name", flat=True)
return render(request, "accounts/profile.html", {"groups": groups})
# accounts/urls.py
# path("profile/", views.profile, name="profile"),<!-- accounts/templates/accounts/profile.html -->
{% extends "base.html" %}
{% block content %}
<h1>{{ user.get_username }}</h1>
<p>Member since {{ user.date_joined|date:"d M Y" }}</p>
<p>Groups: {{ groups|join:", "|default:"No groups" }}</p>
{% endblock %}<h1>priya</h1> <p>Member since 30 Oct 2026</p> <p>Groups: Librarians</p>
@login_required keeps anonymous visitors out. join turns the group names into one string, and default covers users without groups because an empty join gives an empty string.
Quick Quiz
1. How should you create a user so the password is hashed?
create_user() calls set_password(), which stores a salted hash.
2. What happens when an anonymous visitor opens a view decorated with @login_required?
The decorator sends them to LOGIN_URL and remembers the page they wanted.
3. Why does GET /accounts/logout/ return 405 in Django 5.0 and later?
Changing state with GET is unsafe, so Django's LogoutView accepts only POST.
4. Where is the best place to grant "can change books" to several librarians?
Groups keep roles in one place and follow the principle of least privilege.
Learn Django & Flask with a mentor
Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
