Permissions & Decorators
Reviewed & published by Brayan K
Permissions in Django control what an authenticated user is allowed to do, and decorators like @login_required and @permission_required are wrappers you place above a view to enforce those rules — redirecting or blocking anyone who isn't allowed before the view ever runs.
Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll learn how to lock views down so only logged-in users reach them, how Django's built-in model permissions and groups work, and how to protect both function-based and class-based views cleanly.
What You'll Learn in This Lesson
- How the @login_required decorator gates a view
- Setting LOGIN_URL and the login_url redirect target
- Django's built-in add/change/delete/view permissions
- Using groups and @permission_required to authorise actions
- Checking access in code with user.has_perm()
- Protecting class-based views with LoginRequiredMixin and PermissionRequiredMixin
1 The @login_required Decorator
The most common rule on a website is simply: you must be logged in to see this page. Django ships a decorator for exactly that. A decorator is a function that wraps another function to add behaviour before or after it runs, and you apply one by placing it on the line above a view with the @ symbol.
When you decorate a view with @login_required, Django checks request.user.is_authenticated first. If the user is signed in, the view runs normally. If not, Django redirects them to the login page defined by LOGIN_URL in your settings, attaching a next parameter so they come back afterward.
# views.py
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
@login_required
def dashboard(request):
return HttpResponse(f"Welcome back, {request.user.username}!")
# settings.py — where unauthenticated users are sent
LOGIN_URL = "/accounts/login/"
# You can also override the redirect per-view:
@login_required(login_url="/signin/")
def billing(request):
return HttpResponse("Your invoices")To really understand the decorator, it helps to see one written from scratch in plain Python. The example below builds a tiny login_required wrapper around a fake request, exactly like Django does under the hood.
# A from-scratch version of @login_required so you can see the mechanics.
# A decorator wraps the view and runs a check BEFORE the view code runs.
def login_required(view_func):
def wrapper(request):
if not request.get("is_authenticated", False):
return "REDIRECT to /accounts/login/?next=" + request["path"]
return view_func(request)
return wrapper
@login_required
def dashboard(request):
return "Welcome back, " + request["user"] + "!"
# A logged-out visitor is redirected before the view ever runs:
guest = {"path": "/dashboard/", "is_authenticated": False, "user": "guest"}
print(dashboard(guest))
# A logged-in visitor passes the check and sees the page:
member = {"path": "/dashboard/", "is_authenticated": True, "user": "Alice"}
print(dashboard(member))
# ✅ Expected output:
# REDIRECT to /accounts/login/?next=/dashboard/
# Welcome back, Alice!2 Model Permissions, Groups & @permission_required
Being logged in is rarely enough — an editor should change posts while a regular reader should not. Django solves this with permissions. For every model you create, Django automatically generates four permissions: add, change, delete, and view.
Each permission has a string identifier in the form app_label.codename. For a Post model in a blog app, the change permission is blog.change_post. You can check whether a user holds it in code with user.has_perm('blog.change_post'), or enforce it on a view with the @permission_required decorator.
- add_post — may create new Post rows.
- change_post — may edit existing Post rows.
- delete_post — may remove Post rows.
- view_post — may view Post rows.
Assigning the same permissions to many users one at a time is tedious, so Django gives you groups. A group is a named bundle of permissions; add a user to the "Editors" group and they inherit every permission that group holds.
# views.py
from django.contrib.auth.decorators import permission_required
@permission_required("blog.change_post")
def edit_post(request, post_id):
... # only users with the change_post permission get here
# By default a user without the permission gets 403 Forbidden.
# Pass raise_exception=False to redirect to LOGIN_URL instead:
@permission_required("blog.delete_post", raise_exception=False)
def delete_post(request, post_id):
...
# Creating a group and granting it permissions (e.g. in a shell):
from django.contrib.auth.models import Group, Permission
editors = Group.objects.create(name="Editors")
perm = Permission.objects.get(codename="change_post")
editors.permissions.add(perm)
# Now: some_user.groups.add(editors) grants change_post to that user.The runnable example models has_perm() with a simple user object, then shows how a permission check decides what an action returns.
# Simulating Django's permission system in plain Python.
# A user carries a set of permission strings like "blog.change_post".
class FakeUser:
def __init__(self, name, perms):
self.name = name
self._perms = set(perms)
def has_perm(self, perm):
return perm in self._perms
def edit_post(user, post_id):
if not user.has_perm("blog.change_post"):
return "403 Forbidden: " + user.name + " cannot edit posts"
return user.name + " edited post #" + str(post_id)
editor = FakeUser("Alice", ["blog.view_post", "blog.change_post"])
reader = FakeUser("Bob", ["blog.view_post"])
print(edit_post(editor, 7))
print(edit_post(reader, 7))
print("Alice has change_post:", editor.has_perm("blog.change_post"))
print("Bob has change_post:", reader.has_perm("blog.change_post"))
# ✅ Expected output:
# Alice edited post #7
# 403 Forbidden: Bob cannot edit posts
# Alice has change_post: True
# Bob has change_post: False3 Protecting Class-Based Views & user_passes_test
Decorators wrap plain functions, so they fit function-based views perfectly. Class-based views use inheritance instead, so Django provides mixins that add the same protection. A mixin is a small class you list as a parent to inject behaviour.
Use LoginRequiredMixin to require authentication, and PermissionRequiredMixin with a permission_required attribute to require a specific permission. The crucial rule: list the mixin before the base view class, so its check runs first.
# views.py
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
)
from django.views.generic import ListView, UpdateView
from .models import Post
# Mixin FIRST, then the generic view base class:
class PostList(LoginRequiredMixin, ListView):
model = Post
login_url = "/accounts/login/" # optional per-view override
class PostEdit(PermissionRequiredMixin, UpdateView):
model = Post
fields = ["title", "body"]
permission_required = "blog.change_post"
raise_exception = True # 403 instead of redirectFor rules that don't map to a stored permission — say "only staff active before 2020" or "email ends in @company.com" — use the @user_passes_test decorator. You give it a function that receives the user and returns True to allow access or False to redirect to login.
# views.py
from django.contrib.auth.decorators import user_passes_test
def is_staff_member(user):
return user.is_active and user.is_staff
@user_passes_test(is_staff_member, login_url="/accounts/login/")
def staff_panel(request):
... # only active staff reach this view# Modeling @user_passes_test in plain Python.
# It takes a test function; if the test fails, access is redirected.
def user_passes_test(test_func, login_url="/accounts/login/"):
def decorator(view_func):
def wrapper(user):
if test_func(user):
return view_func(user)
return "REDIRECT to " + login_url
return wrapper
return decorator
def is_staff_member(user):
return user["is_active"] and user["is_staff"]
@user_passes_test(is_staff_member)
def staff_panel(user):
return "Welcome to the staff panel, " + user["name"]
admin = {"name": "Alice", "is_active": True, "is_staff": True}
guest = {"name": "Bob", "is_active": True, "is_staff": False}
print(staff_panel(admin))
print(staff_panel(guest))
# ✅ Expected output:
# Welcome to the staff panel, Alice
# REDIRECT to /accounts/login/🎯 Your Turn: Check the Permission
Fill in the blank so the function correctly checks whether the user is allowed to delete a post using the right method name.
# 🎯 YOUR TURN: replace ___ with the method that checks a permission.
# Hint: a user object exposes a method named has_perm(...)
class FakeUser:
def __init__(self, perms):
self._perms = set(perms)
def has_perm(self, perm):
return perm in self._perms
def can_delete(user):
# Replace ___ with the correct method name
return user.___("blog.delete_post")
editor = FakeUser(["blog.delete_post", "blog.view_post"])
reader = FakeUser(["blog.view_post"])
print("Editor can delete:", can_delete(editor))
print("Reader can delete:", can_delete(reader))
# EXPECTED OUTPUT:
# Editor can delete: True
# Reader can delete: FalseCommon Errors (And How to Fix Them)
❌ Adding parentheses to @login_required when you have no arguments... or forgetting them when you do
Bare @login_required works with no arguments, but to pass options you must call it: @login_required(login_url="/signin/").
✅ Fix: use @login_required alone, or @login_required(login_url=...) with parentheses when overriding the redirect.
❌ PermissionDenied even though the user clearly has access
The permission string is wrong — it must be 'app_label.codename', not just the codename. Writing 'change_post' instead of 'blog.change_post' never matches.
✅ Fix: always include the app label, e.g. @permission_required("blog.change_post").
❌ Mixin has no effect on a class-based view
The mixin was listed after the base view class, so the base view handled the request first and the check never ran.
✅ Fix: put the mixin before the view base — class PostList(LoginRequiredMixin, ListView), never the reverse.
📋 Quick Reference
| Tool | What It Does |
|---|---|
| @login_required | Require a logged-in user on a function-based view |
| @permission_required("app.codename") | Require a specific model permission on a view |
| LoginRequiredMixin | Require authentication on a class-based view |
| PermissionRequiredMixin | Require a permission on a class-based view |
| @user_passes_test(fn) | Allow access only when a custom test function returns True |
🏆 Mini Challenge: A Permission Gate
Build a small access gate that combines a login check and a permission check, returning the right message for guests, logged-in readers, and editors.
# 🏆 Mini Challenge: combine login_required and permission_required logic.
class FakeUser:
def __init__(self, name, is_authenticated, perms):
self.name = name
self.is_authenticated = is_authenticated
self._perms = set(perms)
def has_perm(self, perm):
return perm in self._perms
def access_edit_page(user):
# Step 1: must be logged in (like @login_required)
if not user.is_authenticated:
return "REDIRECT to /accounts/login/"
# Step 2: must hold the permission (like @permission_required)
if not user.has_perm("blog.change_post"):
return "403 Forbidden for " + user.name
return user.name + " may edit the post"
guest = FakeUser("Guest", False, [])
reader = FakeUser("Bob", True, ["blog.view_post"])
editor = FakeUser("Alice", True, ["blog.view_post", "blog.change_post"])
for u in (guest, reader, editor):
print(access_edit_page(u))
# EXPECTED:
# REDIRECT to /accounts/login/
# 403 Forbidden for Bob
# Alice may edit the post❓ Frequently Asked Questions
Lesson complete — your views are locked down!
You now know how @login_required gates a view, how Django's add/change/delete/view permissions and groups work, how to enforce them with @permission_required and user.has_perm(), and how to protect class-based views with LoginRequiredMixin, PermissionRequiredMixin, and @user_passes_test.
Practice quiz
What does the @login_required decorator require before a view runs?
- That the user is authenticated
- That the user is a superuser
- That the request is a POST
- That DEBUG is False
Answer: That the user is authenticated. @login_required checks request.user.is_authenticated and redirects to LOGIN_URL if not.
Which setting names the page that unauthenticated users are redirected to?
- AUTH_URL
- LOGIN_URL
- REDIRECT_URL
- LOGIN_REDIRECT
Answer: LOGIN_URL. LOGIN_URL in settings.py defines where @login_required sends logged-out visitors.
Which four permissions does Django create automatically for each model?
- read, write, edit, remove
- create, update, list, destroy
- add, change, delete, view
- get, post, put, patch
Answer: add, change, delete, view. Django generates add, change, delete, and view permissions for every model.
What is the correct form of a permission string for the Post model in the blog app?
- change_post
- post.change
- Post.change
- blog.change_post
Answer: blog.change_post. Permission strings are 'app_label.codename', e.g. blog.change_post.
Which method checks whether a user holds a permission in code?
- user.has_perm('blog.change_post')
- user.can('blog.change_post')
- user.check_perm('blog.change_post')
- user.permission('blog.change_post')
Answer: user.has_perm('blog.change_post'). user.has_perm('app.codename') returns True when the user has that permission.
Which decorator enforces a specific permission on a function-based view?
- @login_required
- @permission_required
- @require_perm
- @needs_permission
Answer: @permission_required. @permission_required('app.codename') gates a view on a stored permission.
What is a Django group?
- A single user account
- A database table
- A named bundle of permissions
- A URL pattern
Answer: A named bundle of permissions. A group is a named collection of permissions; members inherit all of them.
When listing mixins on a class-based view, where must LoginRequiredMixin go?
- Anywhere in the list
- After the base view
- It is not needed
- Before the base view class
Answer: Before the base view class. The mixin must come before the generic view so its check runs first.
By default, what does @permission_required return for a logged-in user who lacks the permission?
- 403 Forbidden
- 200 OK
- 404 Not Found
- A redirect to the homepage
Answer: 403 Forbidden. Without raise_exception=False, the user gets a 403 Forbidden response.
Which decorator allows access only when a custom test function returns True?
- @permission_required
- @login_required
- @user_passes_test
- @staff_member_required
Answer: @user_passes_test. @user_passes_test(fn) grants access only when the given test function returns True.
Continue this course
- Previous: User Authentication
- Next: Django REST Framework Intro — Turn your Django models into a JSON API with DRF
- Quick reference: Django cheat sheet › Admin & Auth
Frequently asked questions
What is the difference between authentication and permissions in Django?
Authentication answers 'who are you?' — it verifies a user's identity, usually by checking a username and password and starting a session. Permissions answer 'what are you allowed to do?' — once Django knows who the user is, permissions decide whether that specific user may add a post, change a record, or even view a page at all. You almost always combine them: first authenticate, then check permissions.
Do I need to define my own permissions, or does Django create them?
Django automatically creates four permissions for every model you define: add, change, delete, and view. Their codenames look like 'add_post', 'change_post', 'delete_post', and 'view_post', and the full string includes the app label, like 'blog.change_post'. You only define custom permissions when you need rules beyond those four — for example a 'can_publish' permission — which you add in the Meta class of your model.
When should I use a decorator versus a mixin for permissions?
Use decorators like @login_required and @permission_required on function-based views, since decorators wrap plain functions. Use mixins like LoginRequiredMixin and PermissionRequiredMixin on class-based views, because mixins are added through inheritance. They enforce the same rules; the choice simply follows the style of view you are writing.
What happens when a user fails a permission check?
For @login_required, an unauthenticated user is redirected to the login page set by LOGIN_URL (defaulting to /accounts/login/), with a 'next' query parameter so they return after signing in. For @permission_required and PermissionRequiredMixin, a logged-in user who lacks the permission gets a 403 Forbidden response by default, though you can set raise_exception to False to redirect to login instead.