Filter concepts by levelShowing all levels.

Django · DRF Permissions

DRF Permissions

The shipped classes, custom BasePermission and object-level checks, roles, and composition

  • The list is AND, evaluated in orderthe first class returning False ends the request — use | for OR
  • has_object_permission never runs for list or createa collection is scoped by get_queryset(), not by a permission class
  • The out-of-the-box default is AllowAnya forgotten endpoint is public — set DEFAULT_PERMISSION_CLASSES to IsAuthenticated
  • IsAdminUser means is_staffnot is_superuser, despite the name
  • Validation is not authorizationa valid choice value says nothing about who may set it

1.AllowAny, IsAuthenticated, IsAdminUser, and the shipped set

A permission class answers "may this caller do this?" after authentication has already answered "who is this?". DRF ships a handful: `AllowAny` lets everything through, `IsAuthenticated` requires a signed-in user, `IsAdminUser` requires `user.is_staff` (not `is_superuser`, despite the name), and `IsAuthenticatedOrReadOnly` allows anyone to read but only signed-in users to write. `DjangoModelPermissions` maps HTTP methods onto Django's own `add`/`change`/`delete` model permissions. Every class in `permission_classes` must pass — the list is an AND, and the first failure ends the request.

Key features

  • DRF's out-of-the-box `DEFAULT_PERMISSION_CLASSES` is `["rest_framework.permissions.AllowAny"]` — an endpoint you forget to protect is public, not closed
  • `IsAdminUser` checks `request.user.is_staff`, not `is_superuser` — the name is misleading and the difference matters
  • `SAFE_METHODS` is `("GET", "HEAD", "OPTIONS")`; `IsAuthenticatedOrReadOnly` is built entirely on that tuple
  • `DjangoModelPermissions` maps `POST` → `add_*`, `PUT`/`PATCH` → `change_*`, `DELETE` → `delete_*`, and requires the view to have a `queryset` so it can find the model
  • `DjangoModelPermissions` allows GET for anyone authenticated; use `DjangoModelPermissionsOrAnonReadOnly` or a custom class if reads need restricting too
  • The list is AND, evaluated in order — for OR you need the `|` operator, not two entries

The shipped permission classes

The shipped permission classes
ClassPasses whenWatch out for
`AllowAny`alwaysit is the default — a forgotten endpoint is public
`IsAuthenticated``request.user.is_authenticated`says nothing about *which* rows may be touched
`IsAuthenticatedOrReadOnly`safe method, or authenticatedanonymous reads still see everything `get_queryset()` returns
`IsAdminUser``request.user.is_staff`staff, not superuser
`DjangoModelPermissions`the mapped model permission is heldneeds `queryset` on the view; GET is unrestricted
`DjangoObjectPermissions`per-object model permission is heldneeds a backend such as django-guardian; not usable alone
What each shipped class actually gates
AllowAny
the default — allows every cell
IsAuthenticatedOrReadOnly
anonymous reads allowed; writes require a user
IsAuthenticated
the whole right column, nothing on the left
DjangoModelPermissions
writes need add/change/delete; GET is unrestricted
IsAdminUser
is_staff only — the narrowest shipped class
  • AllowAny: between anonymous caller and authenticated caller, between safe methods · GET/HEAD/OPTIONS and unsafe methods · POST/PUT/PATCH/DELETE — the default — allows every cell
  • IsAuthenticatedOrReadOnly: anonymous caller, safe methods · GET/HEAD/OPTIONS — anonymous reads allowed; writes require a user
  • IsAuthenticated: authenticated caller, between safe methods · GET/HEAD/OPTIONS and unsafe methods · POST/PUT/PATCH/DELETE — the whole right column, nothing on the left
  • DjangoModelPermissions: authenticated caller, unsafe methods · POST/PUT/PATCH/DELETE — writes need add/change/delete; GET is unrestricted
  • IsAdminUser: authenticated caller, unsafe methods · POST/PUT/PATCH/DELETE — is_staff only — the narrowest shipped class

Command

python
class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    permission_classes = [IsAuthenticated]   # every class in the list must pass

