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 dispatch before checking the login.
  • request.user is always set: an User instance when authenticated, an AnonymousUser otherwise. Test with is_authenticated (a property, never called as a method).
  • login_required appends ?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'))
"
LevelAPINotes
Modeladd_/change_/delete_/view_<model>Created automatically by migrate for every model
CustomMeta.permissionsAdds codenames; requires a migration
Groupuser.groups, Group.permissionsBundle permissions; put users in groups, not permissions directly
Python checkuser.has_perm()Superusers always return True
Template check{{ perms.<app>.<codename> }}Hides UI only — the server must check again
Object levelNot built inFilter 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 call save().
  • Validators run when a UserCreationForm or SetPasswordForm is validated. A raw user.set_password() call bypasses every one of them, so validate with validate_password() first.
  • AUTH_PASSWORD_VALIDATORS is 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.

Templates and the Django template language Django REST Framework: serializers and viewsets

Last refreshed 2026-09-18.