HTTP 405 is a status code a server returns when it recognizes the requested resource but refuses the specific method used to reach it, such as POST, PUT, or DELETE. The resource exists. The request is valid. The server has simply been configured to reject that particular action on that particular URL, and it will tell you exactly which actions it does accept if you know where to look.
That last part is the piece most explanations skip, and it’s the reason this error frustrates people longer than it needs to. According to RFC 7231, the specification the Internet Engineering Task Force published in 2014 to define HTTP semantics, every 405 response must include an Allow header listing the methods the target resource actually supports. Almost nobody checks it. Most people instead start guessing: clearing cache, retyping the URL, restarting the server. None of that works, because a 405 was never a connectivity problem or a typo. It’s a deliberate refusal, and the fix depends entirely on where that refusal was configured.
This guide walks through where that refusal typically lives on the platforms real sites actually run: Apache, Nginx, IIS, WordPress, Laravel, Node, and Django. It also covers two situations most 405 guides never mention at all: what this error looks like inside a CORS preflight request, and what it looks like when it silently breaks SSL certificate renewal.
What the Allow Header Actually Tells You
Before touching any configuration file, run one command:
curl -i -X POST https://yoursite.com/some-endpoint
Look at the response headers for a line like Allow: GET, HEAD. That single line tells you, with certainty, exactly which methods the server currently permits for that URL. Everything else in this guide is about finding where that list got set and how to change it. Skipping this step is the single biggest reason people spend hours debugging a problem the server was willing to explain in one line.
Why This Isn’t the Same as a 404 or a 403
Three status codes get confused with 405 constantly, and the difference matters because it changes where you look for the fix.
| Code | What it means | How it differs from 405 |
|---|---|---|
| 400 Bad Request | The request itself is malformed | 405 means the request is well-formed; only the method is rejected |
| 403 Forbidden | The client has no access to the resource at all | 405 means the resource is reachable, just not through this method |
| 404 Not Found | The resource does not exist at this address | 405 confirms the resource exists, since the server evaluated the method against it |
| 501 Not Implemented | The server does not recognize the method at all | 405 means the server understands the method, it just refuses it here |
A 404 means the door isn’t there. A 403 means you can’t go through the door. A 405 means the door is open, you’re just using the wrong way to open it, and the server already told you which way works.
The Three Buckets Every 405 Falls Into
Most articles list causes as one long, undifferentiated pile: server config, plugin conflicts, routing errors, security rules. That approach wastes time, because which bucket you’re in should determine your very first move.
Deliberate restriction. An administrator disabled PUT or DELETE on purpose, usually to reduce what an attacker could do if they ever got partial access. This is good practice right up until a legitimate feature needs one of those methods and nobody remembers the restriction exists. If you’re in this bucket, your first question should be whether the restriction was ever meant to be permanent, not whether it’s broken.
Accidental restriction. A config file copied from a template, a plugin that inserted its own rule during installation, a load balancer running its factory defaults. Nobody decided this should happen. It’s simply what “secure by default” looks like when nobody circles back to loosen it for a specific route.
Application and server disagreement. Your code expects POST to work on a given route, but the web server in front of it, or the framework’s own routing table, was never told that route accepts POST. Touching Apache or Nginx configuration accomplishes nothing here, because the restriction lives entirely inside your application.
Working out which bucket you’re in before changing anything prevents the most common mistake in 405 debugging: quietly reversing a security decision someone made on purpose, without realizing that’s what you just did.
Fixing HTTP 405 on Apache
Apache administrators most often hit this through the LimitExcept directive, or, on newer installs, the AllowMethods directive. Here’s the broken pattern to look for first, usually sitting inside a virtual host file or .htaccess:
<Location "/submit-form">
<LimitExcept GET HEAD>
Require all denied
</LimitExcept>
</Location>
That block explicitly denies every method except GET and HEAD, which is exactly why a POST to this location fails. Fix it in three steps.
First, confirm which configuration file is actually active for that domain. Running apachectl -S shows the real answer, since a stale include file is a far more common surprise than a typo in the file you think you’re editing.
Second, update the directive. On Apache 2.4.24 and later, AllowMethods is the cleaner option:
<Location "/submit-form">
AllowMethods GET POST OPTIONS
</Location>
Third, reload rather than restart, so active connections aren’t dropped, and verify with the same curl command from earlier:
apachectl configtest && systemctl reload apache2
curl -i -X POST https://yoursite.com/submit-form
If you’re on an Apache version older than 2.4.24, AllowMethods isn’t available, so you’re back to editing LimitExcept directly. One less obvious trap worth checking if the Allow header looks correct but the request still fails downstream: a RewriteRule earlier in the same config file can occasionally alter the method during an internal redirect, a rare cause that costs people an entire afternoon when they don’t know to look for it.
Fixing HTTP 405 on Nginx
Nginx handles this through a limit_except block inside a location directive. The broken version usually looks like this:
location /submit-form {
limit_except GET {
deny all;
}
}
Only GET survives here, so any POST returns 405. Confirm which server block is actually in effect first, since Nginx configurations nest and override each other in ways that surprise even experienced administrators:
nginx -T
Then widen the allowed methods:
location /submit-form {
limit_except GET POST {
deny all;
}
}
Test the configuration before reloading, exactly as with Apache:
nginx -t && nginx -s reload
curl -i -X POST https://yoursite.com/submit-form
One Nginx-specific cause deserves separate attention: a proxy_pass block combined with a misplaced proxy_method directive can silently force every request to GET before it ever reaches the backend, even when the Allow header looks entirely correct. This is a genuinely obscure cause, but it’s exactly the kind of thing that makes a fix look complete when it isn’t.
Fixing HTTP 405 in Microsoft IIS
IIS is the platform where this error most often has nothing to do with the application at all. Two separate IIS systems can each independently produce a 405, and they need to be checked one at a time.
Check Request Filtering first. Open IIS Manager, select the site, open Request Filtering, and click the HTTP Verbs tab. Any verb marked “Denied” there will return 405 no matter what your application expects. This maps directly to the following section of web.config, which can be edited by hand:
<system.webServer>
<security>
<requestFiltering>
<verbs>
<add verb="POST" allowed="true" />
<add verb="PUT" allowed="true" />
</verbs>
</requestFiltering>
</security>
</system.webServer>
Check the WebDAV Publishing module second, and separately, since it intercepts requests before your application code ever runs. If it’s installed and enabled for the site, it rejects PUT and DELETE by default regardless of what Request Filtering allows. Remove it for that specific site with:
%windir%\system32\inetsrv\appcmd.exe set config "YourSiteName" /section:system.webServer/webdav/globalSettings /enabled:"false"
Restart the application pool, not the whole server, so the change applies without downtime:
appcmd recycle apppool /apppool.name:"YourAppPoolName"
curl -i -X PUT https://yoursite.com/your-endpoint
If both of those come back clean and the error persists, check the handlers section of web.config for a mapping whose verb attribute was copied from a template and never updated for the method your application actually needs.
Fixing HTTP 405 in WordPress
WordPress is different from the server-level platforms above because the restriction rarely lives in Apache or Nginx at all. It’s almost always a plugin, and the process is closer to isolating a variable than editing a config file.
Deactivate every plugin except the one running your form or REST API integration, then retest. If the 405 disappears, reactivate the rest one at a time, retesting after each, until it reappears. That plugin is your cause, and now you know exactly where to look in its settings instead of guessing across a dozen plugins at once.
If the problem is isolated to /wp-json/ specifically, a security plugin has likely inserted something like this into .htaccess:
<FilesMatch "wp-json">
<LimitExcept GET>
Order Deny,Allow
Deny from all
</LimitExcept>
</FilesMatch>
Add the method you need to that LimitExcept list, following the same pattern as a bare Apache installation.
If no plugin turns out to be responsible, check caching before anything else. Load the page in a private browser window with a cache-busting query string appended to the URL. If the form works there but not at the normal address, your CDN or caching plugin is serving a static version of the page that never reaches the PHP handler responsible for processing POST. The fix here is a cache exclusion rule for that specific URL, not a code change.
Fixing HTTP 405 in Laravel
Laravel throws a MethodNotAllowedHttpException for one specific reason: the route exists, but not for the verb being used.
// broken
Route::get('/submit', [FormController::class, 'submit']);
If the form sends POST, the route needs to declare POST:
// fixed
Route::post('/submit', [FormController::class, 'submit']);
HTML forms can only natively send GET or POST. If a route needs PUT, PATCH, or DELETE from a browser form, Laravel needs a spoofed method field, or it never sees the verb you actually intended:
<form method="POST" action="/submit">
@method('PUT')
@csrf
</form>
If the routes file already looks correct and the error persists, the cause is very likely a stale route cache. Production reads from that cached list, not the file open in your editor:
php artisan route:clear && php artisan route:cache
In diagnosing this exact issue for an e-commerce client migrating from shared hosting to a dedicated server, checking the Allow header first identified a stale route cache in under ten minutes, work that had previously taken the client’s own team most of a day because they started by rewriting routes that were never actually broken.
Fixing HTTP 405 in Node.js and Django
Express returns a 405 when a route path matches but the registered method doesn’t. The broken pattern:
app.get('/submit', formHandler);
The fix is registering the missing method explicitly, or using app.all() if the route should genuinely accept every method:
app.get('/submit', formHandler);
app.post('/submit', formHandler);
If you’re using express.Router(), confirm the method is registered on the router instance actually mounted with app.use(). Registering routes on a router that was never mounted is a common mistake, and it silently falls through to whatever default handler catches the request elsewhere.
Django and Flask both whitelist allowed methods explicitly, so a 405 here almost always means the declared list is missing something. In Django:
@require_http_methods(["GET", "POST"])
def submit_view(request):
...
For Django REST Framework specifically, check which mixin a ViewSet inherits from. A ReadOnlyModelViewSet never includes create or update methods at the class level, regardless of how the URL is routed. Switch to ModelViewSet if write operations are actually required.
HTTP 405 in CORS Preflight Requests
What is a CORS preflight request? A CORS preflight is an automatic OPTIONS request a browser sends before certain cross-origin requests, asking the target server which methods and headers it will accept from that origin, before the browser sends the real request at all.
If only OPTIONS requests return 405, not the GET or POST you actually intended, this is a preflight failure, not a routing problem, and it’s one of the most misdiagnosed causes of 405 in modern applications built as single-page apps. The visible error in the browser console usually points at the request you meant to send, not the preflight that silently failed first. Confirm this by checking whether OPTIONS specifically is failing, separate from your intended method, then fix it by making sure Access-Control-Allow-Methods and Access-Control-Allow-Origin are set correctly on OPTIONS responses. Most frameworks ship dedicated CORS middleware for exactly this reason; hand-rolling it per route is where subtle mistakes creep in.
HTTP 405 in SSL Certificate Renewal
This is the scenario almost no guide to this error mentions, and it has nothing to do with forms or APIs at all.
ACME clients such as Certbot expect HTTP-01 challenge validation to reach a specific well-known path using a plain GET request on port 80. If a firewall or security layer blocks GET requests to that path, a rule that may have made perfect sense when it was written for an entirely different reason, certificate renewal fails with a silent 405 instead of a clear certificate-specific error. Nobody notices until the certificate actually expires and visitors start seeing browser warnings.
DNS-01 validation scripts carry the same risk. A script written against an older version of a DNS provider’s API can fail the same way if that provider has since changed which method a given endpoint accepts. The diagnostic is identical to everything covered above: check the Allow header in the failed response, just pointed at your renewal script’s logs instead of a browser’s developer tools. Running your domain through our SSL certificate checker before a renewal cycle runs confirms your certificate’s current status and expiry window, rather than finding out only after something has already gone wrong. Teams managing wildcard SSL certificates across multiple subdomains are especially exposed here, since one blocked validation path can silently affect every certificate tied to that domain.
The SEO Cost of Leaving a 405 Unresolved
A page returning 405 to Googlebot cannot be crawled using the default GET request, which means Google simply cannot see what’s on it. Internal links, sitemaps, or redirects pointing at a URL that unexpectedly returns 405 will see that page drop out of the index over time, and repeated 405 responses across many URLs waste crawl budget on paths that will never be indexed regardless of how much content sits behind them. This is worth checking directly in Search Console’s Page Indexing report, filtering specifically for URLs marked blocked or not indexed alongside a 405 status, since this error type is easy to overlook among the more commonly discussed 404s and redirect chains.
Quick-Reference Troubleshooting Matrix
| Symptom | Likely Cause | Where to Look First |
|---|---|---|
| 405 on every page, every method | Server-wide restriction or WAF rule | Web server config, firewall rules |
| 405 only on form submission | POST blocked on that route, or the page is cached | .htaccess, Nginx location block, cache settings |
| Works locally, fails in production | Hosting-level or reverse-proxy restriction | Hosting panel, load balancer config |
| 405 only on API endpoints | Route mismatch, missing OPTIONS handler | Route file, CORS middleware |
| Started right after migration | New environment’s default rules differ from the old one | Compare old vs. new server config, method by method |
| Only OPTIONS requests fail | Broken CORS preflight handling | Access-Control-Allow-Methods header |
A 405 is one of the more honest errors a server can return. It doesn’t hide behind vague messaging, and it doesn’t fail intermittently for no reason. It names exactly what it will and won’t accept, in the Allow header, on every single response, whether or not anyone bothers to read it. Most of the time lost debugging this error isn’t spent finding the fix. It’s spent everywhere else, before someone finally checks the one line the server was handing over for free the whole time.
