Filter concepts by levelShowing all levels.

Django · Section 48

DRF Permissions

Level
advanced
Read
28 min
Concepts
3

Permission classes answer "may this caller do this?" once authentication has answered "who is this?". The shipped set — `AllowAny`, `IsAuthenticated`, `IsAuthenticatedOrReadOnly`, `IsAdminUser` (which checks `is_staff`, not `is_superuser`), `DjangoModelPermissions` — gates on the caller alone, which is exactly why none of them can express ownership: they run before any row is loaded. A custom class subclasses `BasePermission` and implements `has_permission(request, view)`, `has_object_permission(request, view, obj)`, or both. The distinction that decides whether an API leaks data is when the second one runs: DRF calls it from `get_object()`, so it fires for retrieve, update, partial_update and destroy — and never for `list` or `create`. Scoping a collection is therefore `get_queryset()`'s job, and a permission class implementing only the object-level hook protects the detail routes while the list route returns the whole table. Roles belong in Django `Group`s; combinations belong in `&`/`|`/`~` expressions rather than a new class per combination. And the rule this section states outright: serializer validation does not replace authorization — a well-formed `status="refunded"` is still an access-control decision, which is what `read_only_fields` and per-transition endpoints exist for.

What is true here

  1. permission_classes is an AND list evaluated in order; the shipped classes gate on the caller and cannot express ownership.
  2. has_object_permission() runs only from get_object() — never for list, never for create.
  3. A collection is protected by get_queryset() filtering, not by a permission class.
  4. Compose roles with &, |, ~ instead of writing a class per combination; keep parameterised permissions as classes.
  5. A serializer checks that a value is well-formed, never that this caller may set it — server-controlled fields go in read_only_fields.

What you will be able to do

  • Pick the right shipped permission class, and know why `IsAdminUser` is not "superuser"
  • Write a custom `BasePermission` with the correct hook for the question being asked
  • Protect list endpoints in `get_queryset()` rather than assuming permissions cover them
  • Choose between 403 and 404 deliberately when the existence of a row is itself sensitive
  • Express role logic with composition operators, and keep authorization out of serializer validation
Where each check runs, and which route it actually protects
any classreturns FalseGET /orders/POST/orders//orders/57/detailroutes onlyFalselist — no objectcheck exists

request.user resolved

authentication has already run

has_permission(request, view)

every class in the list, in order — AND

list action

no object is ever fetched

create action

the object does not exist yet

retrieve / update / destroy

calls get_object()

get_queryset() filtering

THE protection for a collection

has_object_permission(..., obj)

ownership lives here

403 Forbidden

or 404, if existence is sensitive

The view body runs

  • request.user resolved — authentication has already run
    • leads to has_permission(request, view)
  • has_permission(request, view) — every class in the list, in order — AND
    • on error, leads to 403 Forbidden (any class returns False)
    • leads to list action (GET /orders/)
    • leads to create action (POST /orders/)
    • leads to retrieve / update / destroy (/orders/57/)
  • list action — no object is ever fetched
    • leads to get_queryset() filtering
  • create action — the object does not exist yet
    • leads to The view body runs
  • retrieve / update / destroy — calls get_object()
    • leads to get_queryset() filtering
  • get_queryset() filtering — THE protection for a collection
    • leads to has_object_permission(..., obj) (detail routes only)
    • leads to The view body runs (list — no object check exists)
  • has_object_permission(..., obj) — ownership lives here
    • on error, leads to 403 Forbidden (False)
    • leads to The view body runs
  • 403 Forbidden — or 404, if existence is sensitive
  • The view body runs

The shipped classes

What each one gates, and why the default being AllowAny matters more than it looks.

AllowAny, IsAuthenticated, IsAdminUser, and the shipped set

standardbeginner

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.

Think of it as

The shipped classes are deliberately coarse: they answer questions about the *caller*, not about the *object*. "Are you signed in?" and "are you staff?" need nothing but `request.user`, which is why they can run before the view has fetched anything. That is also their limit — none of them can express "only the person who created this order", because at the moment they run there is no order yet. Treat them as the outer gate: they keep the obviously-unauthorised out cheaply, and anything that depends on which row is being touched belongs in `has_object_permission` or in `get_queryset()` instead. The default when you set nothing is `AllowAny`, which means an endpoint with no `permission_classes` and no global default is fully public — the failure is silent, so the global default is worth setting to `IsAuthenticated` and opening endpoints up deliberately.

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

What we're doing: Close the project by default, then open a health endpoint and a staff-only report deliberately.

