405 Method Not Allowed — Wrong HTTP Verb
Triệu chứng
- An API endpoint returns 405 Method Not Allowed with no further explanation
- The response includes an `Allow` header listing which methods are accepted (e.g., `Allow: GET, HEAD, OPTIONS`) — your request used a different method
- The same URL works when accessed with a different HTTP method, confirming the route exists but the verb is wrong
- A redirect (301 or 302) changes a POST to a GET internally, and the destination only accepts POST, causing an unexpected 405
- A load balancer or WAF is blocking PUT, DELETE, or PATCH while allowing GET and POST — these methods never reach the application server
- The response includes an `Allow` header listing which methods are accepted (e.g., `Allow: GET, HEAD, OPTIONS`) — your request used a different method
- The same URL works when accessed with a different HTTP method, confirming the route exists but the verb is wrong
- A redirect (301 or 302) changes a POST to a GET internally, and the destination only accepts POST, causing an unexpected 405
- A load balancer or WAF is blocking PUT, DELETE, or PATCH while allowing GET and POST — these methods never reach the application server
Nguyên nhân gốc rễ
- Client sending POST to a GET-only endpoint or GET to a POST-only endpoint — a common mistake when reading API docs or copy-pasting curl commands
- URL typo causing the request to hit a different route than intended, one that only accepts a different method
- REST framework auto-generating method restrictions based on the view class — a `ListAPIView` only accepts GET; `CreateAPIView` only accepts POST
- A 301/302 redirect from the original URL changes the method from POST to GET per RFC 7231 (browsers follow this; curl follows with `-L`)
- Load balancer, WAF, or API gateway blocking specific HTTP methods like PUT, DELETE, or PATCH at the network layer before they reach the application
Chẩn đoán
**Step 1 — Read the Allow header to see what the endpoint accepts**
```bash
curl -v -X POST https://api.example.com/resource/123
# Look for:
# < HTTP/1.1 405 Method Not Allowed
# < Allow: GET, HEAD, OPTIONS
# Means this endpoint only accepts GET. Use GET instead.
```
**Step 2 — Send an OPTIONS request to discover allowed methods**
```bash
curl -v -X OPTIONS https://api.example.com/resource/123
# Django REST Framework / FastAPI return Allow header on OPTIONS:
# < Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
# < HTTP/1.1 200 OK
```
**Step 3 — Check for redirect-induced method change**
```bash
# Use -v to see each hop and its method
curl -v -L -X POST https://api.example.com/resource
# If you see:
# < HTTP/1.1 301 Moved Permanently
# < Location: https://api.example.com/resource/
# > POST (changes to GET after redirect)
# < HTTP/1.1 405 Method Not Allowed
# → The trailing slash redirect changed POST to GET
```
**Step 4 — Verify your WAF/load balancer allows the method**
```bash
# Test if the method is blocked at the network layer by bypassing it:
curl -v -X DELETE http://direct-backend-ip/resource/123 \
-H 'Host: api.example.com'
# If this works but the public URL doesn't → WAF is blocking DELETE
```
**Step 5 — Check Django URL and view configuration**
```python
# In Django REST Framework:
class ResourceViewSet(viewsets.ModelViewSet):
# ModelViewSet allows GET, POST, PUT, PATCH, DELETE by default
# If you use ReadOnlyModelViewSet — only GET is allowed
pass
# In Django function-based views:
from django.views.decorators.http import require_http_methods
@require_http_methods(['GET', 'POST']) # Only these methods are allowed
def my_view(request):
pass
```
```bash
curl -v -X POST https://api.example.com/resource/123
# Look for:
# < HTTP/1.1 405 Method Not Allowed
# < Allow: GET, HEAD, OPTIONS
# Means this endpoint only accepts GET. Use GET instead.
```
**Step 2 — Send an OPTIONS request to discover allowed methods**
```bash
curl -v -X OPTIONS https://api.example.com/resource/123
# Django REST Framework / FastAPI return Allow header on OPTIONS:
# < Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
# < HTTP/1.1 200 OK
```
**Step 3 — Check for redirect-induced method change**
```bash
# Use -v to see each hop and its method
curl -v -L -X POST https://api.example.com/resource
# If you see:
# < HTTP/1.1 301 Moved Permanently
# < Location: https://api.example.com/resource/
# > POST (changes to GET after redirect)
# < HTTP/1.1 405 Method Not Allowed
# → The trailing slash redirect changed POST to GET
```
**Step 4 — Verify your WAF/load balancer allows the method**
```bash
# Test if the method is blocked at the network layer by bypassing it:
curl -v -X DELETE http://direct-backend-ip/resource/123 \
-H 'Host: api.example.com'
# If this works but the public URL doesn't → WAF is blocking DELETE
```
**Step 5 — Check Django URL and view configuration**
```python
# In Django REST Framework:
class ResourceViewSet(viewsets.ModelViewSet):
# ModelViewSet allows GET, POST, PUT, PATCH, DELETE by default
# If you use ReadOnlyModelViewSet — only GET is allowed
pass
# In Django function-based views:
from django.views.decorators.http import require_http_methods
@require_http_methods(['GET', 'POST']) # Only these methods are allowed
def my_view(request):
pass
```
Giải quyết
**Fix 1: Match the HTTP method to what the API specifies**
```bash
# If Allow: GET, read the resource with GET:
curl https://api.example.com/resource/123
# If Allow: POST, create with POST (include a body):
curl -X POST https://api.example.com/resource/ \
-H 'Content-Type: application/json' \
-d '{"name": "example"}'
# For updates, use PATCH (partial) or PUT (full replacement):
curl -X PATCH https://api.example.com/resource/123 \
-H 'Content-Type: application/json' \
-d '{"name": "updated"}'
```
**Fix 2: Handle the redirect method-change in HTTP client**
```python
import httpx
# httpx preserves the method on redirect by default for POST:
resp = httpx.post('https://api.example.com/resource', json=data)
# Or use follow_redirects=False and handle manually:
resp = httpx.post(
'https://api.example.com/resource',
json=data,
follow_redirects=False,
)
if resp.status_code == 301:
# POST to the final URL directly
resp = httpx.post(resp.headers['location'], json=data)
```
**Fix 3: Expose the correct methods in Django REST Framework**
```python
from rest_framework import viewsets
from rest_framework.decorators import action
class ItemViewSet(viewsets.ModelViewSet):
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""Custom action on POST /items/{pk}/publish/"""
...
```
**Fix 4: Configure WAF to allow required methods**
For AWS WAF or Cloudflare, add a rule that allows PUT/DELETE/PATCH for API paths like `/api/*` while keeping restrictions on other paths.
```bash
# If Allow: GET, read the resource with GET:
curl https://api.example.com/resource/123
# If Allow: POST, create with POST (include a body):
curl -X POST https://api.example.com/resource/ \
-H 'Content-Type: application/json' \
-d '{"name": "example"}'
# For updates, use PATCH (partial) or PUT (full replacement):
curl -X PATCH https://api.example.com/resource/123 \
-H 'Content-Type: application/json' \
-d '{"name": "updated"}'
```
**Fix 2: Handle the redirect method-change in HTTP client**
```python
import httpx
# httpx preserves the method on redirect by default for POST:
resp = httpx.post('https://api.example.com/resource', json=data)
# Or use follow_redirects=False and handle manually:
resp = httpx.post(
'https://api.example.com/resource',
json=data,
follow_redirects=False,
)
if resp.status_code == 301:
# POST to the final URL directly
resp = httpx.post(resp.headers['location'], json=data)
```
**Fix 3: Expose the correct methods in Django REST Framework**
```python
from rest_framework import viewsets
from rest_framework.decorators import action
class ItemViewSet(viewsets.ModelViewSet):
http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
"""Custom action on POST /items/{pk}/publish/"""
...
```
**Fix 4: Configure WAF to allow required methods**
For AWS WAF or Cloudflare, add a rule that allows PUT/DELETE/PATCH for API paths like `/api/*` while keeping restrictions on other paths.
Phòng ngừa
- **Document the HTTP method alongside every endpoint URL** in your API documentation — many 405 errors occur simply because the caller read `/api/resource/123` without noticing the required method
- **Return 405 with a helpful Allow header** from your application — Django REST Framework does this automatically, but custom views may not
- **Use POST to the exact destination URL** for mutation requests — avoid relying on redirect following for anything other than GET requests, since redirects change POST to GET per HTTP spec
- **Test all methods on every endpoint in your API test suite** — a simple check that `GET /resource` returns 200 and `POST /resource` returns 405 (or 201) catches method routing bugs before production
- **Return 405 with a helpful Allow header** from your application — Django REST Framework does this automatically, but custom views may not
- **Use POST to the exact destination URL** for mutation requests — avoid relying on redirect following for anything other than GET requests, since redirects change POST to GET per HTTP spec
- **Test all methods on every endpoint in your API test suite** — a simple check that `GET /resource` returns 200 and `POST /resource` returns 405 (or 201) catches method routing bugs before production