Skip to main content

🔐 Django Authentication System

Almost every real app needs to answer two questions: who is this? and what are they allowed to do? Django ships a battle-tested answer to both — secure password hashing, sessions, users, groups, and permissions — so you build features instead of reinventing security.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Distinguish authentication (who you are) from authorization (what you may do)
  • Create users safely and explain why to define a custom user model from day one
  • Wire up login, logout, and registration using Django's built-in views and forms
  • Protect views with @login_required, permission_required, and CBV mixins
  • Model roles with groups and permissions, and enforce object-level ownership
  • Apply core security settings for passwords, sessions, and HTTPS

Estimated Time: 55–70 minutes  •  Difficulty: Intermediate

Hands-on: Add registration, login, and a login-protected profile page to a small app.

In This Lesson

Authentication vs. Authorization

These two words are easy to blur, but the distinction runs through everything in this lesson:

  • Authentication proves identity — "you are who you claim to be." Logging in with a password is authentication.
  • Authorization checks permission — "you're allowed to do this." Deciding whether you can delete an article is authorization.
💡 The office-building analogy: Authentication is showing your badge at the front desk to get inside. Authorization is whether that badge opens the server-room door. You can be authenticated (inside the building) yet not authorized (locked out of a room).
flowchart TD A[User submits credentials] --> B{Authentication} B -->|Fail| C[Login error] B -->|Success| D[Session created] D --> E{Authorization} E -->|Has permission| F[Access granted] E -->|No permission| G[403 Forbidden]

📖 The pieces Django gives you

User & Group models for identities and roles; permissions for granular rights; secure password hashing; and cookie-backed sessions that remember a logged-in user between requests.

The User Model

The default User model stores a username, hashed password, email, names, the is_active/is_staff/is_superuser flags, and join/login timestamps.

Creating users the safe way

from django.contrib.auth import get_user_model, authenticate

User = get_user_model()

# ALWAYS use create_user — it hashes the password. Never User.objects.create().
user = User.objects.create_user(
    username="ada",
    email="ada@example.com",
    password="a-strong-passphrase",
)

# Verifying credentials returns the user or None
who = authenticate(username="ada", password="a-strong-passphrase")
if who is not None:
    ...  # credentials good

⚠️ Never store or set a raw password

create_user() and set_password() hash the password (PBKDF2-SHA256 by default). User.objects.create(password="...") stores it in plain text — a serious breach waiting to happen. And always call get_user_model() instead of importing User directly, so your code works with a custom user model.

Define a custom user model on day one

Django strongly recommends a custom user model for every new project — even one that starts identical to the default. Swapping it later, once tables and foreign keys exist, is painful.

💡 Buy the roomier suit: A custom user model is like buying a suit with a little extra room. You may not need the space today, but altering it later is far cheaper than replacing the whole thing after your database is full of related rows.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    bio = models.TextField(blank=True)
    birth_date = models.DateField(null=True, blank=True)
# settings.py — set this BEFORE the first migration
AUTH_USER_MODEL = "accounts.User"

Logging in with email instead of username

To drop usernames entirely, subclass AbstractUser, remove username, make email the identifier, and supply a manager:

# accounts/models.py
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db import models


class UserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra):
        if not email:
            raise ValueError("Users must have an email address.")
        user = self.model(email=self.normalize_email(email), **extra)
        user.set_password(password)     # hashes it
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra):
        extra.setdefault("is_staff", True)
        extra.setdefault("is_superuser", True)
        return self.create_user(email, password, **extra)