config/settings.py + api/views.pypython
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
}

class HealthView(APIView):
    authentication_classes = []
    permission_classes = [AllowAny]

class RevenueReportView(generics.ListAPIView):
    queryset = Order.objects.all()
    serializer_class = RevenueSerializer
    permission_classes = [IsAdminUser]
2
The one line that changes the failure mode of the whole project: a new view that forgets `permission_classes` now inherits "signed in required" instead of "public".
6
Emptying `authentication_classes` too keeps a health check cheap — no session lookup, no token query, on an endpoint a load balancer hits constantly.
12
`IsAdminUser` is `is_staff`. If this report should be superuser-only, the shipped classes do not cover it and a custom class is needed.

Why this works: Defaulting to `IsAuthenticated` and opening endpoints one at a time turns a forgotten permission into a 403 during development rather than a public endpoint discovered in production — the mistake becomes loud instead of silent.

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

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

Together

python
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
}

class PublicStatusView(APIView):
    permission_classes = [AllowAny]     # opened deliberately, one endpoint at a time

Remember: 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.

See also: custom basepermission and object level checks · role based permissions and composition · model permissions and groups

Advertisement

Custom permissions and ownership

The two hooks, when each runs, and the list/create gap that makes get_queryset() load-bearing.

Custom BasePermission, object-level checks, and ownership

coreintermediate

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.

Think of it as

Think of the two methods as two different questions asked at two different moments, not as a fine-grained version of the same check. `has_permission` runs when the only thing that exists is the request — it can ask "are you staff?", "is this a write?", "does your token carry this scope?", but it cannot ask "is this yours?", because "this" has not been loaded. `has_object_permission` runs after the row is in memory and can ask exactly that. The trap is assuming DRF closes the loop for you on collections: it does not, and it cannot — checking ownership on every row of a 10,000-row list would mean fetching all of them and filtering in Python. So DRF draws the line explicitly at `get_object()`, and the burden of not leaking other people's rows on a list endpoint falls on `get_queryset()`. Two layers, two responsibilities: the permission class stops the wrong person from touching a row they asked for by id; the queryset makes sure rows they should not see never enter the response in the first place.

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

What we're doing: Let anyone read published orders, but restrict edits to the owner — with the list scoped so nothing leaks.

orders/permissions.py + orders/views.pypython
class IsOwnerOrReadOnly(BasePermission):
    def has_permission(self, request, view):
        # cheap gate: reads are open, writes need a signed-in user
        return request.method in SAFE_METHODS or request.user.is_authenticated

    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        return obj.customer_id == request.user.id


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = [IsOwnerOrReadOnly]

    def get_queryset(self):
        if not self.request.user.is_authenticated:
            return Order.objects.filter(is_public=True)
        return Order.objects.filter(
            Q(is_public=True) | Q(customer=self.request.user)
        )
2–4
The view-level half rejects an anonymous write immediately, before a query runs. It cannot check ownership — no object exists at this point.
6–9
The object-level half compares `obj.customer_id` to `request.user.id`. Comparing the `_id` attribute avoids a second query that `obj.customer` would trigger.
16–21
This — not the permission class — is what stops `GET /orders/` from returning someone else's rows. DRF never calls `has_object_permission()` for a list.

Why this works: The two halves cover different attack shapes. Without `get_queryset()` scoping, a list request enumerates every order in the table. Without `has_object_permission()`, a direct `PATCH /orders/57/` edits an order the caller happens to know the id of. Both are needed; neither substitutes for the other.

Relying on `has_object_permission` to scope a list endpoint

Wrong

python
class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()          # every order in the table
    permission_classes = [IsOwner]          # only has_object_permission implemented
# GET /orders/ returns every customer's orders, with no error anywhere.

Better

python
class OrderViewSet(viewsets.ModelViewSet):
    permission_classes = [IsOwner]

    def get_queryset(self):
        return Order.objects.filter(customer=self.request.user)

What you see: Detail routes behave correctly — `GET /orders/57/` on someone else's order returns 403 — while `GET /orders/` quietly returns the entire table. Every test written against the detail route passes.

Why: DRF calls `has_object_permission()` from `get_object()`, and a list response never calls `get_object()`. This is documented behaviour, not a bug: filtering thousands of rows through a Python permission check would be unusable. The consequence is that list safety is the queryset's responsibility, and a permission class that only implements the object-level hook protects exactly the routes that fetch one row.

