Custom Decorators for User Roles & Permissions in Django

Blog / Django · March 13, 2021 · Updated June 10, 2026 · 6 min read
Custom Decorators for User Roles & Permissions in Django

Role- and permission-based access control shows up in almost every Django app: an admin can edit anything, a manager can approve, a regular member can only read. Django ships with solid built-in tools for this, but real apps eventually need cross-cutting role logic that the built-ins don't express cleanly. That's where custom view decorators earn their place.

A decorator is just a function that wraps another function and returns an enhanced version of it. For a Django view, a decorator can inspect request.user before the view runs and decide whether to let the request through, return a 403 Forbidden, or redirect to the login page.

This guide is current for Django 5.x and covers:

  • The built-in auth decorators and mixins (use these first)
  • Writing a clean custom decorator with functools.wraps
  • PermissionDenied (which Django turns into a 403) vs redirecting to login
  • Parameterized decorators: a role_required('admin', 'manager') factory
  • A reusable group_required decorator
  • Stacking custom decorators with @login_required
  • A short note on Django REST Framework permission classes for APIs

Reach for the built-ins first

Before you write a single line, know what Django already gives you. For the common cases you should not roll your own:

Need Function-based views Class-based views
Require a logged-in user @login_required LoginRequiredMixin
Require a specific permission @permission_required('app.perm', raise_exception=True) PermissionRequiredMixin
Any arbitrary test @user_passes_test(fn) UserPassesTestMixin

If you're new to Django's permission model, read Understanding Django permissions and groups first — custom decorators build directly on those concepts.

from django.contrib.auth.decorators import (
    login_required,
    permission_required,
    user_passes_test,
)
from django.shortcuts import render


@login_required
def profile(request):
    return render(request, "profile.html")


# raise_exception=True returns 403 Forbidden instead of redirecting to login.
@permission_required("blog.change_post", raise_exception=True)
def edit_post(request, pk):
    return render(request, "blog/edit.html")


# Any callable that takes the user and returns a bool works here.
@user_passes_test(lambda u: u.is_active and u.is_staff, login_url="/login/")
def staff_area(request):
    return render(request, "staff.html")

For class-based views, the equivalent mixins must be listed first in the inheritance order (before the generic view base class) so their checks run before dispatch():

from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    PermissionRequiredMixin,
    UserPassesTestMixin,
)
from django.views.generic import ListView, UpdateView


class DashboardView(LoginRequiredMixin, ListView):
    model = Report
    login_url = "/login/"


class PostUpdateView(PermissionRequiredMixin, UpdateView):
    model = Post
    fields = ["title", "body"]
    permission_required = "blog.change_post"
    raise_exception = True  # 403 for logged-in users without the permission


class ManagerOnlyView(UserPassesTestMixin, ListView):
    model = Invoice

    def test_func(self):
        return self.request.user.role == "manager"

New in Django 5.1: if you want every view to require login by default, add django.contrib.auth.middleware.LoginRequiredMiddleware to your middleware stack and mark the few public views with @login_not_required. That removes a lot of repetitive @login_required decorators.

So when should you write your own decorator?

Reach for a custom decorator when the rule is role-based and cross-cutting — the same check repeated across many views, expressed in your domain's language ("admins and managers only") rather than as a raw permission codename. A named @role_required('admin', 'manager') reads far better than copy-pasting a user_passes_test lambda into 20 views, and it keeps the rule in one place.

Anatomy of a custom decorator

A robust view decorator does three things:

  1. Wraps the view with @wraps(view_func) so the original name, docstring and attributes survive — this matters for Django's URL resolver, the admin, and debugging.
  2. Inspects request.user to decide access.
  3. Either calls the view, raises PermissionDenied (rendered as a 403 Forbidden), or redirects to login.

Use PermissionDenied when the user is logged in but lacks the role — sending them back to a login page would be confusing. Redirect to login (via redirect_to_login) only when there's no authenticated user yet.

from functools import wraps

from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.shortcuts import render


def role_required(*roles):
    """Allow access only to authenticated users whose role is in *roles*.

    Unauthenticated users are sent to the login page; authenticated users
    without a matching role get a 403 via PermissionDenied.
    """

    def decorator(view_func):
        @wraps(view_func)
        def _wrapped(request, *args, **kwargs):
            user = request.user
            if not user.is_authenticated:
                return redirect_to_login(request.get_full_path())
            if user.is_superuser or getattr(user, "role", None) in roles:
                return view_func(request, *args, **kwargs)
            raise PermissionDenied

        return _wrapped

    return decorator


