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.userandrequest.auth.Permissions answer “may this identity use this view?” before the view body runs.
SessionAuthenticationfits a browser or same-origin AJAX client using Django sessions.IsAuthenticateddenies anonymous requests and admits authenticated users; it does not mean staff-only.A
DefaultRoutergenerates 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
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
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}Two defaults, two separate decisions
SessionAuthenticationreads Django’s signed session cookie and setsrequest.user; it does not accept a username and password on every API request.IsAuthenticatedchecks 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-Authenticatechallenge.
Override policy for one viewset when needed
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_classeson a viewset replacesDEFAULT_PERMISSION_CLASSESfor that viewset.IsAdminUserchecksuser.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
python manage.py migrate
python manage.py createsuperuser --username admin --email admin@example.comPassword:
Password (again):
Superuser created successfully.Risk level: caution. Review the command before running it.
Keep credentials out of shell history
migrateis marked caution because it changes the configured database; inspect pending migrations before production use.createsuperuserprompts 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
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
Open
http://127.0.0.1:8000/api/and follow the Login link, or visit/api-auth/login/directly.Submit the account credentials over HTTPS in any non-local environment.
Return to
/api/; the session cookie identifies the user and the protected routes should appear.Open a private browsing window and confirm the same URL is denied anonymously.
Log out and verify the prior session can no longer access the endpoint.
Lock the behavior in an API test
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
AllowAnyoverrides.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 --deployand 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
DRF authentication explains session credentials, response status behavior, and CSRF requirements.
DRF permissions defines
IsAuthenticated, global policy, overrides, and access checks.DRF quickstart shows router and optional browsable API login routes.
DRF testing documents session login and request clients.
Django authentication documents user creation and password handling.
Django security explains CSRF and HTTPS protections.
Comments and corrections