Django Intermediate #4: Users/Permissions — custom user model, permission, group
In Basics #7, we covered Django’s built-in authentication — login/logout, @login_required, the User model. As soon as you get into real work, two questions come up.
- “What if I want to log in by email instead of username?”
- “How do I give users fine-grained permissions?”
This post is the answer. It starts with one heavy warning — AUTH_USER_MODEL must be decided at project start.
Heavy warning — a decision to make at the start #
The official Django docs put it plainly: “Even if you’re happy with the default User model, you’ll likely want to change it later. So if you’re starting a new project, definitely set up a custom user model, even if it looks just like Django’s default user model.”
The reason is that changing it after running migrations is very hard — you’d have to update every foreign key, the full migration history, and all existing data. For a new project, set the following two things before the first migration.
AUTH_USER_MODEL = "accounts.User"from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
passEven if you don’t need extra fields right now, start with your own User class. Adding fields later is easy.
AbstractUser vs AbstractBaseUser
#
There are two paths depending on customization depth.
AbstractUser | AbstractBaseUser + PermissionsMixin | |
|---|---|---|
| Built-in fields | username, email, first_name, last_name, … | password, last_login only |
| Permission system | Auto-included | Add via PermissionsMixin |
| When to choose | Most cases (90%) — small additions | Completely different user model (email only, etc.) |
| Workload | Very little | Lots (write your own UserManager) |
AbstractUser is the answer almost everywhere. Use AbstractBaseUser only when you really want to drop username and use email exclusively.
AbstractUser — just adding fields
#
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
bio = models.TextField(blank=True)
avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
birth_date = models.DateField(null=True, blank=True)
def __str__(self):
return self.usernameThat’s it. username, email, first_name, last_name, is_staff, is_active, etc. are all still there.
Email-login pattern — AbstractBaseUser
#
A common real-world requirement — using email as username. The standard here is AbstractBaseUser + a custom manager.
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, email, password, **extra):
if not email:
raise ValueError("Email is required.")
email = self.normalize_email(email)
user = self.model(email=email, **extra)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra):
extra.setdefault("is_staff", False)
extra.setdefault("is_superuser", False)
return self._create_user(email, password, **extra)
def create_superuser(self, email, password=None, **extra):
extra.setdefault("is_staff", True)
extra.setdefault("is_superuser", True)
if extra.get("is_staff") is not True:
raise ValueError("superuser must have is_staff=True.")
if extra.get("is_superuser") is not True:
raise ValueError("superuser must have is_superuser=True.")
return self._create_user(email, password, **extra)
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True)
name = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
date_joined = models.DateTimeField(default=timezone.now)
objects = UserManager()
USERNAME_FIELD = "email" # the field used for login
REQUIRED_FIELDS = [] # extra fields createsuperuser asks for (besides email/password)
def __str__(self):
return self.emailCheckpoints:
USERNAME_FIELD = "email"— login identifier.unique=Trueis requiredREQUIRED_FIELDS— extra fieldscreatesuperuserwill prompt for.USERNAME_FIELDandpasswordare auto-included, so don’t list them hereUserManager’screate_user/create_superuserare called bymanage.py createsuperuserand friendsPermissionsMixinmust also be inherited for the group/permission system to work
admin registration #
Even if createsuperuser works, the admin’s user-change form is based on the default User model and will break. Register a custom UserAdmin instead.
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserCreationForm, UserChangeForm
from .models import User
class UserAdmin(BaseUserAdmin):
ordering = ("email",)
list_display = ("email", "name", "is_staff", "is_active")
search_fields = ("email", "name")
fieldsets = (
(None, {"fields": ("email", "password")}),
("Personal info", {"fields": ("name",)}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser",
"groups", "user_permissions")}),
("History", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(None, {"classes": ("wide",),
"fields": ("email", "password1", "password2")}),
)
admin.site.register(User, UserAdmin)For signup and login forms, the username-based built-in forms won’t work as-is either — subclass UserCreationForm / AuthenticationForm and rebuild them around email.
Permission system #
Django automatically creates 4 permissions per model.
| Permission code | Meaning |
|---|---|
add_<model> | Create |
change_<model> | Update |
delete_<model> | Delete |
view_<model> | View |
For the Post model in the blog app, blog.add_post, blog.change_post, blog.delete_post, blog.view_post are auto-generated.
Checking in code #
if request.user.has_perm("blog.change_post"):
# has change permission
...
if request.user.has_perms(["blog.change_post", "blog.delete_post"]):
# has both
...Decorator / Mixin #
from django.contrib.auth.decorators import permission_required
@permission_required("blog.change_post", raise_exception=True)
def post_edit(request, pk):
...from django.contrib.auth.mixins import PermissionRequiredMixin
class PostUpdateView(PermissionRequiredMixin, UpdateView):
permission_required = "blog.change_post"
raise_exception = TrueThe Mixin we saw in #1 CBV.
Custom permissions #
You can define them in the model Meta to add to the auto-generated set.
class Post(models.Model):
...
class Meta:
permissions = [
("publish_post", "Can publish post"),
("feature_post", "Can feature post on homepage"),
]After makemigrations, use as blog.publish_post. Assign through the admin’s user/group editor.
Group — bundles of permissions #
Assigning permissions directly to individual users gets unmanageable fast. Bundle them into groups and assign the group to users — that’s the standard approach.
from django.contrib.auth.models import Group, Permission
editors, _ = Group.objects.get_or_create(name="Editor")
editors.permissions.add(
Permission.objects.get(codename="add_post"),
Permission.objects.get(codename="change_post"),
Permission.objects.get(codename="publish_post"),
)
# assign group to user
user.groups.add(editors)You can do the same in the admin’s Groups menu. For reproducibility, it’s good practice to bake group/permission seed values into code via a data migration or seed command.
from django.db import migrations
def create_default_groups(apps, schema_editor):
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
editors, _ = Group.objects.get_or_create(name="Editor")
perms = Permission.objects.filter(
codename__in=["add_post", "change_post", "publish_post"]
)
editors.permissions.set(perms)
class Migration(migrations.Migration):
dependencies = [("blog", "0001_initial")]
operations = [migrations.RunPython(create_default_groups, migrations.RunPython.noop)]Checking groups/permissions in templates #
{% if user.is_authenticated %}
{% if perms.blog.publish_post %}
<a href="{% url 'post_publish' post.pk %}">Publish</a>
{% endif %}
{% endif %}perms is a helper passed by the django.contrib.auth.context_processors.auth context processor. If it’s in settings.py’s TEMPLATES.OPTIONS.context_processors, it’s automatic.
Object-level permission — one-line note #
Django’s built-in permissions are per-model — only “can this user change any Post”. “Can this user change this specific Post” (per-object) is not provided out of the box.
If you need per-object permissions:
UserPassesTestMixin(#1) for inline checks — sufficient for small cases- django-guardian external library — full-blown object permissions
- DRF’s
permission_classes— for REST APIs (DRF #2)
For small cases, an inline check is clean.
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Post
form_class = PostForm
def test_func(self):
return self.get_object().author == self.request.userPasswords — hash / change / reset #
Django automatically stores passwords with safe hashes like PBKDF2 (default). You rarely have to touch them directly.
user.set_password("new password") # hashes and stores
user.save()
user.check_password("input") # verifyNever store plain-text passwords. Direct assignment like user.password = "..." writes plaintext directly, so always use set_password.
The built-in password change/reset views are in django.contrib.auth.urls.
from django.urls import path, include
urlpatterns = [
path("accounts/", include("django.contrib.auth.urls")),
# /accounts/login/, /accounts/logout/, /accounts/password_change/,
# /accounts/password_reset/, etc.
]Difference from DRF auth — briefly #
This post’s auth is the session + cookie (browser form login) model. REST APIs usually use stateless auth like token / JWT / OAuth2.
| Django built-in (this post) | DRF | |
|---|---|---|
| Auth medium | Session cookie | Token / JWT / OAuth2 |
| Permission check | has_perm / Mixin / decorator | permission_classes |
| User model | Same (AUTH_USER_MODEL) | Same |
| Login form | Built-in HTML | Separate endpoint |
Same User model, but with a different auth medium and a different permission-check layer — that is the structure. The DRF auth/permission stack is detailed in DRF #2.
What request.user actually is
#
AuthenticationMiddleware (the middleware seen in #3) populates request.user on every request.
- Logged in →
Userinstance - Not logged in →
AnonymousUser(a placeholder object;is_authenticated == False)
if request.user.is_authenticated:
...
if request.user.is_staff:
...
if request.user.is_superuser:
...is_authenticated is an attribute (not a method). Don’t call it as request.user.is_authenticated() — in old Django 1.x it was a method, but now it’s an attribute.
Summary #
What we covered in this post:
AUTH_USER_MODELis set at project start — changing it later is very expensiveAbstractUseris the answer almost everywhere (just add fields)- Email login —
AbstractBaseUser+PermissionsMixin+ customUserManager - The meaning of
USERNAME_FIELD,REQUIRED_FIELDS - Auto 4 permissions per model (
add/change/delete/view_<model>) has_perm,@permission_required,PermissionRequiredMixin- Custom permissions via model
Meta.permissions - Bundle permissions via Group — assign the group to users
- Object-level permission via
UserPassesTestMixinordjango-guardian/ DRF - Passwords —
set_password/check_password, built-in change/reset views request.user.is_authenticatedis an attribute
In the next post (#5 Messages/sessions/cookies), we cover flash messages sent to authenticated users, sessions for state across requests, and the cookies beneath them — all in one post.