EthicsComputerCourses
Join training +91 90532 10052

Authentication & Permissions

Django & Flask · Lesson 21 of 42 Advanced 7 min read Updated

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.

Python
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"))
Output
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:

Python
# 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:

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:

Python
LOGIN_URL = "login"                          # where @login_required sends visitors
LOGIN_REDIRECT_URL = "accounts:dashboard"    # after logging in
LOGOUT_REDIRECT_URL = "catalog:index"        # after logging out

Logging 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:

HTML
<form method="post" action="{% url 'logout' %}">
  {% csrf_token %}
  <button>Log out</button>
</form>

Protecting views

Python
# 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:

HTML
<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:

Output
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:

Python
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"))
Output
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:

Python
# 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; use create_user() or set_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_required or a check.
  • Switching to a custom user model late. Decide on day one; set AUTH_USER_MODEL before the first migration.
  • Trusting next blindly in your own login code. Redirecting to any URL in next lets attackers send users to fake sites; Django's LoginView already checks it is a safe, same-site URL.

Key points

  • django.contrib.auth provides users, hashed passwords, sessions, login/logout views, permissions and groups.
  • Create users with create_user(); check credentials with authenticate(); start a session with login().
  • include("django.contrib.auth.urls") adds login, logout and password views; templates live in registration/.
  • @login_required and @permission_required protect 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
Python
# 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"),
HTML
<!-- 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 %}
Output
<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?

2. What happens when an anonymous visitor opens a view decorated with @login_required?

3. Why does GET /accounts/logout/ return 405 in Django 5.0 and later?

4. Where is the best place to grant "can change books" to several librarians?

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