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_requireddecorator - 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:
- 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. - Inspects
request.userto decide access. - Either calls the view, raises
PermissionDenied(rendered as a403 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 = InvoiceSerializerWrapping 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:
- Understanding Django permissions and groups — the model behind it all
- User-level and object-level permissions in Django REST Framework — for APIs
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.