---
title: "Authentication, Permissions and JWT in Django REST Framework"
url: "https://nareshkumar.online/blog/drf-authentication-permissions-jwt"
author: "Naresh Kumar"
published: "2026-10-05"
updated: "2026-10-05"
category: "Engineering"
tags: ["API Design", "Django", "Django REST Framework", "Python", "Security"]
---

# Authentication, Permissions and JWT in Django REST Framework

**In short:** Authentication identifies the caller, permissions decide what they may do, and throttling limits how often they can try. Default to IsAuthenticated, scope get_queryset() to the user, add object permissions for detail views, and throttle logins.

Securing a Django REST Framework API means getting three separate layers right. Authentication works out who is calling. Permissions decide whether that caller may do this action, on this object. Throttling limits how often they can try. Each layer has defaults that are easy to misread, and the gaps between them are where data leaks.

| Layer | Question it answers | Failure response |
| --- | --- | --- |
| Authentication | Who is calling? | 401, or 403 when the scheme has no login challenge |
| Permissions | May this caller do this, to this object? | 403 (401 for an anonymous caller when the scheme has a challenge) |
| Throttling | Is the caller sending too many requests? | 429 |

## Start from safe defaults

DRF's default permission is `AllowAny`. Change it, so that a view you forget to configure is closed instead of open:

`settings.py`

```python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}
```

Public endpoints then opt out with `permission_classes = [AllowAny]`, which stands out in a code review.

## Choose an authentication scheme

| Scheme | Best for | Keep in mind |
| --- | --- | --- |
| SessionAuthentication | A browser front end on the same site as the API | Uses Django's login and cookies. Unsafe requests need a CSRF token. |
| TokenAuthentication | Simple server-to-server clients | One database token per user, with no expiry built in |
| JWT (Simple JWT) | Mobile apps, front ends on another domain, several services | Signed tokens with an expiry. Short-lived access tokens plus refresh tokens. |

### JWT with Simple JWT