Permission classes answer "may they?" using only `request.user` — they run before any object is fetched, so none of them can express ownership. The list is AND: every class must pass. `IsAdminUser` means `is_staff`, not superuser. The out-of-the-box default is `AllowAny`, so set `DEFAULT_PERMISSION_CLASSES` to `IsAuthenticated` and open endpoints deliberately.

2.Custom BasePermission, object-level checks, and ownership

A custom permission subclasses `BasePermission` and implements one or both of two methods. `has_permission(request, view)` runs first, before anything is fetched, and answers questions about the caller and the request. `has_object_permission(request, view, obj)` runs later, once `get_object()` has loaded a specific row, and is where an ownership check belongs. The critical detail is when the second one runs: DRF calls it from `get_object()`, which means **only** for detail actions — retrieve, update, partial_update, destroy. A list response never calls it, and neither does create, because there is no object yet. Scoping a list is `get_queryset()`'s job, not a permission's.

Key features

  • `has_permission()` returns a bool and runs for every action, before the queryset or object is touched
  • `has_object_permission()` runs only when the view calls `get_object()` — retrieve, update, partial_update, destroy, and any custom `@action(detail=True)` that uses it
  • It is NOT called for `list` and NOT called for `create` — the first has many objects, the second has none yet
  • DRF only reaches `has_object_permission()` if `has_permission()` already returned `True` on the same class
  • Returning `False` produces 403; raising `PermissionDenied("...")` produces 403 with your own message; raising `NotFound` hides the row's existence entirely
  • A `@action(detail=True)` that fetches with `Model.objects.get(pk=pk)` instead of `self.get_object()` skips object permissions silently — no error, no log line

Which hook runs for which action

Which hook runs for which action
Actionhas_permission()has_object_permission()What actually protects the data
`list`yes**no**`get_queryset()` filtering
`create`yes**no** (no object yet)`has_permission()` + serializer/`perform_create()`
`retrieve`yesyesboth
`update` / `partial_update`yesyesboth
`destroy`yesyesboth
`@action(detail=True)`yesonly if it calls `self.get_object()`both — if written correctly
When each permission hook is called — and the one place DRF never calls the second
Client
DRF view
Permission class
Queryset
  1. 1GET /orders/ (list)
  2. 2has_permission(request, view)
  3. 3True
  4. 4get_queryset()the ONLY thing scoping a list — has_object_permission is never called here
  5. 5200 with whatever rows the queryset returned
  6. 6PATCH /orders/57/ (detail)
  7. 7has_permission(request, view)
  8. 8get_object() — fetches row 57
  9. 9has_object_permission(request, view, obj)reached only via get_object(); a raw .get(pk=) skips it
  10. 10False → 403 Forbidden
  1. Client → DRF view: GET /orders/ (list)
  2. DRF view → Permission class: has_permission(request, view)
  3. Permission class → DRF view: True
  4. DRF view → Queryset: get_queryset() (the ONLY thing scoping a list — has_object_permission is never called here)
  5. Queryset → Client: 200 with whatever rows the queryset returned
  6. Client → DRF view: PATCH /orders/57/ (detail)
  7. DRF view → Permission class: has_permission(request, view)
  8. DRF view → Queryset: get_object() — fetches row 57
  9. DRF view → Permission class: has_object_permission(request, view, obj) (reached only via get_object(); a raw .get(pk=) skips it)
  10. Permission class → Client: False → 403 Forbidden

Command

