PYTHON / DJANGO
Users, authentication, and permissions
Create users with correctly hashed passwords, log them in with authenticate and login, and gate access using groups, permissions, and has_perm.
What you will learn
- Hash passwords correctly by using User.objects.create_user, never User.objects.create
- Separate identity (authenticate, login, is_authenticated) from authority (has_perm)
- Check permissions with the 'app_label.codename' string, unioned from user and groups
- Recognise that superusers bypass has_perm and inactive users fail authenticate()
Understanding Users, authentication, and permissions
Django's User model keeps the password in a single text column, but not as text you gave it: create_user pushes the raw string through make_password, producing a value like pbkdf2_sha256$870000$<salt>$<hash>. check_password re-hashes the candidate with the salt and iteration count read back out of that string and compares the results, which is why there is no way to recover the original password and why assigning to user.password directly breaks login silently. authenticate() does not talk to the database itself; it walks AUTHENTICATION_BACKENDS, and the default ModelBackend looks the username up, calls check_password, and then refuses the user anyway if is_active is False. login(request, user) stores the user's primary key and a session auth hash in the session, and AuthenticationMiddleware turns that back into request.user on every later request.
Permissions are ordinary rows in the auth_permission table, each pointing at a ContentType. When you run migrate, a post_migrate signal creates four permissions per model: add_, change_, delete_ and view_<modelname>. You check one with the dotted string app_label.codename, as in has_perm('auth.change_user'), and ModelBackend answers by taking the union of the user's own user_permissions and the permissions of every Group the user belongs to. A Group is nothing more than a named bundle of those rows, which is why assigning permissions to groups instead of individuals is the maintainable choice. is_superuser short-circuits the whole lookup and returns True for any permission string, even one that does not exist.
The useful mental model is four independent layers rather than one privilege dial: is_authenticated says Django knows who this is, is_active says the account may still be used at all, is_staff says the admin site will open its door, and the permission set says which specific actions are allowed. Mixing them up produces confusing bugs, such as a deactivated user who still passes check_password but can never log in. ModelBackend also caches the resolved permission set on the user instance the first time you ask, so a group added after that first has_perm call in the same request appears to have no effect until you re-fetch the user or delete the cache attributes.
import django
from django.conf import settings
settings.configure(
DEBUG=True,
SECRET_KEY="demo-only",
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"],
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
USE_TZ=True,
)
django.setup()
from django.core.management import call_command
call_command("migrate", verbosity=0)
from django.contrib.auth import authenticate
from django.contrib.auth.models import Group, Permission, User
ada = User.objects.create_user("ada", password="analytical-1843")
print("hasher:", ada.password.split("$")[0])
print("correct password:", ada.check_password("analytical-1843"))
print("wrong case:", ada.check_password("Analytical-1843"))
print("authenticate ok:", authenticate(username="ada", password="analytical-1843") == ada)
print("authenticate bad:", authenticate(username="ada", password="nope") is None)
# First permission question: caches the resolved set on this instance.
print("before group:", ada.has_perm("auth.add_user"))
editors = Group.objects.create(name="editors")
editors.permissions.add(Permission.objects.get(codename="add_user"))
ada.groups.add(editors)
print("after group, same object:", ada.has_perm("auth.add_user"))
fresh = User.objects.get(pk=ada.pk)
print("after refetch:", fresh.has_perm("auth.add_user"))
print("unrelated perm:", fresh.has_perm("auth.delete_user"))Authentication establishes who the request is from, permissions decide what that identity may do, and Django keeps the two in separate, independently checkable layers.
Worked examples
Superusers, inactive accounts, and anonymous visitors
Shows that a superuser owns no permission rows yet passes every check, while an inactive user keeps a valid password but is refused by authenticate.
import django
from django.conf import settings
settings.configure(
DEBUG=True,
SECRET_KEY="demo-only",
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"],
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
USE_TZ=True,
)
django.setup()
from django.core.management import call_command
call_command("migrate", verbosity=0)
from django.contrib.auth import authenticate
from django.contrib.auth.models import AnonymousUser, User
root = User.objects.create_superuser("root", "root@example.com", "pw-9481")
print("superuser has_perm:", root.has_perm("auth.delete_group"))
print("superuser permission rows:", root.user_permissions.count())
banned = User.objects.create_user("banned", password="pw-2210")
banned.is_active = False
banned.save()
print("password still valid:", banned.check_password("pw-2210"))
print("authenticate inactive:", authenticate(username="banned", password="pw-2210"))
guest = AnonymousUser()
print("anonymous is_authenticated:", guest.is_authenticated)
print("anonymous has_perm:", guest.has_perm("auth.add_user"))Example explained
Line 1root.has_perm returns True without any auth_permission row because is_superuser short-circuits the backend lookup entirely.
Line 2banned.check_password stays True: deactivating an account changes is_active, it does not touch the stored hash.
Line 3authenticate returns None rather than the user, because ModelBackend calls user_can_authenticate and rejects is_active=False.
Line 4AnonymousUser is a real object, not None, so request.user always exists; its is_authenticated is False and every has_perm is False.
Reading the full permission set of a user
Demonstrates get_all_permissions merging directly assigned permissions with those inherited from a group.
import django
from django.conf import settings
settings.configure(
DEBUG=True,
SECRET_KEY="demo-only",
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"],
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
USE_TZ=True,
)
django.setup()
from django.core.management import call_command
call_command("migrate", verbosity=0)
from django.contrib.auth.models import Group, Permission, User
user = User.objects.create_user("grace", password="compiler-1952")
user.user_permissions.add(Permission.objects.get(codename="view_group"))
reviewers = Group.objects.create(name="reviewers")
reviewers.permissions.add(Permission.objects.get(codename="change_group"))
user.groups.add(reviewers)
user = User.objects.get(pk=user.pk)
print(sorted(user.get_all_permissions()))
print("direct only:", sorted(user.get_user_permissions()))
print("from groups:", sorted(user.get_group_permissions()))
print("module level:", user.has_module_perms("auth"), user.has_module_perms("contenttypes"))Example explained
Line 1get_all_permissions returns the union as 'app_label.codename' strings, which is exactly the format has_perm expects.
Line 2get_user_permissions and get_group_permissions let you see which side a permission came from when debugging access rules.
Line 3Re-fetching the user before the first check avoids reading a permission cache built before groups.add ran.
Line 4has_module_perms('auth') is True because the user holds at least one auth permission; contenttypes is False because it holds none there.
Important notes
Testing permission logic while logged in as a superuser proves nothing, because is_superuser makes has_perm return True for every string, including misspelled ones like 'auth.chnage_user'.
ModelBackend's default permission lookup ignores the obj argument entirely, so has_perm('app.change_thing', instance) is not per-object security unless you write a backend that implements it.
Common mistakes
Calling User.objects.create(username='x', password='secret'): the raw string lands in the password column, check_password and authenticate then always fail, and the account can never log in even though it looks fine in the admin.
Writing if request.user.is_authenticated(): with parentheses. It is a property, so calling it raises TypeError: 'bool' object is not callable, and the same mistake with is_authenticated on AnonymousUser hides the real access check.
Assuming @login_required restricts anything beyond being logged in. Every authenticated account, including a brand-new self-registered one, passes it; you need permission_required or an explicit has_perm check to limit actions.
Try it yourself
Change, predict, then run
In a Django shell, create two users and a group named 'moderators' holding only the auth.change_user permission, add one user to it, then re-fetch both users from the database and print has_perm('auth.change_user') and has_perm('auth.delete_user') for each.
Open the Python workspaceCheck your understanding
A staff member is added to a group carrying polls.change_question during a request, but request.user.has_perm('polls.change_question') still returns False later in that same request. What is the most likely reason?
- ModelBackend cached the resolved permission set on that user instance before the group was added, so the check needs a freshly loaded user
- Group permissions apply only to users with is_superuser set to True
- has_perm consults only user_permissions, so group membership never affects it
- The permission row does not exist until migrate is run again
Show answer
ModelBackend stores the union of user and group permissions on the user object the first time it is asked, so changes made afterwards are invisible until you re-fetch the user or clear the cached attributes. Option three is tempting because it sounds like a clean separation, but has_perm explicitly unions user_permissions with every group's permissions; if it ignored groups, the check would still fail after re-fetching, which it does not.