Skip to content

Commit 022855b

Browse files
committed
DRF auth bug post
1 parent b825694 commit 022855b

File tree

3 files changed

+154
-2
lines changed

3 files changed

+154
-2
lines changed

_config.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ title : "orez-rj"
2121
title_separator : "-"
2222
subtitle : # site tagline that appears below site title in masthead
2323
name : "Or Ezra"
24-
description : "Security, Cryptography, and scaleable real-time systems"
24+
description : "Security, Cryptography, Scaleable real-time and distributed systems"
2525
url : # the base hostname & protocol for your site e.g. "https://mmistakes.github.io"
2626
baseurl : # the subpath of your site, e.g. "/blog"
2727
repository : # GitHub username/repo-name e.g. "mmistakes/minimal-mistakes"
@@ -115,7 +115,7 @@ analytics:
115115
author:
116116
name : "Or Ezra"
117117
avatar : "/assets/images/main/avatar.png"
118-
bio : "Security, Cryptography, and scaleable real-time systems"
118+
bio : "Security-curious backend dev"
119119
location : "Israel"
120120
email :
121121
links:
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
title: "The Sneaky DRF Authentication Bug: When 401 Turns into 403"
3+
classes: wide
4+
header:
5+
teaser: /assets/images/posts/drf-auth-bug.png
6+
overlay_image: /assets/images/posts/drf-auth-bug.png
7+
overlay_filter: 0.3
8+
ribbon: Crimson
9+
excerpt: "A silent bug in Django Rest Framework caused my API to return 403 instead of 401. The culprit? Authentication class order. Here's what I discovered deep in the source."
10+
description: "How DRF's authentication class order causes incorrect HTTP response codes - and why it's arguably a bug"
11+
categories:
12+
- Django Rest Framework
13+
- Bugs
14+
- Blog
15+
tags:
16+
- Django
17+
- DRF
18+
- Authentication
19+
- HTTP Status Codes
20+
- Bug Hunt
21+
- Python
22+
toc: true
23+
toc_sticky: true
24+
toc_label: "DRF Auth Internals"
25+
toc_icon: "bug"
26+
---
27+
28+
# Introduction
29+
30+
Not all bugs scream at you. Some whisper in 403s.
31+
32+
I recently spent a lot of time debugging a strange behavior in a Django Rest Framework (DRF) API. When a request failed authentication, it returned a **403 Forbidden** instead of **401 Unauthorized** - and this subtle difference broke parts of my application logic.
33+
34+
After triple-checking everything and diving deep into DRF’s internals, I found the unexpected root cause: **the order of authentication classes in your settings matters more than you think**.
35+
36+
Let me walk you through what happened, what I learned, and why I believe this is a real bug in DRF.
37+
38+
# Background: DRF Authentication Flow
39+
40+
In DRF, authentication is handled by a list of classes defined in `DEFAULT_AUTHENTICATION_CLASSES`. Each class tries to authenticate the request, raising `AuthenticationFailed` on failure or returning `None` if it chooses not to act.
41+
42+
When authentication fails, DRF must return either a **401 Unauthorized** or **403 Forbidden**. According to spec, a 401 response **must** include a `WWW-Authenticate` header. DRF checks each class’s `authenticate_header()` method to decide which status to send.
43+
44+
If no class provides a header-that is, if all return `None`-DRF defaults to 403. Otherwise, it returns 401.
45+
46+
---
47+
48+
## This Is Known-and Here’s Why (but I Still Think It’s a Bug)
49+
50+
DRF documentation clearly states:
51+
52+
> "Although multiple authentication schemes may be in use, only one scheme may be used to determine the type of response. ***The first*** authentication class set on the view is used when determining the type of response." [DRF docs](https://www.django-rest-framework.org/api-guide/authentication/#unauthorized-and-forbidden-responses)
53+
54+
And [GitHub issue #3800](https://github.com/encode/django-rest-framework/issues/3800) shows this has been an intentional design decision for years.
55+
56+
### Why Was This Designed This Way?
57+
58+
Session-based authentication doesn’t define `authenticate_header()`, so returning a 401 with no header would violate RFC 7235. DRF therefore treats session auth failure as 403.
59+
60+
Because DRF uses the **first** authenticator’s header presence to decide status, even if a later class actually raises `AuthenticationFailed`, its header gets ignored if an earlier class returns `None`.
61+
62+
The rationale: session first → no header → 403; token-first → header present → 401.
63+
64+
---
65+
66+
# My Setup
67+
68+
I had this configuration:
69+
70+
```python
71+
REST_FRAMEWORK = {
72+
'DEFAULT_AUTHENTICATION_CLASSES': (
73+
rest_framework.authentication.SessionAuthentication,
74+
rest_framework.authentication.TokenAuthentication,
75+
myapp.authentication.BearerAuthentication,
76+
)
77+
}
78+
````
79+
80+
My `BearerAuthentication` inherits from `BaseAuthentication` and returns `"Bearer"` in `authenticate_header()`.
81+
82+
Yet failed bearer requests returned **403**, not **401**-because `SessionAuthentication` came first.
83+
84+
# DRF Source Code Digging
85+
86+
In `rest_framework/views.py` (around line 189), DRF handles exceptions like this:
87+
88+
```python
89+
# simplified from rest_framework.views APIView.handle_exception()
90+
91+
# WWW-Authenticate header for 401 responses, else coerce to 403
92+
auth_header = self.get_authenticate_header(self.request)
93+
94+
if auth_header:
95+
exc.auth_header = auth_header
96+
else:
97+
exc.status_code = status.HTTP_403_FORBIDDEN
98+
```
99+
100+
While `auth_header` equals:
101+
102+
```python
103+
# authentication_classes = DEFAULT_AUTHENTICATION_CLASSES or per-view
104+
authenticators = [auth() for auth in self.authentication_classes]
105+
if authenticators:
106+
return authenticators[0].authenticate_header(request) # returns the first no matter what
107+
```
108+
That means DRF picks the **first** `authenticate_header()` among all authenticators, no matter which authenticator actually failed.
109+
110+
Because `SessionAuthentication.authenticate_header()` returns `None`, every failure came through as 403-even when the error originated from my bearer auth.
111+
112+
Once I reordered my settings:
113+
114+
```python
115+
DEFAULT_AUTHENTICATION_CLASSES = (
116+
myapp.authentication.BearerAuthentication,
117+
rest_framework.authentication.SessionAuthentication,
118+
rest_framework.authentication.TokenAuthentication,
119+
)
120+
```
121+
122+
...failed bearer requests finally returned **401** as expected.
123+
124+
---
125+
126+
### Why It Still Matters
127+
128+
DRF treats this behavior as expected and arguably spec‑compliant-but it still goes against intuitive expectations. If a specific authentication scheme fails, clients should receive a response tied to that scheme’s semantics.
129+
130+
Importantly, returning the `WWW-Authenticate` header of the **class that raised** `AuthenticationFailed` would **not violate RFC 7235**. The spec requires that a **401 Unauthorized** response *must* include at least one `WWW-Authenticate` header identifying the correct authentication challenge. Choosing the header from the authenticator that failed actually aligns better with that rule, precisely informing the client which type of credentials are required-without risking a spec violation.([http.dev](https://http.dev/www-authenticate))
131+
132+
In contrast, DRF’s current approach-relying on the first listed authenticator-can lead to misleading 403 responses when a later scheme actually fails.
133+
134+
---
135+
136+
## Proposed Fix and Upcoming PR
137+
138+
I plan to open a pull request addressing issue #3800 with:
139+
140+
* A minimal reproducer test case
141+
* A patch that captures the `authenticate_header()` from the authenticator that *raised* `AuthenticationFailed`, not just from the first in the list
142+
* An explanation of how this improves DRF behavior to better match RFC 7235 and developer expectations
143+
144+
Until then, the only reliable workaround is:
145+
146+
👉 **Always put the authenticator likely to raise errors (e.g. your BearerAuthentication) *first* in the list, or implement your own exception handler**
147+
148+
# Conclusion
149+
150+
This subtle auth-status behavior can sneakily break API logic or client-side handling. DRF’s current design serves spec compliance-but sacrifices developer intuition when multiple authentication schemes are involved.
151+
152+
I hope this deep dive helps you avoid the same trap.
1.27 MB
Loading

0 commit comments

Comments
 (0)