When each permission hook is called — and the one place DRF never calls the second
Client
DRF view
Permission class
Queryset
  1. 1. GET /orders/ (list)
  2. 2. has_permission(request, view)
  3. 3. True
  4. 4. get_queryset()the ONLY thing scoping a list — has_object_permission is never called here
  5. 5. 200 with whatever rows the queryset returned
  6. 6. PATCH /orders/57/ (detail)
  7. 7. has_permission(request, view)
  8. 8. get_object() — fetches row 57
  9. 9. has_object_permission(request, view, obj)reached only via get_object(); a raw .get(pk=) skips it
  10. 10. False → 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

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

Together

python
def get_queryset(self):
    # protects list; has_object_permission protects the detail routes
    return Order.objects.filter(customer=self.request.user)

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. 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.

See also: built in permission classes · role based permissions and composition · viewsets and routers · object level and resource level authorization

Advertisement

Roles, composition, and the validation boundary

Groups as roles, the &/|/~ operators, and why a valid value is not an authorized one.

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

coreadvanced

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.

Think of it as

Two mistakes hide in this area, and they are opposites. The first is expressing roles as a tangle of custom classes — `IsOwnerOrSupport`, `IsOwnerOrSupportOrAdmin` — each one a copy of the last with an extra clause. Composition exists precisely so that logic lives in the expression, where it reads like the sentence you would say out loud, instead of multiplying into classes. The second mistake is subtler and more dangerous: assuming that because a field passed serializer validation, the caller was entitled to set it. Validation and authorization ask different questions. `status="refunded"` is a perfectly valid value of a choices field — well-formed, in range, correct type — and a serializer will accept it from anyone whose request reaches it. Whether *this* caller may move *this* order to refunded is an authorization question, and nothing in the serializer layer ever asks it. That is why a writable field on a serializer is an access-control decision, not just a schema decision.

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

What we're doing: Give support agents read access to any order while keeping writes with the owner — and stop a customer refunding their own order through a valid-looking payload.

orders/permissions.py + orders/serializers.pypython
class InGroup(BasePermission):
    group_name = None

    def has_permission(self, request, view):
        return request.user.groups.filter(name=self.group_name).exists()


class IsSupportAgent(InGroup):
    group_name = "support"


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated & (IsOwnerOrReadOnly | IsAdminUser)]


class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["id", "items", "total", "status"]
        read_only_fields = ["status", "total"]

# Refunds move through their own endpoint, guarded by its own permission:
#   POST /orders/57/refund/  ->  @action(detail=True, permission_classes=[IsAdminUser])
1–9
One base class plus a subclass per role replaces a family of near-identical permissions — and stays a *class*, which is what DRF's operators need. `has_permission()` is the right hook: group membership is a property of the caller, not of the row.
14
The composed expression states the rule once: signed in, and then either the owner (with reads open to all) or staff. No `IsOwnerOrAdmin` class is needed.
21
The line that actually prevents the refund. `status` and `total` are server-controlled, so making them read-only removes them from the writable surface entirely.
24
A state change with its own authorization rule gets its own endpoint and its own permission — explicit, auditable, and impossible to reach through a generic PATCH.

Why this works: Composition keeps the access rule readable in one line, and `read_only_fields` closes the gap composition cannot: a permission class decides whether the request may proceed, not which fields it may carry. Those are different questions, and only the serializer answers the second.

Trusting a `ChoiceField` to gate a state transition

Wrong

python
class OrderSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["id", "items", "total", "status"]   # status is writable

# PATCH /orders/57/  {"status": "refunded"}
# 200 OK — "refunded" is a valid choice, and the caller owns order 57.

Better

python
class Meta:
    model = Order
    fields = ["id", "items", "total", "status"]
    read_only_fields = ["status", "total"]

# The transition lives behind its own authorization:
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
def refund(self, request, pk=None):
    order = self.get_object()
    order.refund()
    return Response(OrderSerializer(order).data)

What you see: Customers refund their own orders. Nothing errors, nothing is logged as suspicious, and the payload passes every validator — because `"refunded"` genuinely is one of the field's choices and the caller genuinely does own the order.

Why: Validation asks "is this value well-formed?". Authorization asks "is this caller allowed to set it?". A `ChoiceField` only ever answers the first, and `IsOwner` only answers "may you touch this row at all" — neither one asks whether the owner may move the row to *this particular* state. Any field the server controls has to be read-only on the serializer, with transitions exposed as explicit endpoints carrying their own permission classes.

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.

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

Together

python
class OrderViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated & (IsOwner | IsSupportAgent)]

Remember: 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.

See also: custom basepermission and object level checks · built in permission classes · model permissions and groups · serializer validation

Advertisement