Permissions & Authorization
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text
says so. Every example was run on that stack in a scratch project on SQLite with the Django test client, the
manage.py check command and a test suite of 25 tests (the Django REST framework example used 3.18.1, the object-level
examples django-guardian 3.5.0, released 2026-09-12, and django-rules 3.5, released 2024-09-02; both classifiers stop
below Django 6.1, yet both ran without problems on 6.1.1). Authentication answers "who is this?"; authorization answers
"what may this person do?". Django ships a small, solid answer for the second question at the level of a model (may
this user add, change, delete or view books at all?), a thin hook for the level of a single object (may this user
change this book?), and nothing at all for the level of a row set (which books may this user even see?). This page
covers the three levels for the Bookshelf application (the catalog app built across this section), and the tests that
prove the rules hold. Signing in is covered on Authentication, the user model on
Custom User Model & Backends, and how the admin applies the same
permissions on The Admin Site.
In a hurry: which tool for which question
| Question | Tool | Section |
|---|---|---|
May this user do this kind of thing to this kind of record? |
A model permission ( |
|
Which permissions does a role bundle? |
A |
|
Protect a view |
|
|
May this user change this object? |
An ownership test in |
|
Which objects may this user see at all? |
A scoped queryset ( |
|
Prove it works |
Tests that assert both the denial and that the database did not change, with a positive control. |
Model permissions
When django.contrib.auth is installed, migrate creates four permissions for every model of every installed app:
add_<model>, change_<model>, delete_<model> and view_<model>. The view permission was added in Django 2.1;
older books only know the first three. A permission is addressed by the string "<app label>.<codename>".
For a Bookshelf Book, the permissions the application needs beyond the four defaults are declared in Meta.
from django.conf import settings
from django.db import models
from django.db.models import Q
class BookQuerySet(models.QuerySet):
def visible_to(self, user):
"""Rows the user may see: published books, plus their own drafts."""
if user.has_perm("catalog.view_book"):
return self
if user.is_authenticated:
return self.filter(Q(published=True) | Q(owner=user))
return self.filter(published=True)
class Book(models.Model):
title = models.CharField(max_length=200)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="books"
)
published = models.BooleanField(default=False)
objects = BookQuerySet.as_manager()
class Meta:
permissions = [
("publish_book", "Can publish a book"),
]
Source: Custom permissions
makemigrations records the option and migrate creates the rows, in a post_migrate handler. The permission is only
a row: Django never enforces publish_book by itself. The code that publishes a book must ask for it, as the views below
do. Listing what exists for a model is a query on Permission:
codenames = set(
Permission.objects.filter(content_type__app_label="catalog", content_type__model="book")
.values_list("codename", flat=True)
)
assert codenames == {"add_book", "change_book", "delete_book", "view_book", "publish_book"}
Source: Default permissions
Permissions can also be created in code, which is useful for permissions that do not belong to a model and for tests. Each row belongs to a content type, because the pair (content type, codename) is what makes a codename unique:
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(Book)
perm = Permission.objects.create(
codename="feature_book", name="Can feature books", content_type=ct
)
user.user_permissions.add(perm)
Two details are easy to trip over. Proxy models get their own permissions and do not inherit those of the concrete
model, and to look up their content type you must pass for_concrete_model=False to get_for_model(). And two changes
arrived in Django 6.1: when a model is renamed, migrate now renames its default permissions to match (and raises an
error with next steps if the new codename already exists, for example from a leftover permission), where older versions
left the old rows in place and created fresh, ungranted ones; and the new Permission.user_perm_str property returns the
string to hand to has_perm(), so code no longer builds f"{app_label}.{codename}" by hand.
perm = Permission.objects.get(codename="publish_book")
assert perm.user_perm_str == "catalog.publish_book"
Source: Default permissions and Django 6.1 release notes
Checking permissions with has_perm
user.has_perm("catalog.add_book") is the single question the rest of the page builds on; has_perms([…]) requires
all of the listed permissions. Behind the scenes has_perm() asks every backend in AUTHENTICATION_BACKENDS, and the
user gets a permission if any one backend grants it (a backend that raises PermissionDenied ends the check at once).
The rules for the built-in ModelBackend are worth memorising:
-
an active superuser has every permission, including ones that do not exist, without any row granting it
-
an inactive user has none, even as a superuser
-
an anonymous user has none (
AnonymousUser.has_perm()is alwaysFalsewithModelBackend; a custom backend may answer differently, which is how django-guardian and django-rules support anonymous access) -
a regular user has the permissions assigned to them directly (
user_permissions) plus those of all their groups -
with an
objargument,ModelBackendreturnsFalse: Django has the plumbing for object permissions and no implementation of them
def test_has_perm_group_and_superuser_and_inactive(self):
self.assertFalse(self.ann.has_perm("catalog.add_book"))
self.assertTrue(self.ed.has_perm("catalog.add_book")) # via the Editors group
self.assertFalse(self.ed.has_perm("catalog.delete_book"))
self.assertTrue(self.ed.has_perms(["catalog.add_book", "catalog.publish_book"]))
self.assertFalse(self.ed.has_perms(["catalog.add_book", "catalog.delete_book"]))
root = User.objects.create_superuser("root", password="not-a-real-password")
self.assertTrue(root.has_perm("catalog.delete_book"))
self.assertTrue(root.has_perm("no.such_permission")) # superusers pass everything
root.is_active = False
root.save()
root = User.objects.get(pk=root.pk)
self.assertFalse(root.has_perm("catalog.delete_book"))
self.assertFalse(AnonymousUser().has_perm("catalog.view_book"))
Source: PermissionsMixin
The "superuser passes everything" rule has a consequence for tests: a typo in a permission string is invisible when the test user is a superuser. Test with ordinary users.
The permission cache
ModelBackend caches a user’s permissions on the user instance after the first check. In a view that is harmless
because the instance lives for one request, but code that grants a permission and checks it straight away sees the old
answer, and so does a test:
user = User.objects.create_user("cache", password="not-a-real-password")
assert not user.has_perm("catalog.view_book")
user.user_permissions.add(Permission.objects.get(codename="view_book"))
assert not user.has_perm("catalog.view_book") # stale: answered from the cache
user.refresh_from_db()
assert not user.has_perm("catalog.view_book") # refresh_from_db() does not clear it
user = User.objects.get(pk=user.pk) # a fresh instance does
assert user.has_perm("catalog.view_book")
Source: Permission caching
Async code
The user and the backends have async twins (ahas_perm(), ahas_perms(), aget_all_permissions() and friends, added in
Django 5.2), so an async view does not need sync_to_async:
async def can_add(user):
return await user.ahas_perm("catalog.add_book")
Source: User.ahas_perm() and Django 5.2 release notes
The class-based mixins and decorators below are synchronous. They work on a request.user already loaded by
AuthenticationMiddleware, and a custom async view calls request.auser() (see
Authentication) and ahas_perm() itself.
In templates
The auth context processor puts a perms object in every template context. perms.catalog.add_book is the template
spelling of has_perm("catalog.add_book"), and perms.catalog of has_module_perms("catalog") (any permission of the
app):
{% if perms.catalog.add_book %}<a href="/books/add/">Add a book</a>{% endif %}
{% if perms.catalog %}<p>has some catalog permission</p>{% endif %}
Source: Permissions in templates
Hiding a link is a courtesy, not a control: the view behind it must enforce the same rule.
Groups
A Group is a named bag of permissions. A user in a group has all of its permissions, and the groups of a user are a
many-to-many (user.groups.add(group)). Roles in the Bookshelf (editors who add, change and publish books) are
groups; assigning permissions one user at a time does not scale and is hard to audit.
Create groups from code that is safe to re-run. A management command run after migrate is the simplest form, and
set() makes repeated runs converge on the declared list instead of accumulating permissions:
from django.contrib.auth.models import Group, Permission
from django.core.management.base import BaseCommand
EDITORS = ["add_book", "change_book", "view_book", "publish_book"]
class Command(BaseCommand):
help = "Create the Editors group with its permissions (idempotent)."
def handle(self, *args, **options):
group, _ = Group.objects.get_or_create(name="Editors")
perms = Permission.objects.filter(
content_type__app_label="catalog", codename__in=EDITORS
)
group.permissions.set(perms) # set() makes re-runs converge
self.stdout.write(f"Editors: {sorted(p.codename for p in perms)}")
$ python manage.py migrate
$ python manage.py setup_groups
Editors: ['add_book', 'change_book', 'publish_book', 'view_book']
Source: Groups and Writing custom django-admin commands
Why not a data migration? Because the permission rows do not exist yet when the migration runs: they are created by the
post_migrate signal after all migrations have been applied. In the scratch project a RunPython step that looked for
publish_book on a fresh database found nothing (Permission.objects.filter(codename="publish_book").exists() was
False), so a migration that builds the group would silently create it empty. If a migration must create groups, call
create_permissions() for the app first or accept a command. See
Management Commands & the Shell for running it from a deployment
script.
Groups are also a free-form label beyond permissions (for example "members of the beta programme"), and a plain check
such as user.groups.filter(name="Editors").exists() is legitimate for that. For access control prefer asking for a
permission: the view then does not change when the role is reorganised.
Enforcing permissions in views
Function views have two decorators and class-based views have two mixins, one pair for permissions and one pair for arbitrary tests. What matters in practice is what each one does on failure, because the answer differs between anonymous and authenticated users:
| Guard | Anonymous user | Logged-in user lacking access |
|---|---|---|
|
redirect to |
redirect to |
|
403 |
403 |
|
redirect to |
403 |
|
403 |
403 |
|
redirect to |
redirect to |
|
redirect to |
403 |
PermissionRequiredMixin
The mixin takes a permission or an iterable of them (all are required) and can be customised through the AccessMixin
attributes (login_url, redirect_field_name, raise_exception, permission_denied_message):
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from django.views.generic import CreateView
from .models import Book
class BookCreateView(PermissionRequiredMixin, CreateView):
model = Book
fields = ["title"] # never "__all__"
permission_required = "catalog.add_book"
template_name = "catalog/form.html"
success_url = "/books/"
def form_valid(self, form):
form.instance.owner = self.request.user
return super().form_valid(form)
class PublishView(PermissionRequiredMixin, View):
permission_required = "catalog.publish_book"
raise_exception = True # always 403, never a redirect to the login page
def post(self, request, pk):
book = get_object_or_404(Book, pk=pk)
book.published = True
book.save(update_fields=["published"])
return redirect("/books/")
Note what BookCreateView does not do: the owner is set from request.user and is not a form field, so a posted
owner value is ignored. The test that exercises the three roles also checks that:
def test_anonymous_redirects_authenticated_gets_403(self):
r = self.client.get("/books/add/")
self.assertRedirects(r, "/accounts/login/?next=/books/add/", fetch_redirect_response=False)
self.client.force_login(self.ann)
self.assertEqual(self.client.get("/books/add/").status_code, 403)
self.client.force_login(self.ed)
self.assertEqual(self.client.get("/books/add/").status_code, 200)
def test_create_sets_owner_and_ignores_owner_field(self):
self.client.force_login(self.ed)
r = self.client.post("/books/add/", {"title": "New", "owner": self.bob.pk})
self.assertEqual(r.status_code, 302)
self.assertEqual(Book.objects.get(title="New").owner, self.ed)
Override get_permission_required() when the permission depends on the request, and has_permission() to replace the
check entirely. A PublishView that answers 403 even to anonymous users is deliberate: it is a POST-only endpoint, and
a redirect to a login page is no use to a script. (A state change belongs behind POST and CSRF protection, see
Security.)
The permission_required decorator, and the redirect-loop trap
For function views the decorator does the same job. Without raise_exception=True it redirects every failure to the
login page, including a user who is already logged in. If the login view then redirects authenticated users away
(redirect_authenticated_user=True), the browser bounces between the two pages forever. The documented fix is to stack
login_required on top and ask for the 403:
@login_required
@permission_required("catalog.publish_book", raise_exception=True)
def unpublish(request, pk):
if request.method != "POST":
return HttpResponse(status=405)
Book.objects.filter(pk=pk).update(published=False)
return redirect("/books/")
@permission_required("catalog.view_book") # no raise_exception: redirects even logged-in users
def audit(request):
return HttpResponse("audit")
r = self.client.get("/books/audit/") # anonymous: 302
self.client.force_login(self.ann)
r = self.client.get("/books/audit/") # logged in, no permission: still 302
r = self.client.post(f"/books/{self.pub.pk}/unpublish/") # decorated stack: 403
If every view of the site (not just the sensitive ones) requires a login, LoginRequiredMiddleware (Django 5.1) states
that once and lets @login_not_required opt views out; permissions are then layered on top. It is covered on
Authentication.
UserPassesTestMixin: when a permission is not the question
Rules such as "only the owner" or "only on weekdays" are not permissions. The documented tool is test_func(), with
@user_passes_test as the function-view equivalent. The Bookshelf owner-only edit view uses it:
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import UpdateView
class OwnerBookUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Book
fields = ["title"]
template_name = "catalog/form.html"
success_url = "/books/"
def test_func(self):
return self.get_object().owner == self.request.user
Source: UserPassesTestMixin
def test_user_passes_test_owner_only(self):
url = f"/books/{self.draft.pk}/edit/"
self.assertEqual(self.client.get(url).status_code, 302) # anonymous: login redirect
self.client.force_login(self.bob)
self.assertEqual(self.client.get(url).status_code, 403)
self.assertEqual(self.client.post(url, {"title": "Hacked"}).status_code, 403)
self.draft.refresh_from_db()
self.assertEqual(self.draft.title, "Draft") # the database did not change
self.client.force_login(self.ann)
self.assertEqual(self.client.post(url, {"title": "Renamed"}).status_code, 302)
self.draft.refresh_from_db()
self.assertEqual(self.draft.title, "Renamed") # positive control
Three rules apply to UserPassesTestMixin. It runs test_func() in dispatch(), so the test happens before the HTTP
method handler. It cannot be stacked: two mixins that each define test_func() do not combine, so one test_func()
combines all conditions with and. And get_object() inside test_func() costs a query that the view repeats; when
that matters, fetch once and keep the object on self.
The order of the mixins is not cosmetic. LoginRequiredMixin must come first (leftmost), because mixins run in method
resolution order and the login check has to stop anonymous requests before any other dispatch() runs. Reversed, the
test sees an AnonymousUser:
class WrongOrderBookUpdateView(UserPassesTestMixin, LoginRequiredMixin, UpdateView):
"""The mistake: the test runs before the login check."""
model = Book
fields = ["title"]
def test_func(self):
return self.request.user.email.endswith("@example.com") # anonymous: AttributeError
def test_wrong_mixin_order_crashes_for_anonymous(self):
with self.assertRaises(AttributeError):
self.client.get(f"/books/{self.draft.pk}/edit-wrong/")
With the right order, an anonymous visitor is redirected to the login page; with the wrong one, AnonymousUser has no
email and the visitor gets a 500 error page. A test that only logs in as a normal user would never notice.
Source: AccessMixin and UserPassesTestMixin
Object-level permissions
Model permissions answer "may editors change books?". The next question is "may this editor change this book?". Django
passes the object through (has_perm(perm, obj), and the same obj argument on every backend method) but its
ModelBackend always answers False when an object is given, so core Django supports no object-level permission on its
own. There are three options, in increasing weight.
| Approach | Good for | Cost |
|---|---|---|
Ownership test in |
One rule ("the owner"), few views. |
The rule is repeated in every view, API and admin that needs it. |
django-rules (predicates) |
Rules computed from data you already have: owner, group, status, dates. Rules compose with |
Nothing stored; no way to list "all objects this user can edit" except by filtering yourself. |
django-guardian (stored per-object permissions) |
Sharing: "give Bob edit access to this one book". Lists objects by permission. |
One row per (user or group, object, permission); extra queries; row clean-up when objects go away. |
The pitfall: model-level permission does not imply object-level
Once an object is passed, ModelBackend contributes nothing, so a user who holds catalog.change_book through a group is
not granted has_perm("catalog.change_book", book) by it. A view that passes the object (django-guardian’s and
django-rules' mixins do) must therefore make the "may change all books" case explicit: guardian does it with
accept_global_perms, and a rules predicate does it by naming the group or checking the global permission itself. Without
that, editors get a 403 on exactly the views meant to serve them. The tests below demonstrate both sides.
django-guardian
django-guardian stores a row per (user or group, object, permission). Install it, add "guardian" to INSTALLED_APPS,
add its backend next to ModelBackend and run migrate:
INSTALLED_APPS += ["guardian", "rules", "catalog"]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"rules.permissions.ObjectPermissionBackend",
"guardian.backends.ObjectPermissionBackend",
]
$ pip install django-guardian rules
Source: django-guardian documentation and django-rules README
migrate also creates an AnonymousUser row (the name comes from ANONYMOUS_USER_NAME) that stands in for visitors, so
anonymous access can be granted per object. Do not delete it. Permissions are then assigned with assign_perm() and
read with get_perms(), has_perm() and get_objects_for_user(); the view mixin tests the object that get_object()
returns:
from guardian.mixins import PermissionRequiredMixin as GuardianPermissionRequiredMixin
class GuardianBookUpdateView(GuardianPermissionRequiredMixin, UpdateView):
model = Book
fields = ["title"]
template_name = "catalog/form.html"
permission_required = "catalog.change_book"
raise_exception = True # logged-in users without the permission get a 403
success_url = "/books/"
from guardian.shortcuts import assign_perm, get_perms, remove_perm
def test_guardian_assign_and_view(self):
url = f"/books/{self.draft.pk}/edit-guardian/"
self.client.force_login(self.bob)
self.assertEqual(self.client.get(url).status_code, 403)
assign_perm("catalog.change_book", self.bob, self.draft)
bob = User.objects.get(pk=self.bob.pk) # fresh instance: permission cache
self.assertTrue(bob.has_perm("catalog.change_book", self.draft))
self.assertFalse(bob.has_perm("catalog.change_book", self.pub))
self.assertFalse(bob.has_perm("catalog.change_book")) # no model-level permission
self.assertEqual(get_perms(bob, self.draft), ["change_book"])
self.assertEqual(self.client.get(url).status_code, 200)
remove_perm("catalog.change_book", self.bob, self.draft)
self.assertEqual(self.client.get(url).status_code, 403)
Source: django-guardian documentation
In the scratch project a logged-in user without the permission was redirected to the login page by this mixin unless
raise_exception = True (or return_403 = True) was set, the same redirect-loop trap as with the Django decorator.
Permissions can be given to a group (assign_perm(perm, group, obj)), and get_objects_for_user() is the guardian way
to scope a queryset; its accept_global_perms argument (default True) is what lets a model-level permission count
there:
from guardian.shortcuts import assign_perm, get_objects_for_user
def test_guardian_group_and_listing(self):
group = Group.objects.create(name="Reviewers")
self.bob.groups.add(group)
assign_perm("catalog.view_book", group, self.draft)
qs = get_objects_for_user(User.objects.get(pk=self.bob.pk), "catalog.view_book", Book)
self.assertEqual(list(qs), [self.draft])
# Editors hold the model-level permission, so with accept_global_perms they see everything
qs = get_objects_for_user(self.ed, "catalog.view_book", Book)
self.assertEqual(qs.count(), 2)
self.assertEqual(
get_objects_for_user(self.ed, "catalog.view_book", Book, accept_global_perms=False).count(), 0
)
Source: django-guardian documentation
Things to know before adopting it: every object-permission check is a query (use get_objects_for_user() for lists
rather than checking object by object, which is the N+1 problem of authorization); and by default guardian adds helper methods to the
User and Group classes at start-up (GUARDIAN_MONKEY_PATCH_USER and GUARDIAN_MONKEY_PATCH_GROUP, both True by
default), which can be switched off if you prefer the guardian.shortcuts functions. Its documentation is the reference
for the other options; this page only claims what was run.
django-rules
django-rules stores nothing. A rule is a function of the user and optionally an object, built with @rules.predicate,
combined with &, | and ~, and registered under a permission name with rules.add_perm(). Its
ObjectPermissionBackend (in the settings above) lets user.has_perm("catalog.change_review", review) evaluate the
rule:
import rules
@rules.predicate
def is_review_author(user, review):
return review is not None and review.author_id == user.pk
is_editor = rules.is_group_member("Editors")
rules.add_perm("catalog.change_review", rules.is_authenticated & (is_review_author | is_editor))
rules.add_perm("catalog.delete_review", rules.is_authenticated & (is_review_author | is_editor))
The module must be imported at start-up so that the rules are registered; the scratch project does it from
AppConfig.ready() (from . import rules). The view mixin of django-rules passes get_object() to the check:
from rules.contrib.views import PermissionRequiredMixin as RulesPermissionRequiredMixin
class ReviewUpdateView(RulesPermissionRequiredMixin, UpdateView):
model = Review
fields = ["body"]
template_name = "catalog/form.html"
permission_required = "catalog.change_review"
success_url = "/books/"
def test_rules_author_only(self):
review = Review.objects.create(book=self.pub, author=self.bob, body="ok")
url = f"/books/reviews/{review.pk}/edit/"
self.assertEqual(self.client.get(url).status_code, 302) # anonymous
self.client.force_login(self.ann)
self.assertEqual(self.client.get(url).status_code, 403) # stranger
self.client.force_login(self.bob)
self.assertEqual(self.client.get(url).status_code, 200) # author
self.assertEqual(self.client.post(url, {"body": "better"}).status_code, 302)
def test_rules_editor_and_model_level_permission(self):
review = Review.objects.create(book=self.pub, author=self.bob, body="ok")
self.assertTrue(self.ed.has_perm("catalog.change_review", review)) # group member
mod = User.objects.create_user("mod", password="not-a-real-password")
mod.user_permissions.add(Permission.objects.get(codename="change_review"))
mod = User.objects.get(pk=mod.pk)
self.assertTrue(mod.has_perm("catalog.change_review")) # model level: ModelBackend
self.assertFalse(mod.has_perm("catalog.change_review", review)) # object level: nobody grants it
self.assertTrue(self.bob.has_perm("catalog.change_review", review)) # the author
self.assertFalse(self.bob.has_perm("catalog.change_review")) # no model-level permission
Source: django-rules README
The last two assertions of the second test are the pitfall in action: mod has the model-level change_review
permission from the default permissions and still fails the object check. The predicate has to say who else may act.
Rule names live in the same namespace as Django permissions: registering a rule with a default codename such as
catalog.change_review is combined (any backend granting is enough), which is why this example works for the model-level
question too, but it also means a rule cannot remove a permission another backend grants. A rule can be tested without
a request through rules.has_perm("catalog.change_review", user, review), and in a template the tag
{% load rules %} and {% has_perm "catalog.change_book" request.user book as can_edit %} asks the same question, which is
what the Bookshelf list template uses to show an "edit" marker.
import rules
assert rules.has_perm("catalog.change_review", self.bob, review)
assert not rules.has_perm("catalog.change_review", self.ann, review)
The admin
The admin asks the same questions. ModelAdmin has has_view_permission(), has_add_permission(),
has_change_permission() and has_delete_permission(), each of which receives the object (or None on list pages), so
an admin can implement per-object rules without any backend; see The Admin Site for the
hooks and rules.contrib.admin for a ready-made integration. The admin also needs the view or change permission to
show a model at all, and the 2.1 release note about the new view permission is a reminder to grant view, not change, to people who only
need to read.
Queryset scoping
A permission check answers yes or no for one request. Scoping answers a different question: which rows a user may see
at all. Checking has_perm() in a detail view while the list view returns everything is the most common authorization
bug in Django projects (it is called an insecure direct object reference when the leak is by guessable id). The fix is to
define the visible set once, on the queryset, and make every access path go through it.
The visible_to() method shown above is that definition. The views use it for both lists and details:
from django.views.generic import DetailView, ListView
class BookListView(ListView):
template_name = "catalog/list.html"
def get_queryset(self):
return Book.objects.visible_to(self.request.user)
class BookDetailView(DetailView):
template_name = "catalog/detail.html"
def get_queryset(self): # scoped: other people's drafts are a 404
return Book.objects.visible_to(self.request.user)
DetailView looks the object up in get_queryset(), so the scoped queryset turns "not yours" into "does not exist", a
404, and the response does not even confirm that the draft exists. That is the safer default for private data. Answer
403 only when the existence of the object is not a secret (a shared page the user is simply not allowed to edit, as in
the owner-only edit view above). The test suite checks both the lists and the detail pages:
def test_visible_to(self):
names = lambda u: sorted(Book.objects.visible_to(u).values_list("title", flat=True))
self.assertEqual(names(AnonymousUser()), ["Public"])
self.assertEqual(names(self.bob), ["Public"])
self.assertEqual(names(self.ann), ["Draft", "Public"])
def test_detail_is_404_not_403_for_hidden(self):
self.client.force_login(self.bob)
self.assertEqual(self.client.get(f"/books/{self.draft.pk}/").status_code, 404)
self.assertEqual(self.client.get(f"/books/{self.pub.pk}/").status_code, 200)
Rules of thumb that follow from this:
-
put the filter in one
QuerySetmethod (see Managers & Custom QuerySets) and call it from every view, API, export, admin class and task that lists, fetches, counts or aggregates the model; a single forgottenBook.objects.all()is a leak -
use
get_object_or_404(Book.objects.visible_to(user), pk=pk)in function views instead ofget_object_or_404(Book, pk=pk)followed by a check -
scope related data too: a review list for a hidden book is as sensitive as the book (
Review.objects.filter( book__in=Book.objects.visible_to(user))) -
do not treat unguessable identifiers (UUIDs) as authorization: they reduce enumeration but are leaked by logs, shared links and referrers; the scoped queryset is what protects the data
-
never trust a user-supplied foreign key: set
ownerfromrequest.user(asBookCreateViewdoes) and list only the fields that may be edited -
the admin applies the same idea through
ModelAdmin.get_queryset()
The same idea in Django REST framework
The Django REST framework’s DjangoModelPermissions is the API twin of model permissions: it maps POST, PUT/PATCH
and DELETE to add, change and delete, and it requires an authenticated user. It does not restrict GET by
permission, so on its own it would let any signed-in user read every record of a model. Pair it with a scoped
get_queryset():
from rest_framework import permissions, routers, serializers, viewsets
from .models import Book
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "published"] # explicit, never "__all__"
read_only_fields = ["published"]
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookSerializer
permission_classes = [permissions.DjangoModelPermissions]
def get_queryset(self): # scoping: the safe default for every verb
return Book.objects.visible_to(self.request.user)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
router = routers.DefaultRouter()
router.register("books", BookViewSet, basename="api-book")
def test_model_permissions_do_not_gate_reads_but_scoping_does(self):
c = APIClient()
self.assertIn(c.get("/api/books/").status_code, (401, 403)) # anonymous
c.force_authenticate(self.bob)
r = c.get("/api/books/")
self.assertEqual([b["title"] for b in r.json()], ["Public"]) # no drafts: scoped
self.assertEqual(c.get(f"/api/books/{self.draft.pk}/").status_code, 404)
self.assertEqual(c.post("/api/books/", {"title": "x"}, format="json").status_code, 403)
c.force_authenticate(User.objects.get(pk=self.ed.pk))
r = c.post("/api/books/", {"title": "x", "owner": self.bob.pk}, format="json")
self.assertEqual(r.status_code, 201)
self.assertEqual(Book.objects.get(title="x").owner, self.ed) # owner field ignored
DRF’s has_object_permission() hook is the object-level equivalent and is only called for single-object lookups
(get_object()), never for list responses, which is one more reason to scope the queryset.
Testing authorization
Authorization tests are written to fail when the rule is broken, which asks more of a test than "the page loaded". Four habits keep them honest, and every example above follows them:
-
Test every actor: anonymous, authenticated without the permission, authenticated with it, and the owner and a stranger for object rules. Anonymous and authenticated-but-forbidden differ (redirect versus 403).
-
Assert the effect, not only the status: after a forbidden
POST, callrefresh_from_db()and assert the row is unchanged; for a delete, assert the row still exists. -
Add a positive control: the allowed actor must succeed with the same request, otherwise the denial may be an error in the test (a wrong URL name,
argspassed as a string instead of a list, a missing CSRF-exempt client) and the test passes for the wrong reason. -
Use ordinary users, never superusers, for the denial cases (see Checking permissions with has_perm), and fetch a fresh user instance after changing permissions (see the permission cache).
The test client’s force_login() skips the password hashing and the login form, which makes a test suite of this size run
in under two seconds. Client.force_login(), override_settings() and the DRF APIClient.force_authenticate() are the
tools used here.
Where the books are outdated or wrong
Four Django books were consulted for the topics above. Their authorization chapters are useful for the narrative and the class names, but several statements are outdated and a few examples are insecure or broken. They are corrected here; none of the book text or code is reproduced.
| Book pattern | Problem | Correct form |
|---|---|---|
A custom |
The check runs first, so anonymous users crash it or bypass the login redirect. |
|
"Only the author may edit" implemented as a check in the template, or not enforced on |
The edit form is hidden but the URL still works. |
|
Tests whose |
The test passes whether or not the rule works, or never reaches the code it claims to test. |
Assert 302/403, assert the database state, add a positive control (Testing authorization). |
|
Mass assignment: the visitor chooses |
List fields explicitly and set owner server-side ( |
A |
Any signed-in user lists and edits every record. |
A scoped |
|
Monkey-patches the model at import time, breaks with custom user models and migrations, hides state from the type system. |
A custom user model from the start, or a proxy model/profile model; see Custom User Model & Backends. |
A permission mentioned only in the admin, never checked in views (several books) |
The admin enforces |
Check the same permission in views (Enforcing permissions in views). |
No tests for access rules at all (Melé) |
Regressions are silent. |
One test per actor per rule (Testing authorization). |
Outdated rather than wrong: books written for Django 1.x/2.x describe three default permissions (add, change, delete;
view arrived in Django 2.1), import django.contrib.auth.mixins classes as if they were new (1.9), and give
user.is_authenticated() as a call (a property since 1.10, and the call form was removed in 2.0). All of them predate
the async permission methods (5.2) and LoginRequiredMiddleware (5.1).
What changed recently
| Version | Change |
|---|---|
2.1 |
The default |
5.1 |
|
5.2 |
Async twins of the permission methods: |
6.1 |
|
Packages |
django-guardian 3.5.0 (2026-09-12, Python 3.10+) works on Django 6.1.1 although its classifiers stop at 6.0; django-rules 3.5 (2024-09-02) works on 6.1.1 too. Re-check the classifiers on the project pages before upgrading, as the versions above were verified on 2026-10-03. |
Source: Django 6.1 release notes, 5.2, 5.1 and 2.1
Pitfalls checklist
-
Permissions are rows, not behaviour: a
Meta.permissionsentry enforces nothing until a view checks it. -
A typo in a permission string is a silent
Falsefor ordinary users and a silentTruefor superusers. -
Cached permissions: re-fetch the user after changing permissions in tests or long-lived code.
-
@permission_requiredwithoutraise_exception=Trueredirects logged-in users to the login page. -
LoginRequiredMixingoes first. -
Model-level permission does not grant object-level permission: say so in the rule, or use
accept_global_perms. -
Scope the queryset once and reuse it; prefer a 404 over a 403 for private data.
-
Never trust a foreign key from the request: set it from
request.user. -
Hiding a button is not access control.