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
-
Security you didn't have to invent โ Hashing, sessions, CSRF protection, and password reset flows are all handled by well-reviewed code.
-
One system everywhere โ The admin, your views, and your templates all check the same users and permissions.
-
Roles without hard-coding โ Groups let you change who can do what in the admin, without touching code.
-
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 = TrueandCSRF_COOKIE_SECURE = True - Keep password validators enabled in
AUTH_PASSWORD_VALIDATORS, and considerargon2as the primary hasher by installingdjango[argon2]and listingArgon2PasswordHasherfirst inPASSWORD_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_staffandis_superusersparingly. 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
-
Check the defaults.
django.contrib.authanddjango.contrib.sessionsshould be inINSTALLED_APPS, with the matching middleware enabled. -
Decide on a custom user model before running your first migration.
-
Add the built-in auth URLs and write a simple
login.html. -
Protect one view with
@login_required, then try it while logged out. -
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-allauthor 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-Redirectheader 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.