Django Authentication and Authorization

7 min read #Authentication #Django #Security

Introduction

Sooner or later, every web app has to answer two questions about each request: who is this? and are they allowed to do that? Get either one wrong and you have a security problem. Django ships with a mature system for both, built in and battle-tested, and the Django admin you use to write posts is built on top of it.

Two Questions, Two Words

These terms are often blurred together, so it helps to separate them:

  • Authentication answers "Who are you?" It means logging in, proving identity with a password or another credential, and remembering the user between requests.
  • Authorization answers "What are you allowed to do?" It means checking whether this user may view, edit, or delete a particular thing.

You always authenticate first. You can't decide what someone may do until you know who they are.

The Problem Django's Auth System Solves

Rolling your own login system is a classic way to ship vulnerabilities. You have to get all of this right:

  • Storing passwords safely, using slow, salted hashing
  • Managing sessions and cookies securely
  • Validating password strength, resetting forgotten passwords, and logging out cleanly
  • Modeling roles and permissions without scattering if user.name == "admin" checks everywhere

Django handles these with django.contrib.auth, which is enabled in every new project by default.

Authentication in Django

The User Model

Django provides a User model with a username, hashed password, email, and flags such as is_active, is_staff, and is_superuser. Passwords are never stored in plain text. By default, Django hashes them with PBKDF2 and a per-password salt, and it can upgrade stored hashes automatically over time.

A tip for new projects: define a custom user model before your first migration, even if it's empty. Changing it later is painful.

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


class User(AbstractUser):
    pass
# settings.py
AUTH_USER_MODEL = "accounts.User"

In the rest of your code, refer to it with get_user_model() or settings.AUTH_USER_MODEL, not by importing it directly.

Login and Logout

Django includes ready-made views and URLs for login, logout, and password reset:

# urls.py
from django.urls import include, path

urlpatterns = [
    path("accounts/", include("django.contrib.auth.urls")),
]
# settings.py
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"

You provide the templates (for example registration/login.html), and Django handles the rest. Since Django 5.0, logging out requires a POST request, so use a small form rather than a link:

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

Who Is Logged In?

Once a user is authenticated, request.user is available in every view, and user is available in every template:

{% if user.is_authenticated %}
  <p>Signed in as {{ user.username }}</p>
{% else %}
  <a href="{% url 'login' %}">Log in</a>
{% endif %}

For anonymous visitors, request.user is a special AnonymousUser object, so you can always call request.user.is_authenticated safely.

Authorization in Django

Permissions

For every model, Django automatically creates four permissions: add, change, delete, and view. For the blog's Post model, those are blog.add_post, blog.change_post, blog.delete_post, and blog.view_post.

You can assign them to individual users, or, better, to groups. A group such as "Editors" holds a set of permissions, and anyone in the group inherits them.

user.has_perm("blog.change_post")   # True or False

In templates, the perms variable gives you the same check:

{% if perms.blog.change_post %}
  <a href="{% url 'admin:blog_post_changelist' %}">Manage posts</a>
{% endif %}

Protecting Views

Function-based views use decorators:

from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404, render

from blog.models import Post


@login_required
@permission_required("blog.change_post", raise_exception=True)
def draft_preview(request, slug):
    # `objects` is unfiltered, so drafts are visible here.
    post = get_object_or_404(Post.objects, slug=slug)
    return render(request, "blog/post_detail.html", {"post": post})

Class-based views use mixins:

from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views.generic import ListView

from blog.models import Post


class DraftListView(PermissionRequiredMixin, ListView):
    permission_required = "blog.change_post"
    queryset = Post.objects.filter(is_published=False)
    template_name = "blog/draft_list.html"

What's happening:

  • Anonymous visitors are redirected to the login page
  • Logged-in users without the permission get a 403 Forbidden
  • Users with the permission see the page

Object-Level Checks

Django's permissions apply to a model ("can edit posts"), not to a specific object ("can edit this post"). If you need the second kind, such as authors editing only their own work, write the check yourself:

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


