Authentication, users and permissions
Use the built-in auth app for login, sessions and permissions, extend the user model correctly, and understand what password validators actually enforce.
Login, logout and the user object
# config/urls.py
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")), # login, logout, password reset
path("blog/", include("blog.urls")),
]# config/settings.py
LOGIN_URL = "login" # name or path, used by login_required
LOGIN_REDIRECT_URL = "blog:list"
LOGOUT_REDIRECT_URL = "/"
SESSION_COOKIE_AGE = 60 * 60 * 8
SESSION_COOKIE_SECURE = True # cookies only over HTTPS (production)
# blog/views.py
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import CreateView
@login_required
def draft_list(request):
return render(request, "blog/drafts.html",
{"drafts": request.user.posts.filter(status="draft")})
class DraftCreateView(LoginRequiredMixin, CreateView):
model = Post
fields = ["title", "body"]
login_url = "login" # overrides LOGIN_URL for this view
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)- In a function view the decorator wraps the callable; in a class view the mixin must come first in the MRO, otherwise Django resolves the base
dispatchbefore checking the login. request.useris always set: anUserinstance when authenticated, anAnonymousUserotherwise. Test withis_authenticated(a property, never called as a method).login_requiredappends?next=/protected/path/; the built-in login view returns there after a successful login. Keep it on the same host — never redirect to a raw user-supplied URL.
Permissions and groups
class Post(models.Model):
...
class Meta:
permissions = [("publish_post", "Can publish posts")] # custom codename
# check in Python
user.has_perm("blog.publish_post")
user.has_perm("blog.change_post", obj=post) # object-level check
Permission.objects.get(codename="publish_post")
# check in a view
from django.contrib.auth.mixins import PermissionRequiredMixin
class PublishView(PermissionRequiredMixin, UpdateView):
model = Post
permission_required = "blog.publish_post"
raise_exception = True # 403 instead of redirect for a logged-in user
# check in a template
{% if perms.blog.publish_post %} <a href="{% url 'blog:publish' post.pk %}">Publish</a> {% endif %}python manage.py shell -c "
from django.contrib.auth.models import Group, Permission
editors, _ = Group.objects.get_or_create(name='editors')
editors.permissions.add(Permission.objects.get(codename='publish_post'))
"| Level | API | Notes |
|---|---|---|
| Model | add_/change_/delete_/view_<model> | Created automatically by migrate for every model |
| Custom | Meta.permissions | Adds codenames; requires a migration |
| Group | user.groups, Group.permissions | Bundle permissions; put users in groups, not permissions directly |
| Python check | user.has_perm() | Superusers always return True |
| Template check | {{ perms.<app>.<codename> }} | Hides UI only — the server must check again |
| Object level | Not built in | Filter querysets, or add django-guardian |
Extending the user model and password policy
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
class Role(models.TextChoices):
READER = "reader", "Reader"
EDITOR = "editor", "Editor"
ADMIN = "admin", "Admin"
role = models.CharField(max_length=10, choices=Role.choices, default=Role.READER)
timezone = models.CharField(max_length=40, blank=True)
# config/settings.py
AUTH_USER_MODEL = "accounts.User"
# anywhere else in the codebase
from django.contrib.auth import get_user_model
User = get_user_model() # never import the model class directly# config/settings.py — what the validators actually enforce
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"},
]
# setting or changing a password in code
user.set_password("correct horse battery staple") # hashes; does not save
user.save(update_fields=["password"])set_password()hashes with the configured hasher (PBKDF2 by default) and adds a per-user salt. It never stores plaintext, and it never touches the database until you callsave().- Validators run when a
UserCreationFormorSetPasswordFormis validated. A rawuser.set_password()call bypasses every one of them, so validate withvalidate_password()first. AUTH_PASSWORD_VALIDATORSis your policy floor: length across the four defaults blocks short, common and purely numeric passwords but says nothing about reuse or breach lists — add a breach-check validator if that matters.
⚠️
Swapping
AUTH_USER_MODEL is a decision you get exactly once, before the first migrate of a project. Afterwards it means a data migration that re-points every foreign key, and doing it halfway produces an inconsistent migration graph that is expensive to untangle. If there is any chance you will need a custom user, create accounts.User on day one — even as an empty subclass — and never touch django.contrib.auth.models.User in your code.FAQ
login_required sends users to a page that does not exist. Why?
The default target is
/accounts/login/. Either include django.contrib.auth.urls under an accounts/ prefix as above, or set LOGIN_URL to an existing route name. The redirect works but the destination 404s, which looks like a broken login rather than a missing URL.How do I stop users reading each other's records?
Permissions gate the action; they do not filter rows. Filter by ownership in the queryset —
Post.objects.filter(author=request.user) — and use get_object_or_404 on that filtered queryset so someone else's primary key returns 404 instead of leaking existence.Related
Templates and the Django template language Django REST Framework: serializers and viewsets
Last refreshed 2026-09-18.