A delete that worked locally spat out a 405 on the server
DELETE and PUT worked locally but got a 405 on the server. Why I worked around it with POST, and the real cause I found later: a default ModSecurity rule, with real requests.
#Apache #REST #Express #Troubleshooting
I pressed the delete favorite button in my couples' restaurant app and this showed up in red in the console. DELETE https://... 405 (Method Not Allowed) It definitely worked locally. Favorites were deleted fine, and editing a nickname (PUT) was fine too. But after I put it on the server, the exact same code spat out a 405. I hadn't touched the code, only the environment had changed, and it broke. This kind of problem is the hardest to deal with, because you have no idea what to fix. This post is a record of where that 405 came from and why I ended up dropping both DELETE and PUT and unifying on POST. I've also added what I confirmed much later about where the real cause was. The culprit wasn't my code I stared at the Express router for a long time. The router.delete path was right, the middleware was right, and it worked locally, so the code wasn't to blame. Then it suddenly occurred to me to ask what the server had that local didn't. It was Apache . Everything on my server runs behind an Apache reverse proxy (I have a separate post on that setup). And this Apache was treating methods like DELETE and PUT as dangerous and blocking them. So my request never even reached Express. It was cut off at Apache and came back as a 405. Local has no Apache, so of course it was fine there. Checking this is simple. Hit the same path once in front of the proxy (the domain) and once behind it (the localhost port). Below is a screen I captured now, while revising this post. If I send a DELETE to the domain, Apache's default error page comes back with a 405, and if I send it straight to the port, Express gives a 404. The 404 means the request reached Express. It's only a 404 because the route doesn't exist. The response body isn't JSON made by Express but Apache's default HTML, and the charset is iso-8859-1, so you can see right away who produced this response. That's why I couldn't see it no matter how long I looked at the code. The problem wasn't inside the code, it was in front of it. Two ways to go There were two options. Loosen the Apache config to allow DELETE and PUT, or use only POST and GET, the methods that aren't blocked. The first is the proper way. But it means touching the method policy of the whole server. It's not just the restaurant app running here. The blog, the crypto site, monitoring and several other services live on it together. I wasn't comfortable loosening that shared Apache config without knowing what might blow up somewhere else. So I picked the second. Delete or update, everything goes through POST. It may look petty, but I felt better knowing the scope of what I touched stayed inside my own app. Adding POST on top without tearing up what was there I already had quite a few routes written with DELETE and PUT. Deleting them all and rewriting would be a chore, so I registered the same handlers under POST as well. One handler, hooked to two methods. // leave the existing DELETE as is, and register the same handler for POST too router.delete('/comment/delete/:id', deleteCommentHandler); router.post('/comment/delete/:id', deleteCommentHandler); // same for updates router.put('/nickname', updateNicknameHandler); router.post('/nickname', updateNicknameHandler); This way DELETE still works locally, and on the server (behind Apache) I can call it with POST. On the frontend I stopped using PUT and DELETE entirely and unified everything on POST and GET, so that it doesn't break in whichever environment it's deployed to. Doesn't it feel wrong to use POST for a delete? It does. By REST, a delete should be DELETE. Deleting with POST isn't pretty by the book. I know. But in practice, what actually runs on this server right now came before what's theoretically right. The only client calling this API is the one frontend I built anyway, and the intent is stamped into the paths with /delete and /disconnect, so there's nothing to get confused about. Even if the method name doesn't quite fit, I figured it was enough as long as the behavior is clear. A year later I ran into the same thing again While building a travel itinerary service I hit the same wall. This time it wasn't DELETE but PATCH. It's right there in the commit log. 2026-06-10 add POST aliases in case the WAF blocks PATCH (planner update, place select) It was the second time, so finding the cause didn't take long. When something works locally and not on the server, trying the method as POST first has become a habit. I still don't know whether that's a good habit, though. So who was actually blocking it? While revising this post I kept digging until I found exactly where the block happens. There was no line in the Apache config files that blocks DELETE. Instead, Apache had ModSecurity attached, and rule 911100 of its Core Rule Set (CRS) was blocking every method that isn't on the allow list. The default allow list looks like this. # /usr/share/modsecurity-crs/rules/REQUEST-901-INITIALIZATION.conf setvar:'tx.allowed_methods=GET HEAD POST OPTIONS' # REQUEST-911-METHOD-ENFORCEMENT.conf SecRule REQUEST_METHOD "!@within %{tx.allowed_methods}" \ "id:911100, ... ,deny,status:405" It lets only four through, GET, HEAD, POST and OPTIONS, and cuts off the rest with a 405. That's why DELETE, PUT and PATCH were all caught. Writing WAF in the commit above was correct, but at the time I thought it was a firewall on the hosting side. It was running inside the Apache on my own server. To lift it, I'd add PUT DELETE PATCH to tx.allowed_methods in crs-setup.conf. But that config is also shared by ten sites, so for now I'm still leaving the POST workaround in place. Leaving it knowing where the block is isn't the same as leaving it without knowing, so I think this is good enough. If a request that worked locally gives a 405 on the server, I'd recommend sending the same request once in front of the proxy and once behind it before digging through the app logs. The shape of the response body alone tells you who answered.