There is an easy trap here—I fell into it the first time I used DefaultRouter. The browsable API gained a neat Login link, so the root looked protected. It was not. A login view gives the browser a way to create a session; a permission class is what actually refuses an anonymous request.

The policy in one minute

  • Authentication answers “who made this request?” and populates request.user and request.auth.

  • Permissions answer “may this identity use this view?” before the view body runs.

  • SessionAuthentication fits a browser or same-origin AJAX client using Django sessions.

  • IsAuthenticated denies anonymous requests and admits authenticated users; it does not mean staff-only.

  • A DefaultRouter generates routes, but does not establish an authorization policy by itself.

Tested baseline

The configuration below follows Django 5.2 LTS and the current Django REST Framework documentation. It assumes django.contrib.auth, sessions, messages, static files, and rest_framework are installed, migrations have run, and a router already registers at least one viewset.

Add browsable API login and logout routes

project/urls.pypython
from django.contrib import admin
from django.urls import include, path
 
from api.urls import router
 
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
    path(
        "api-auth/",
        include("rest_framework.urls", namespace="rest_framework"),
    ),
]

What these URL patterns really do

  • include(router.urls) exposes the API root and routes generated from registered viewsets.

  • The api-auth/ include supplies Django-backed login and logout views used by the browsable API.

  • The namespace prevents URL-name collisions and is the convention used by DRF.

  • This code does not deny anonymous API requests; the permission configuration below does that.

Require a session and an authenticated user

project/settings.pypython
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

Two defaults, two separate decisions

  • SessionAuthentication reads Django’s signed session cookie and sets request.user; it does not accept a username and password on every API request.

  • IsAuthenticated checks that the resolved user is authenticated before allowing the view to execute.

  • Global defaults apply to DRF views unless a view or viewset explicitly overrides them.

  • Session-authenticated unsafe methods such as POST, PUT, PATCH, and DELETE require a valid CSRF token.

  • An unauthenticated denial commonly returns HTTP 403 with session authentication because that authenticator does not issue a WWW-Authenticate challenge.

Override policy for one viewset when needed

api/views.pypython
from rest_framework.permissions import IsAdminUser
from rest_framework.viewsets import ModelViewSet
 
from .models import AuditEvent
from .serializers import AuditEventSerializer
 
 
class AuditEventViewSet(ModelViewSet):
    queryset = AuditEvent.objects.all()
    serializer_class = AuditEventSerializer
    permission_classes = [IsAdminUser]

An override replaces the global list

  • permission_classes on a viewset replaces DEFAULT_PERMISSION_CLASSES for that viewset.

  • IsAdminUser checks user.is_staff; it is stricter than merely being logged in.

  • Model-level permissions or object-level rules may be needed when different users can see different records.

  • Permission checks do not automatically filter list querysets—scope get_queryset() when data visibility depends on the user.

Create an administrator without exposing a password

Django project rootbash
python manage.py migrate
python manage.py createsuperuser --username admin --email admin@example.com
Password:
Password (again):
Superuser created successfully.

Risk level: caution. Review the command before running it.

Keep credentials out of shell history

  • migrate is marked caution because it changes the configured database; inspect pending migrations before production use.

  • createsuperuser prompts securely and stores a password hash, not the raw password.

  • Use a strong unique password and do not bypass Django’s password validators for a real account.

  • Replace the example email, use individual accounts, and enable stronger organizational controls where available.

  • For automated provisioning, use a secret manager and an idempotent management command rather than committing credentials.

Verify denial before testing login

Any terminalbash
curl -i -H "Accept: application/json" http://127.0.0.1:8000/api/
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"detail":"Authentication credentials were not provided."}

The status code follows the authenticator

  • The Accept header requests JSON so the check is not confused by browsable HTML.

  • With session authentication, an anonymous permission denial is normally 403 rather than a redirect to login.

  • A 401 response is typical when the highest-priority authenticator supplies an authentication challenge.

  • Verify an actual resource endpoint too; protecting only a custom root view is not sufficient.

Log in through the browser and verify access

  1. Open http://127.0.0.1:8000/api/ and follow the Login link, or visit /api-auth/login/ directly.

  2. Submit the account credentials over HTTPS in any non-local environment.

  3. Return to /api/; the session cookie identifies the user and the protected routes should appear.

  4. Open a private browsing window and confirm the same URL is denied anonymously.

  5. Log out and verify the prior session can no longer access the endpoint.

Lock the behavior in an API test

api/tests/test_permissions.pypython
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
 
 
class ApiRootPermissionTests(APITestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(
            username="reader", password="a-test-only-password"
        )
        self.url = reverse("api-root")
 
    def test_anonymous_user_is_denied(self):
        response = self.client.get(self.url, format="json")
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
    def test_session_user_can_open_api_root(self):
        self.client.force_login(self.user)
        response = self.client.get(self.url, format="json")
        self.assertEqual(response.status_code, status.HTTP_200_OK)

This test guards the security boundary

  • get_user_model() respects projects with a custom user model.

  • create_user() hashes the password correctly; assigning the password field directly would not.

  • force_login() isolates permission behavior without retesting the login form.

  • The exact anonymous status assertion is correct for this session-only configuration; change it deliberately if authenticator order changes.

  • Add resource-level tests so list, retrieve, create, update, delete, and custom actions match the intended policy.

CSRF is expected with browser sessions

  • Safe requests such as GET can use the authenticated session without a CSRF header.

  • Unsafe session-authenticated requests require Django’s CSRF cookie/token pairing.

  • The browsable API forms handle the token for normal use.

  • Same-origin JavaScript should read the CSRF cookie as documented and send X-CSRFToken.

  • Do not “fix” a 403 by broadly applying csrf_exempt; determine whether authentication, permission, or CSRF rejected the request.

  • Native apps and third-party clients usually need a purpose-built token or OAuth/OIDC scheme instead of browser sessions.

Troubleshooting without weakening security

  • Login appears but anonymous users still see data: add an effective permission class and check for per-view AllowAny overrides.

  • Logged-in POST returns 403: inspect the response and server logs for CSRF failure; include the CSRF token instead of disabling protection.

  • Every request returns 403 after login: confirm session and authentication middleware, cookies, host/domain settings, and SessionAuthentication.

  • Expected 401 but received 403: response selection depends on the highest-priority authenticator and its challenge header.

  • Users see other users’ records: permissions alone may not scope list data; filter the queryset and test object access.

  • API root is protected but schema/docs are public: apply explicit permissions to schema and documentation views too.

Production security checklist

  • Serve login and API traffic exclusively over HTTPS; enable secure session and CSRF cookies.

  • Run python manage.py check --deploy and review every warning in the deployed environment.

  • Set ALLOWED_HOSTS, trusted CSRF origins, proxy HTTPS headers, HSTS, and cookie settings for the real topology.

  • Keep DEBUG=False, rotate a leaked secret key, and never expose stack traces or environment secrets.

  • Use least-privilege accounts; reserve superusers for administration rather than routine API consumption.

  • Add throttling, audit logs, monitoring, dependency updates, and tests, while recognizing throttling is not a complete brute-force defense.

  • Document which endpoints are public, authenticated, staff-only, model-permission controlled, or object-scoped.

Official references