[Simple JWT](https://django-rest-framework-simplejwt.readthedocs.io/) is the usual JWT package for DRF. Install it and add its views:

```bash
pip install djangorestframework-simplejwt
```

`urls.py`

```python
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]
```

A client posts a username and password to `/api/token/` and gets two tokens back: a short-lived **access** token that it sends on every request as `Authorization: Bearer <token>`, and a longer-lived **refresh** token that it uses only to get new access tokens.

`settings.py`

```python
from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
}
```

Rotation issues a new refresh token on every refresh. With the blacklist app installed (add `rest_framework_simplejwt.token_blacklist` to `INSTALLED_APPS` and run `migrate`), the old one stops working, so a stolen refresh token doesn't live long either.

> **Warning:** An access token stays valid until it expires, even after the user logs out or changes their password. Keep access tokens short-lived, minutes rather than hours, and store tokens where scripts from other origins can't read them.

## Permissions: view level and object level

The built-in classes cover the common cases: `IsAuthenticated`, `IsAdminUser`, `IsAuthenticatedOrReadOnly`, and `DjangoModelPermissions`, which maps HTTP methods to Django's add, change and delete permissions. They can be combined with `&`, `|` and `~`, as in `[IsAuthenticated & (IsOwner | IsAdminUser)]`.

For rules about one specific object, such as "only the author may edit", write a class with `has_object_permission()`:

`permissions.py`

```python
from rest_framework import permissions


class IsAuthorOrReadOnly(permissions.BasePermission):
    """Anyone may read; only the author may change or delete."""

    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.author == request.user
```

`views.py`

```python
from rest_framework import permissions, viewsets


class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.select_related("author")
    serializer_class = ArticleSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
```

> **Danger:** `has_object_permission()` runs only when a view calls `get_object()`, which detail views do. It never runs for list views. A list endpoint returns every row in its queryset, whatever your object permissions say.

### Scope private data in get\_queryset()

For data that belongs to one user, filter the queryset itself. This one line is what stops one user from listing, or even guessing their way to, another user's records:

`views.py`

```python
class NoteViewSet(viewsets.ModelViewSet):
    serializer_class = NoteSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return Note.objects.filter(owner=self.request.user)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
```

A scoped queryset also turns another user's object into a 404 rather than a 403, so the API doesn't confirm that the object exists. Staff screens that need everything can branch on `self.request.user.is_staff` inside `get_queryset()`.

## Throttle what attackers will hammer

Throttling limits requests per client over time. Login and token endpoints need it most, because they are what password-guessing scripts call.

`settings.py`

```python
REST_FRAMEWORK = {
    # ...the authentication and permission settings from above
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
        "rest_framework.throttling.ScopedRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "60/minute",
        "user": "600/minute",
        "login": "5/minute",
    },
}
```

`views.py`

```python
from rest_framework_simplejwt.views import TokenObtainPairView


class LoginView(TokenObtainPairView):
    throttle_scope = "login"
```

`ScopedRateThrottle` applies only to views that set a `throttle_scope`. Throttle counters live in Django's cache, so production needs a shared cache such as Redis. With the default per-process memory cache, each worker counts on its own. Behind a reverse proxy, set `NUM_PROXIES` so that DRF takes the client's address from `X-Forwarded-For`.

> **Note:** Throttling slows guessing down; it doesn't stop a distributed attack. Strong passwords, account lockouts and a second factor for sensitive accounts do the rest.

## A short security checklist

- [ ] Serve the API over HTTPS only. Behind a proxy, set `SECURE_PROXY_SSL_HEADER`.
- [ ] Default to `IsAuthenticated`, and mark public views with `AllowAny`.
- [ ] Scope every `get_queryset()` that returns private data to the requesting user.
- [ ] Keep JWT access tokens short-lived, and rotate and blacklist refresh tokens.
- [ ] Throttle login, token and password-reset endpoints.
- [ ] Allow cross-origin requests only from known origins, for example with django-cors-headers and `CORS_ALLOWED_ORIGINS`.
- [ ] Run `python manage.py check --deploy` before every release.

Then prove it with tests: one per endpoint showing that another user can't read or change the data. [The pytest guide](https://nareshkumar.online/blog/testing-drf-apis-with-pytest) shows a compact way to test a whole permission matrix.

## Frequently asked questions

**Should I use JWT or session authentication with Django REST Framework?**

Use sessions when the front end is a browser app on the same site as the API: Django's cookies and CSRF protection are mature, and logging out takes effect at once. Use JWT for mobile apps, third-party clients and front ends on another domain, where cookies are awkward.

**Why does my API return 403 instead of 401?**

DRF returns 401 only when the first authentication class can send a `WWW-Authenticate` challenge, as token and JWT authentication do. Session authentication can't, so unauthenticated requests get 403.

**Why doesn't my object permission apply to the list endpoint?**

`has_object_permission()` runs only from `get_object()`, and list views never call it. Filter the list in `get_queryset()` instead.

**How do I log a user out when using JWT?**

Blacklist their refresh token, for example with Simple JWT's `TokenBlacklistView` and the blacklist app, and delete both tokens on the client. The current access token stays valid until it expires, which is why it should be short-lived.

## More in this series

This is part 2 of 5 in **Django REST Framework in practice**:

1. [DRF serializers: validation, nested writes and speed](https://nareshkumar.online/blog/drf-serializers-validation-nested-writes)
2. Authentication, permissions and JWT in DRF (this post)
3. [Pagination, filtering and search in DRF](https://nareshkumar.online/blog/drf-pagination-filtering-search)
4. [Fixing N+1 queries with select\_related and prefetch\_related](https://nareshkumar.online/blog/django-n-plus-one-queries)
5. [Testing DRF APIs with pytest](https://nareshkumar.online/blog/testing-drf-apis-with-pytest)