@role_required("admin", "manager")
def approvals(request):
    return render(request, "approvals.html")

role_required is a decorator factory: calling role_required('admin', 'manager') returns the actual decorator, which in turn wraps your view. The *roles argument is what makes it parameterized and reusable across the whole app.

This example assumes your user model has a role attribute (a CharField with choices, or a property derived from groups). If you store roles as Django groups instead, the next decorator is a better fit. See how to create a custom user model in Django for where a role field naturally lives.

from functools import wraps

from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.shortcuts import render


def group_required(*group_names):
    """Allow access only to users in at least one of the named groups."""

    def decorator(view_func):
        @wraps(view_func)
        def _wrapped(request, *args, **kwargs):
            if not request.user.is_authenticated:
                return redirect_to_login(request.get_full_path())
            in_group = request.user.groups.filter(name__in=group_names).exists()
            if request.user.is_superuser or in_group:
                return view_func(request, *args, **kwargs)
            raise PermissionDenied

        return _wrapped

    return decorator


@group_required("editors", "moderators")
def moderate(request):
    return render(request, "moderate.html")

Stacking decorators (and why order matters)

Decorators apply bottom-up when the view is wrapped, but at request time the outermost one runs first. If a custom decorator already handles unauthenticated users you don't strictly need @login_required — but you can still stack them when you want Django's standard login redirect to run before your role check:

from django.contrib.auth.decorators import login_required


@login_required                 # runs first: bounces anonymous users to login
@role_required("admin")         # then checks the role -> 403 if not an admin
def admin_panel(request):
    return render(request, "admin_panel.html")

APIs: use DRF permission classes, not view decorators

Decorators are the right tool for traditional Django views. For a Django REST Framework API, prefer permission classes — they integrate with DRF's request flow, content negotiation and browsable API. Subclass BasePermission and implement has_permission (and has_object_permission for per-object checks):

from rest_framework.permissions import BasePermission


class IsManager(BasePermission):
    message = "Manager role required."

    def has_permission(self, request, view):
        user = request.user
        return bool(user and user.is_authenticated and user.role == "manager")


# views.py
from rest_framework.generics import ListAPIView


class InvoiceList(ListAPIView):
    permission_classes = [IsManager]
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer

Wrapping up

Use Django's built-ins for the standard cases, and reach for custom decorators only when you have cross-cutting, role-based rules that deserve a clear name. Keep each decorator small, wrap it with functools.wraps, and be deliberate about PermissionDenied (403) vs redirecting to login.

To go deeper:

Need a hand designing access control for a production Django app? Our Django development services team can help.

Frequently Asked Questions

When should I write a custom decorator instead of using Django's built-ins?

Use the built-ins (login_required, permission_required, user_passes_test, and their class-based-view mixins) for standard cases. Write a custom decorator when you have a cross-cutting, role-based rule that you repeat across many views and want to express in your domain's language, such as @role_required('admin', 'manager').

Why do I need functools.wraps in a decorator?

@wraps(view_func) copies the wrapped view's name, docstring and attributes onto the wrapper. Without it, Django's URL resolver, the admin, error pages and debugging tools see a generic _wrapped function instead of your real view, which makes tracing and introspection harder.

Should the decorator raise PermissionDenied or redirect to login?

Raise PermissionDenied (which Django renders as a 403) when the user is authenticated but lacks the required role — redirecting a logged-in user to a login page is confusing. Redirect to login (via redirect_to_login) only when there is no authenticated user yet.

How do I make a decorator that takes arguments like a role name?

Write a decorator factory: an outer function that takes the arguments (for example *roles) and returns the real decorator, which then returns the wrapper. Calling role_required('admin') builds and returns a decorator configured for that role.

Does decorator order matter when I stack them?

Yes. At request time the outermost decorator runs first. Put @login_required above @role_required(...) so anonymous users are redirected to login before the role check runs. If your custom decorator already handles unauthenticated users, stacking @login_required is optional.

How do I enforce roles in a Django REST Framework API?

Don't use view decorators — use DRF permission classes. Subclass BasePermission and implement has_permission (and has_object_permission for object-level checks), then list it in a view's permission_classes. This integrates with DRF's request handling and browsable API.

Share this article