Beginner 5 min HTTP 401

Getting 401 Instead of 403 (or Vice Versa)

Sintomas

- API returns 401 even though the request includes a valid JWT — the client is authenticated but lacks permission
- API returns 403 for a missing or expired token, confusing clients that expect to re-authenticate on 401
- OAuth2 clients interpret a 403 as "permanently denied" and don't attempt token refresh, causing silent failures
- API documentation says 401 means "login required" but the server sends it for role-based access failures too
- Browser devtools shows a CORS preflight failing with 401, but the real issue is a missing `Authorization` header

Causas raiz

  • Confusion between authentication (who are you?) and authorization (what are you allowed to do?) — 401 = unauthenticated, 403 = authenticated but forbidden
  • Framework middleware returns 401 for all auth failures without distinguishing missing credentials from insufficient permissions
  • JWT is present and valid but the token's claims (`role`, `scope`) don't include the required permission — should be 403, not 401
  • Token is expired — correct response is 401 with `WWW-Authenticate: Bearer error="invalid_token"` to signal re-authentication
  • Custom auth decorator returns a hardcoded status without checking whether authentication itself failed or authorization was denied

Diagnóstico

**Step 1: Decode the JWT to check claims**
```bash
# Paste token at jwt.io or use the JWT Decoder tool above
# Check: is the token expired? Does it include the required role/scope?
```

**Step 2: Check the WWW-Authenticate response header**
```bash
curl -I -H 'Authorization: Bearer <token>' https://api.example.com/resource
# 401 should include: WWW-Authenticate: Bearer realm="api"
# 403 should NOT include WWW-Authenticate
```

**Step 3: Trace the auth middleware stack**
```python
# Django REST Framework example
# 401 → authentication_classes failed to identify the user
# 403 → permission_classes denied an identified user
print(view.authentication_classes) # who am I?
print(view.permission_classes) # what can I do?
```

**Step 4: Reproduce with curl using no token vs wrong role**
```bash
# No token at all → expect 401
curl -I https://api.example.com/admin
# Valid user token, wrong role → expect 403
curl -I -H 'Authorization: Bearer <user_token>' https://api.example.com/admin
```

Resolução

**The correct mapping:**
| Situation | Correct Code |
|-----------|-------------|
| No token or invalid token format | 401 |
| Expired token | 401 (with `WWW-Authenticate`) |
| Valid token, wrong role/permission | 403 |
| Resource exists but user can never access it | 403 |

**Fix: Django REST Framework — split 401 vs 403**
```python
from rest_framework.exceptions import PermissionDenied, NotAuthenticated
from rest_framework.permissions import BasePermission

class IsAdmin(BasePermission):
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
raise NotAuthenticated() # → 401
if not request.user.is_staff:
raise PermissionDenied() # → 403
return True
```

**Fix: Add WWW-Authenticate header on 401**
```python
from django.http import HttpResponse

def unauthenticated_response():
resp = HttpResponse(status=401)
resp['WWW-Authenticate'] = 'Bearer realm="api", charset="UTF-8"'
return resp
```

Prevenção

- **Team convention**: Document in your API spec (OpenAPI/Swagger) exactly which conditions return 401 vs 403 — enforce it in code review
- **Unit test both paths**: Write separate tests for `test_returns_401_when_no_token` and `test_returns_403_when_wrong_role`
- **OAuth2 client handling**: Ensure clients refresh the token on 401 and show a "permission denied" UI on 403 — mixing them up causes silent infinite loops
- **Never return 404 to hide resources**: Some teams return 404 on unauthorized access to avoid revealing resource existence — only do this deliberately and document it

Códigos de status relacionados

Termos relacionados