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

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.

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: False

3 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 redirect

For 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: False

Common 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

ToolWhat It Does
@login_requiredRequire a logged-in user on a function-based view
@permission_required("app.codename")Require a specific model permission on a view
LoginRequiredMixinRequire authentication on a class-based view
PermissionRequiredMixinRequire 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

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.