405 Method Not Allowed on a POST That Should Work
The file exists. It handles POST. It works when you call it locally. Over the network:
POST /app/save.php -> 405 Method Not Allowed
No PHP error, because PHP never ran. Something in front of it answered first.
What’s actually happening
Two different things wear this error code, and telling them apart saves hours.
1. The web server refuses POST to that path. Appliance web servers — NAS boxes, managed hosting panels, anything with a built-in web UI — often ship rules that treat parts of the tree as static content. A POST to a path the server considers static is rejected before any interpreter is involved. Your code is fine and unreachable.
2. A WAF is disguising a different rejection. This is the one that cost me real time. Some web application firewalls answer with 405 for requests they blocked for entirely different reasons: a suspicious-looking body, a request to a path pattern on a blocklist, a rate limit. The status code you receive is not the reason you were rejected. You will debug HTTP methods for an hour while the actual trigger is a base64 string in your payload.
If POST works to one PHP file and 405s to another on the same server with the same client, it’s almost certainly the second kind.
The fix: one entry point you control
Stop scattering POST handlers across the tree. Route every write through a single endpoint, and keep every page file GET-only:
/app/index.php <- pages, GET only
/app/api/router.php <- the only file that ever receives POST
<?php
// api/router.php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
require __DIR__ . '/../guard.php';
csrf_check();
$action = $_POST['action'] ?? '';
switch ($action) {
case 'save': $out = do_save(); break;
case 'delete': $out = do_delete(); break;
default: $out = ['ok' => false, 'error' => 'unknown action'];
}
header('Content-Type: application/json');
echo json_encode($out);
One path to whitelist, one place for CSRF, one place to log. When the server or firewall does block something, you have exactly one location to investigate instead of forty.
Return errors as 200 with a flag in the body
This part looks wrong the first time you see it, and I’d defend it anyway on a machine like this.
If your firewall rewrites 4xx responses — and some do, including turning your deliberate 400 into a 405 — then your API cannot reliably communicate failure through status codes. The client sees a mangled code and can’t distinguish “your input was invalid” from “the firewall ate this request.”
So on hosts that do this, application-level errors go out as HTTP 200 with the outcome in the body:
// application errors: HTTP 200, failure stated in the payload
echo json_encode(['ok' => false, 'error' => 'invalid_date']);
const r = await fetch('/app/api/router.php', { method: 'POST', body });
const j = await r.json();
if (!j.ok) showError(j.error); // application-level failure
if (!r.ok) showError('blocked: ' + r.status); // infrastructure-level failure
Now the two failure classes are distinguishable, which they weren’t before. Transport-layer status codes still mean transport-layer things. Reserve this for environments that actually rewrite responses — on a normal server, use real status codes.
How to find out which one you have
Test the transport directly, without your app in the picture:
# does POST work at all on this path?
curl -s -o /dev/null -w '%{http_code}\n' -X POST -d 'x=1' https://host/app/api/router.php
# same path, different payload — if one 405s and one doesn't, it's content-based
curl -s -o /dev/null -w '%{http_code}\n' -X POST -d 'x=1' https://host/app/save.php
Two POSTs, same method, same host, different results tells you immediately that the method isn’t the problem. That’s a thirty-second test that ends the wrong investigation before it starts.
The general habit worth taking from this: when an error code doesn’t match anything you did, check whether the code is even telling the truth before you act on it. A status code is a claim made by whatever answered — and on a locked-down host, that’s frequently not your application.
Comments
Loading comments…