Skip to content
Control Plane Labs

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/items

What 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