python
class IsOwner(BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.customer_id == request.user.id

`has_permission()` runs before anything is fetched and can only reason about the caller; `has_object_permission()` runs from `get_object()` and is where ownership belongs. It is never called for `list` or `create` — scoping a collection is `get_queryset()`'s job. A custom `@action(detail=True)` must fetch through `self.get_object()` or it silently skips the object check. Where existence is sensitive, filter the queryset so the answer is 404, not 403.

3.Role-based permissions, composition, and why validation is not authorization

Role-based access means the permission is attached to a role — "support agent", "billing admin" — and users get the role, rather than each user being granted rights one at a time. Django's `Group` model is the built-in home for this, and a permission class asks whether the caller is in the group. Composition is DRF's operator support: permission classes combine with `&`, `|`, `~` and parentheses, so `[IsAuthenticated & (IsOwner | IsSupportAgent)]` is a single expression rather than a hand-written class. And the rule the roadmap states outright: **serializer validation does not replace authorization**. A serializer checks that a value is well-formed. It does not check that this caller is allowed to send it.

Key features

  • Permission classes compose with `&` (and), `|` (or), `~` (not), and parentheses; precedence is `~` then `&` then `|`, the same as Python's own operators
  • A plain list is already an implicit AND — `[A, B]` and `[A & B]` behave the same, so the operators earn their keep when you need OR or NOT
  • `Group` is Django's role primitive: `request.user.groups.filter(name="support").exists()`, or better, cache the role on the request
  • Roles are not the same as Django model permissions — a group can carry model permissions AND be used directly as a role by a custom class
  • A serializer validating `status` against its choices does not check who may set it; a writable field is reachable by anyone whose request passes `has_permission()`
  • The fix for a field only some roles may write is `read_only` plus an explicit server-side transition, or a role check inside `validate_<field>()` reading `self.context["request"]`

Composition, read aloud

Composition, read aloud
ExpressionMeans
`[IsAuthenticated, IsOwner]`both must pass — the plain-list AND
`[IsOwner | IsSupportAgent]`either one passing is enough
`[IsAuthenticated & (IsOwner | IsAdminUser)]`signed in, and then owner or admin
`[~IsBanned]`passes as long as `IsBanned` does not
`[IsAuthenticated, IsOwner | IsAdminUser]`same as the third row — the list AND still applies between entries
Reading a composed permission expression

permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)]

IsAuthenticated

the cheap outer gate — Runs in has_permission() with nothing loaded. An anonymous caller is rejected here, before any query.

&

AND — both sides must pass — Binds tighter than |, so the parentheses on the right are what make this read as "signed in, and then owner-or-admin".

IsOwner

the object-level branch — Implements has_object_permission(), so it only decides anything on detail routes. It cannot scope a list.

|

OR — either branch suffices — The reason to reach for operators at all. A plain list is already AND, so this is what a list cannot express.

IsAdminUser

the role escape hatch — Checks is_staff on the caller, so staff bypass the ownership branch without a second custom class.

  • Whole: permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)]
  • IsAuthenticated — the cheap outer gate: Runs in has_permission() with nothing loaded. An anonymous caller is rejected here, before any query.
  • & — AND — both sides must pass: Binds tighter than |, so the parentheses on the right are what make this read as "signed in, and then owner-or-admin".
  • IsOwner — the object-level branch: Implements has_object_permission(), so it only decides anything on detail routes. It cannot scope a list.
  • | — OR — either branch suffices: The reason to reach for operators at all. A plain list is already AND, so this is what a list cannot express.
  • IsAdminUser — the role escape hatch: Checks is_staff on the caller, so staff bypass the ownership branch without a second custom class.

Command

python
permission_classes = [IsAuthenticated & (IsOwner | IsAdminUser)]
# & and | build a single composed class; ~ negates one

Attach permissions to roles (Django `Group`s), not to individuals, and express combinations with `&`, `|`, `~` rather than writing an `IsThisOrThat` class per combination — a plain list is already AND, so the operators earn their place on OR and NOT. And keep the two questions apart: a serializer says the value is well-formed, never that this caller may set it. Server-controlled fields belong in `read_only_fields`, with each state transition behind its own endpoint and its own permission.

Remember

  • has_permission() runs before anything is fetched and can only reason about the caller; has_object_permission() runs from get_object() and is where ownership belongs.
  • DRF never calls has_object_permission() for a list or a create — a permission class that only implements it protects the detail routes and nothing else.
  • A custom @action(detail=True) must fetch through self.get_object(), or it skips object permissions with no error and no log line.
  • Where the existence of a row is itself sensitive, filter it out in get_queryset() so the answer is 404 — a 403 confirms the row exists.

Pro tip

  • Compose with & and | instead of writing an IsOwnerOrAdminOrSupport class per combination — and keep parameterised permissions as classes, since DRF instantiates them per request.
  • Any field the server controls (status, total, owner) belongs in read_only_fields, with each transition behind its own endpoint and its own permission class.

Next up

  • DRF Filtering, Search, and Ordering
  • DRF Pagination
  • API Error Handling

Sources

  • DRF Permissions — django-rest-framework.org/api-guide/permissions/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.