skip to content

In Django, when do you reach for user_passes_test or UserPassesTestMixin instead of permission_required, and what are their pitfalls?

level: middleimportance: should knowfreq 38%

answer

  1. a rule, not a stored right
  2. the test also sees AnonymousUser
  3. the decorator has one failure action
  4. one test_func per class

basics

~20 s

Use them when access depends on a rule rather than one stored permission, such as an email domain or a profile flag. Pitfalls: the test also runs for AnonymousUser, the decorator can only redirect on failure, and UserPassesTestMixin cannot be stacked.

solid answer

~40 s

`user_passes_test(test_func, login_url=None, redirect_field_name="next")` calls `test_func(request.user)` and redirects to the login URL whenever it returns false; it has **no** `raise_exception`, so for a 403 the test must raise `PermissionDenied` itself. `UserPassesTestMixin` calls `self.test_func()` (no argument; read `self.request.user`) in `dispatch()` and on failure uses `AccessMixin.handle_no_permission()`, so logged-in users get a 403 and anonymous ones a login redirect. Both run the test for anonymous users too, and `AnonymousUser` has no `email` or custom fields, so check `is_authenticated` first. Two `UserPassesTestMixin` subclasses cannot be combined: method lookup finds one `test_func()`. When the rule is really a role ("finance may refund"), prefer a permission: `permission_required` is itself built on `user_passes_test`, and a permission can be granted to a group without a code change.

code

python · 26 lines
python
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.views.generic import ListView

from payments.models import Payment


def is_finance_mailbox(user):
    if not user.is_authenticated:
        return False  # AnonymousUser has no email: send it to log in
    if not user.email.endswith("@finance.example.com"):
        raise PermissionDenied  # logged in, wrong team: 403, not a login loop
    return True


@user_passes_test(is_finance_mailbox)
def refund_report(request): ...


class RefundQueueView(UserPassesTestMixin, ListView):
    model = Payment

    def test_func(self):
        user = self.request.user
        return user.is_authenticated and user.email.endswith("@finance.example.com")

go deeper

for a junior

Recall that user_passes_test takes a callable receiving the user, and that UserPassesTestMixin needs a test_func method on the view.

for a middle

Explain the anonymous-user trap, why the decorator only redirects, and why two UserPassesTestMixin subclasses cannot both run.

for a senior

Judge when a predicate is justified and when it hides a role that should be a permission, and show how to get a 403 from the decorator.

for a principal

Weigh ad hoc predicates scattered across views against a small set of named permissions that administrators can grant and audit.

## Two tools, one idea `user_passes_test` (a decorator in `django.contrib.auth.decorators`) and `UserPassesTestMixin` (in `django.contrib.auth.mixins`) both gate a view on an **arbitrary predicate** about the user, where `permission_required` gates it on stored permissions. | | `user_passes_test` | `UserPassesTestMixin` | |---|---|---| | Used on | function views | class-based views | | The test | a callable that receives the user | a method `test_func(self)` with no user argument | | Failing anonymous user | redirect to `login_url` or `LOGIN_URL` | redirect, or 403 if `raise_exception = True` | | Failing logged-in user | redirect to the login URL | 403 via `PermissionDenied` | | `raise_exception` | not a parameter | inherited from `AccessMixin` | | Test not provided | required positional argument | `test_func()` raises `NotImplementedError` | Since Django 5.1 the decorator also wraps `async def` views, and accepts an async test function. ## When a test beats a permission A test is the right tool when the rule is about the user's **attributes** rather than a right an administrator grants: - the account belongs to an email domain; - a profile flag is set, such as a completed verification; - a combination the permission API cannot express directly, such as any one of several permissions. When the rule is really "people in this role may do this", a permission is better. `permission_required` is literally a `user_passes_test` with a `has_perms()` check inside, so you lose nothing, and you gain a right that can be granted to a group in the admin, that active superusers pass automatically, and that templates can read through `{{ perms }}`. Tests such as `u.is_staff` or `u.groups.filter(name="Finance").exists()` hard-code a proxy for the role: every staff member passes the first, and renaming the group breaks the second. ## Pitfalls 1. **The test runs for anonymous users.** Neither tool checks authentication first. `AnonymousUser` defines `username` (an empty string), `is_staff`, `is_active` and `is_superuser` (all false) but has **no** `email` and none of your custom user fields, so `u.email.endswith(...)` raises `AttributeError` and the request fails with a 500. Start the test with `u.is_authenticated`. 2. **The decorator can only redirect.** A logged-in user who fails is sent to the login page, where they are already logged in; with `LoginView(redirect_authenticated_user=True)` that becomes a loop. Raise `PermissionDenied` inside the test for logged-in users to get a 403. 3. **The mixin cannot be stacked.** In `class V(TestMixin1, TestMixin2, View)` method lookup finds a single `test_func()`, so only one test runs. Combine the conditions in one `test_func()`. 4. **Expensive tests run on every request.** A test that queries the database adds queries to each hit; keep it cheap or cache on the request. 5. **Tests hide rules from the template.** `{{ perms }}` cannot see a predicate, so hiding the matching button needs the same logic repeated in the context. ## How it relates to permission_required Reading the source makes the relationship plain: `permission_required` builds a check function that returns `True` when `has_perms()` succeeds, raises `PermissionDenied` when `raise_exception` is set, and otherwise returns `False`, then passes that function to `user_passes_test`. That is also the pattern to copy when you want a custom test with a 403 for logged-in users: return `False` for anonymous users (login redirect) and raise `PermissionDenied` for the rest. ## Mixing tests and permissions The two ideas combine well when a rule is a permission **plus** a condition. On a class-based view, `PermissionRequiredMixin` with an overridden `has_permission()` that returns `super().has_permission() and <condition>` keeps the stored right visible in `permission_required` while adding the extra rule, and still gives logged-in users a 403. On a function view, a test such as `lambda u: u.has_perm("payments.refund_payment") and u.email.endswith(...)` does the same job, with the redirect caveat above. Either way the permission stays the primary signal an administrator can grant, and the predicate narrows it.

  • How do you make user_passes_test answer 403 instead of redirecting a logged-in user?
    The decorator has no `raise_exception`, so the test itself must raise `PermissionDenied`: return `False` when `user.is_authenticated` is false, so anonymous visitors still get the login redirect, and raise `PermissionDenied` when a logged-in user fails. On a class-based view, `UserPassesTestMixin` already gives authenticated users a 403 through `handle_no_permission()`.
  • Why is a group-name test usually worse than permission_required?
    `u.groups.filter(name="Finance").exists()` ties code to one group's name: renaming it or adding a second team that may refund needs a deploy. A permission can be granted to any group or user in the admin, active superusers pass it automatically, and templates can hide the button with `{{ perms }}` using the same string.

saying these in an interview costs you the question

  • user_passes_test accepts raise_exception=True just like permission_required.
  • The test only runs for logged-in users, so AnonymousUser never reaches it.
  • Two UserPassesTestMixin subclasses on one view enforce both tests.
  • Checking is_staff is a fine stand-in for 'finance may refund'.
  • UserPassesTestMixin passes the user to test_func() as an argument.