405 Method Not Allowed
Client error response defined by RFC 9110 §15.5.6.
Written and maintained by Ben Ennis
Last reviewed July 27, 2026 · How we verify this
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.
Why a 405 on HEAD is a spec violation, not just an oversight
RFC 9110 §9.1 states plainly that all
general-purpose servers MUST support the methods GET and HEAD; every other method is
optional. That MUST is not about supporting HEAD somewhere on the server, it applies per
resource: §9.3.2 defines HEAD as identical to GET except
that no content is sent, which only makes sense if HEAD is available wherever GET is. A
resource that answers 200 to GET and 405 to HEAD is not
enforcing a policy, it is failing to implement a method the specification does not treat
as optional.
This happens constantly in practice because most routers require an explicit handler per
method, and developers write GET handlers without a matching
HEAD one, assuming a framework will synthesise it. Some do: Flask, Django,
and most WSGI-based frameworks generate a HEAD response automatically from the GET
handler and strip the body. Plenty of hand-rolled routers and API gateways do not, so
uptime monitors and link checkers that intentionally use HEAD to save bandwidth get a 405
against a resource that works fine with GET, and the false alarm is the monitor’s fault
only in the sense that it followed the spec.
If a HEAD probe 405s where GET succeeds, the fix belongs in your routing, not the monitor’s configuration: register HEAD alongside GET, or configure the framework to synthesise it, rather than instructing every external checker to switch to GET as a workaround.
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.
Related reading
- Every HTTP status code, and when it shows up
The wider map: which codes you actually meet in production, and the pairs everyone confuses.
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