class User(AbstractUser):
    username = None
    email = models.EmailField("email address", unique=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []                 # email & password are prompted automatically

    objects = UserManager()

Login, Logout & Registration

Django ships class-based views for login, logout, and the whole password-reset flow. You supply templates; Django supplies the logic.

Built-in auth views

# accounts/urls.py
from django.contrib.auth import views as auth_views
from django.urls import path

urlpatterns = [
    path("login/",  auth_views.LoginView.as_view(template_name="accounts/login.html"), name="login"),
    path("logout/", auth_views.LogoutView.as_view(), name="logout"),

    # Forgot-password flow (needs email configured)
    path("password-reset/", auth_views.PasswordResetView.as_view(), name="password_reset"),
    path("password-reset/done/", auth_views.PasswordResetDoneView.as_view(), name="password_reset_done"),
    path("reset/<uidb64>/<token>/", auth_views.PasswordResetConfirmView.as_view(), name="password_reset_confirm"),
    path("reset/done/", auth_views.PasswordResetCompleteView.as_view(), name="password_reset_complete"),
]

💡 Logout must be POST

Since Django 5, LogoutView only accepts POST (a GET link could be triggered by an image tag — a CSRF risk). Render a tiny form: <form method="post" action="{% url 'logout' %}">{% csrf_token %}<button>Log out</button></form>. Set LOGIN_REDIRECT_URL and LOGOUT_REDIRECT_URL in settings to control where users land.

Registration

Django has no built-in signup view, but UserCreationForm does the heavy lifting — including running the password validators:

# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import get_user_model

User = get_user_model()


class RegisterForm(UserCreationForm):
    email = forms.EmailField(required=True)

    class Meta:
        model = User
        fields = ["username", "email", "password1", "password2"]


# accounts/views.py
from django.contrib.auth import login
from django.shortcuts import render, redirect
from .forms import RegisterForm


def register(request):
    form = RegisterForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        user = form.save()
        login(request, user)             # log them in right away
        return redirect("home")
    return render(request, "accounts/register.html", {"form": form})

Sessions

HTTP is stateless — each request arrives with no memory of the last. Sessions bridge that gap: on login, Django stores session data server-side and hands the browser a signed cookie holding only the session key. Every later request presents that cookie, and Django re-loads who's logged in.

sequenceDiagram participant B as Browser participant V as Django View participant S as Session Store B->>V: POST login (username, password) V->>V: authenticate() V->>S: create session S-->>V: session key V-->>B: Set-Cookie: sessionid B->>V: next request + cookie V->>S: look up session S-->>V: request.user

Reading and writing session data

# A guest shopping cart, kept in the session
def add_to_cart(request, product_id):
    cart = request.session.setdefault("cart", {})
    cart[str(product_id)] = cart.get(str(product_id), 0) + 1
    request.session.modified = True      # required when mutating nested data
    return redirect("cart")

⚠️ Mutating nested session data needs a nudge

Django auto-saves the session when you assign a key directly (request.session["x"] = 1). But when you mutate a dict or list inside the session, it can't tell — set request.session.modified = True so the change is persisted.

Permissions & Protecting Views

For every model, Django auto-creates four permissions: add_, change_, delete_, and view_. You can add your own in the model's Meta:

class Article(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

    class Meta:
        permissions = [
            ("publish_article", "Can publish articles"),
        ]

Protecting function-based views

from django.contrib.auth.decorators import login_required, permission_required


@login_required
def profile(request):
    return render(request, "accounts/profile.html")


@permission_required("blog.publish_article", raise_exception=True)
def publish(request, pk):
    article = get_object_or_404(Article, pk=pk)
    article.status = "published"
    article.save()
    return redirect("article_detail", pk=pk)

💡 raise_exception=True

By default permission_required redirects a logged-in-but-unauthorized user to the login page — confusing, since they're already logged in. Passing raise_exception=True returns a proper 403 Forbidden instead.

Protecting class-based views

from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import UpdateView


class ArticleUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
    model = Article
    fields = ["title", "content"]
    permission_required = "blog.change_article"
    raise_exception = True

Checking permissions in templates

{% if perms.blog.publish_article %}
    <a href="{% url 'publish' article.pk %}">Publish</a>
{% endif %}

{% if user.is_authenticated %}
    <span>Hi, {{ user.get_username }}</span>
{% else %}
    <a href="{% url 'login' %}">Log in</a>
{% endif %}

Groups & Roles

Assigning permissions to individual users doesn't scale. Instead, bundle permissions into groups and put users in groups — this is role-based access control (RBAC).

Role-based access control Users belong to groups; groups carry permissions. Users inherit their groups' permissions and can also have direct ones. Users Groups (roles) Permissions member of grants users may also have direct permissions
Figure 2 — Users inherit permissions through their groups (and may carry extra direct permissions). Change a group once and every member updates.
from django.contrib.auth.models import Group, Permission

editors, _ = Group.objects.get_or_create(name="Editors")
publish = Permission.objects.get(codename="publish_article")
editors.permissions.add(publish)

user.groups.add(editors)                 # user now inherits "publish_article"

if user.groups.filter(name="Editors").exists():
    ...  # user is an editor

Real example: a news site with Writers (edit their own drafts), Editors (edit and publish anything), and Admins (manage users). Groups and their permissions are also fully manageable through the Django admin.

Object-Level Access

Model permissions are all-or-nothing: "can change any article." Often you want "can change my own article." That's object-level access, and the simplest approach is an explicit ownership check.

from django.core.exceptions import PermissionDenied


@login_required
def edit_article(request, pk):
    article = get_object_or_404(Article, pk=pk)
    if article.author != request.user:
        raise PermissionDenied           # → 403
    # ... proceed with the edit

In class-based views, filter the queryset so users simply can't reach rows they don't own (a missing row yields a clean 404):

class ArticleUpdateView(LoginRequiredMixin, UpdateView):
    model = Article
    fields = ["title", "content"]

    def get_queryset(self):
        return Article.objects.filter(author=self.request.user)

💡 When to reach for a package

For complex per-object rules across many users (sharing, teams, granular grants), a library like django-guardian stores per-object permissions in the database. For the common "owner-only" case, the checks above are simpler and faster — don't add the dependency until you need it.

Security Best Practices

Enforce strong passwords

# settings.py
AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     "OPTIONS": {"min_length": 10}},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

Secure cookies and HTTPS (production)

# settings.py — production
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True             # cookies only over HTTPS
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True           # JS can't read the session cookie
SECURE_HSTS_SECONDS = 31_536_000         # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

✅ Security checklist

  • Always create/change passwords via create_user() / set_password().
  • Keep SECRET_KEY out of source control (use environment variables).
  • Throttle login attempts (e.g. django-axes) to blunt brute-force attacks.
  • Consider two-factor auth for privileged accounts.
  • Run python manage.py check --deploy before shipping.

⚠️ Client-side validation is not security

Anything checked only in the browser can be bypassed with a crafted request. Every permission and validation rule that matters must be enforced on the server.

Hands-on Exercise

🏋️ Add accounts to a small app

Objective: Give an existing app registration, login/logout, and a profile page only logged-in users can see.

Requirements

  1. A register view using UserCreationForm that logs the new user in on success.
  2. Login and logout wired to Django's built-in views (remember: logout is POST).
  3. A profile view decorated with @login_required.
  4. LOGIN_REDIRECT_URL and LOGIN_URL set in settings.
💡 Hint

The unauthenticated redirect for @login_required comes from LOGIN_URL (or the login URL name). After login, Django honours a ?next= query param, falling back to LOGIN_REDIRECT_URL.

✅ Sample solution
# accounts/views.py
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import render, redirect


def register(request):
    form = UserCreationForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        user = form.save()
        login(request, user)
        return redirect("profile")
    return render(request, "accounts/register.html", {"form": form})


@login_required
def profile(request):
    return render(request, "accounts/profile.html")
# accounts/urls.py
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views

urlpatterns = [
    path("register/", views.register, name="register"),
    path("profile/",  views.profile,  name="profile"),
    path("login/",    auth_views.LoginView.as_view(
        template_name="accounts/login.html"), name="login"),
    path("logout/",   auth_views.LogoutView.as_view(), name="logout"),
]
# settings.py
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "profile"
LOGOUT_REDIRECT_URL = "login"

Summary & Quiz

🎉 Key Takeaways

  • Authentication proves identity; authorization checks permission — keep them distinct.
  • Create users with create_user() (it hashes passwords); reference the model via get_user_model().
  • Define a custom user model before your first migration — changing it later is painful.
  • Django's built-in views cover login, logout, and password reset; you just add templates.
  • Protect views with @login_required, permission_required, and CBV mixins; model roles with groups.
  • Enforce ownership for object-level access, and lock down passwords, cookies, and HTTPS in production.

🎯 Quick Quiz

Question 1: Which method creates a user with a properly hashed password?

Question 2: You want a logged-in but unauthorized user to get a 403 rather than being bounced to the login page. What do you add?

Question 3: What is the recommended way to manage permissions for many users with the same role?

📚 Further Reading

🚀 What's Next?

You've covered Django's server-rendered core. Next we shift toward APIs: building RESTful services with Flask-RESTful, where authentication and validation reappear in a lighter, API-first framework.

🎉 Excellent work!

You can now sign users in, keep them logged in, and control what they may do. On to REST APIs.