405 Method Not Allowed
Client error response defined by RFC 9110 §15.5.6.
Last updated July 27, 2026
Common causes at a glance
- CORS preflight OPTIONS hitting a route that only registers POST
- A static file host rejecting anything other than GET and HEAD
- A reverse proxy or WAF allowlisting methods and blocking PUT, PATCH, or DELETE
- A client using POST where the API defines PUT, or vice versa
Reproduce it
curl -i -X OPTIONS -H 'Access-Control-Request-Method: POST' https://api.example.com/v1/itemsWhat 405 Method Not Allowed means
405 is more informative than 404, because it confirms
the path resolved. A resource that serves GET and HEAD but not
POST should answer 405 with Allow: GET, HEAD, which lets a
client correct itself without guessing.
The version people actually hit is a browser CORS preflight failing. Before a cross-origin
request with a non-simple method or header, the browser sends OPTIONS to the
same URL. If your framework only routes POST on that path, the preflight gets
405, the browser cancels the real request, and the console shows a CORS error that never
mentions the method. It looks like a CORS misconfiguration and is really a routing gap.
What the spec says
RFC 9110 §15.5.6 requires that the origin server
MUST generate an Allow header field in a 405 response
listing the target resource’s currently supported methods; the field itself is
§10.2.1. That MUST is widely ignored, a shame because it is the
one machine-readable thing the response could carry. 405 also means the method is
known but unsupported here — if the server does not implement it at all,
§15.6.2 says 501 is correct. Like 404, a 405 is heuristically
cacheable (RFC 9111 §4.2.2), so a cache can keep serving it after
you add the route. For preflight rules, MDN’s
CORS guide is the
practical reference.
What actually causes it
The preflight case dominates in web applications. Express, Flask, and
most routers bind handlers per method, so OPTIONS on /v1/items
is unrouted unless a CORS middleware registers it, and the resulting 405 surfaces in the
browser as an opaque “preflight did not succeed” message. Static hosts are second: S3
website endpoints and most CDN configurations serve only GET and
HEAD, so a POST a single-page app expects to be proxied returns
405 from the edge. Third is method filtering in infrastructure: proxies and WAFs often
ship with PUT, DELETE, and PATCH disabled, breaking
a REST API the moment it leaves the laptop. If GET works and only writes
fail, suspect the edge.
How to debug it
Ask the resource what it supports. OPTIONS plus the
Allow header is the intended mechanism, and its absence is itself a finding:
curl -si -X OPTIONS https://api.example.com/v1/items | grep -iE '^(HTTP/|allow)'
For a suspected preflight failure, send the preflight exactly as the browser would, with
Origin and Access-Control-Request-Method set. A correct response
is 204 or 200 carrying Access-Control-Allow-Methods; a 405 means the route is
missing, not that the CORS headers are wrong. Then work out which hop answered — a 405
from the edge with no matching application log entry is a proxy method filter. The
curl builder assembles a reproduction for whoever owns that layer.
Working examples
curl
# What does the resource actually allow?
curl -si -X OPTIONS https://api.example.com/v1/items \
| grep -iE '^(HTTP/|allow|access-control-allow)'
# Reproduce a browser CORS preflight precisely.
curl -si -X OPTIONS \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization' \
https://api.example.com/v1/items
# Which methods survive the edge?
for m in GET POST PUT PATCH DELETE; do
printf '%s ' "$m"
curl -o /dev/null -s -w '%{http_code}\n' -X "$m" https://api.example.com/v1/items
done
Python (requests)
import requests
url = "https://api.example.com/v1/items"
# The Allow header is mandatory on 405 and tells you the answer directly.
r = requests.request("PUT", url, timeout=10)
print(r.status_code, r.headers.get("Allow"))
# Preflight probe, exactly as a browser would send it.
pre = requests.options(
url,
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
},
timeout=10,
)
print(pre.status_code, pre.headers.get("Access-Control-Allow-Methods"))
Node (fetch)
const url = "https://api.example.com/v1/items";
// Enumerate what the edge and app actually accept.
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
const res = await fetch(url, { method });
console.log(method.padEnd(7), res.status, res.headers.get("allow") ?? "");
}
// Preflight check: 405 here breaks every cross-origin write.
const pre = await fetch(url, {
method: "OPTIONS",
headers: {
Origin: "https://app.example.com",
"Access-Control-Request-Method": "POST",
},
});
console.log("preflight", pre.status, pre.headers.get("access-control-allow-methods"));Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 4xx codes
- 400 Bad Request
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request