class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
    model = Post
    fields = ["title", "summary", "body_md"]

    def test_func(self):
        # Requires an `author` field on Post
        return self.get_object().author == self.request.user

For complex rules across many objects, packages such as django-guardian or django-rules add per-object permissions.

Why This Matters

  1. Security you didn't have to invent โ€” Hashing, sessions, CSRF protection, and password reset flows are all handled by well-reviewed code.

  2. One system everywhere โ€” The admin, your views, and your templates all check the same users and permissions.

  3. Roles without hard-coding โ€” Groups let you change who can do what in the admin, without touching code.

  4. Sensible defaults โ€” Inactive users can't log in, and sessions are invalidated when passwords change.

Key Auth Tools

Tool Purpose Example
request.user The current user request.user.is_authenticated
@login_required Require login for a function view @login_required
LoginRequiredMixin Require login for a class-based view class V(LoginRequiredMixin, View)
@permission_required Require a specific permission @permission_required("blog.change_post")
UserPassesTestMixin Custom check, including per-object rules def test_func(self): ...
Group Bundle permissions into roles Group.objects.get(name="Editors")
AUTH_PASSWORD_VALIDATORS Enforce password rules Length, common passwords

Hardening Checklist

Using the built-in tools is the start. A few settings make it safer in production:

  • Serve over HTTPS only, with SESSION_COOKIE_SECURE = True and CSRF_COOKIE_SECURE = True
  • Keep password validators enabled in AUTH_PASSWORD_VALIDATORS, and consider argon2 as the primary hasher by installing django[argon2] and listing Argon2PasswordHasher first in PASSWORD_HASHERS
  • Rate-limit login attempts. Django doesn't do this by itself, so use a package such as django-axes, or limit requests at Nginx
  • Never check authorization only in the template. Hiding a button isn't protection. Enforce rules in the view
  • Use is_staff and is_superuser sparingly. Superusers bypass all permission checks
  • Add two-factor authentication for staff accounts if several people can reach the admin

Real-World Use Cases

  • Members-only pages โ€” Content visible only to logged-in users
  • Staff tools โ€” Draft previews and moderation screens restricted to editors
  • Per-user data โ€” Dashboards showing only the current user's records
  • Role-based access โ€” Editors, reviewers, and admins with different capabilities
  • Admin access โ€” Exactly the same system, controlling who may use /admin/

Getting Started

  1. Check the defaults. django.contrib.auth and django.contrib.sessions should be in INSTALLED_APPS, with the matching middleware enabled.

  2. Decide on a custom user model before running your first migration.

  3. Add the built-in auth URLs and write a simple login.html.

  4. Protect one view with @login_required, then try it while logged out.

  5. Create a group in the admin, give it permissions, and add a test user to it.

Know the Limits

  • Permissions are model-level by default. Per-object rules need your own code or an extra package.
  • No built-in rate limiting or 2FA. You need to add them yourself for serious deployments.
  • Social login and APIs need more. OAuth sign-in, token authentication, and JWTs come from packages like django-allauth or Django REST Framework.
  • Redirects and HTMX can clash. If an HTMX request hits a protected view and is redirected to the login page, the login form gets swapped into your target element. Handle it by returning an HX-Redirect header or a 401 for HTMX requests.

The Philosophy

Authentication and authorization are problems where cleverness is a liability. The best systems are the ones thousands of people have already read, attacked, and fixed. Django's auth is deliberately conventional, and that is exactly what you want from the part of your app that guards everything else.

Use the built-in pieces, add the missing hardening deliberately, and enforce every rule on the server. Then you can spend your creativity on features, not on reinventing the lock.


This post is part of a series on the tools behind this blog: Django, HTMX, Tailwind CSS, PostgreSQL, SQLite, Docker, Debian on a VPS, tmux, SSH, Neovim, the Django admin, and now authentication and authorization. Posts are authored in the Django admin as Markdown and rendered to HTML on save.

Read more about the tech stack