Why does a POST request become a GET after a redirect behind nginx?
A 301, 302 or 303 lets the client resend a POST as a GET with no body. Which code each proxy sends by default, and what 11 clients do with each, tested.
Key points
- The client changes the method, not the proxy. On a 301, 302 or 303 every browser, curl, requests, Go, Node fetch and axios resent a POST as a GET with no body. On 307 or 308 all of them resent the POST with its body. The proxy's only part is which code it sends.
- Most redirect directives default to 302: nginx
return URLandrewrite ... redirect, Caddyredir, HAProxyredirect. nginxrewrite ... permanentand the automatic slash redirect forlocation /api/are 301. Caddy's automatic HTTPS redirect is 308; Traefik picks 307 or 308 itself for anything that is not a GET. curl -X POST -Lis a trap: after a 301, 302 or 303 it kept the method and dropped the body, so the backend got a POST with no Content-Length. requests did the same to a PUT after a 301.- Inside nginx,
error_pageto a URI andX-Accel-Redirectto a URI turn every method but HEAD into GET, and the original body andContent-Typewere still sent to the backend. Named locations andrewrite ... lastkept the POST. - The fix is
308for permanent and307for temporary redirects on any path that receives POST, PUT or PATCH. Go only follows a 307 or 308 when it can replay the body, and Python's urllib does not follow them for a POST at all.
A POST turns into a GET after a redirect because the proxy answered with a 301, 302 or 303, and on those codes the client is allowed to resend the request as a GET without its body. nginx never rewrites the method of a redirect it sends; the browser, curl or HTTP library does it when it follows the Location. Every client tested below did exactly that on 301, 302 and 303, and every one kept the POST and its body on 307 and 308. So the fix is to make the redirect a 308 (permanent) or 307 (temporary):
# was: return 301 https://$host$request_uri; (or: rewrite ^ ... permanent;)
return 308 https://$host$request_uri;Then find which directive sent the 3xx, because most of them default to 302 without saying so, and one nginx feature redirects with a 301 that nobody wrote.
| What sent the redirect | Code it sends | POST survives? |
|---|---|---|
nginx return https://...; (URL only) | 302 | No |
nginx rewrite ... redirect;, or a rewrite to an absolute URL with no flag | 302 | No |
nginx rewrite ... permanent; | 301 | No |
nginx location /api/ { proxy_pass ...; } requested as /api | 301 (automatic) | No |
nginx error_page 497 https://...; | 302 | No |
Caddy redir /new with no code, or temporary / permanent | 302 / 302 / 301 | No |
| Caddy automatic HTTP to HTTPS redirect | 308 | Yes |
Traefik redirectScheme or redirectRegex, permanent: false | 302 for GET, 307 for other methods | Yes |
Traefik the same with permanent: true | 301 for GET, 308 for other methods | Yes |
HAProxy redirect or http-request redirect with no code | 302 | No |
Flask or Werkzeug strict_slashes (Werkzeug 0.15.0 and later) | 308 | Yes |
Any of these with an explicit 307 or 308 | as set | Yes |
How the cases were reproduced#
Everything below was observed on 28 October 2026 unless it is attributed to documentation or source code. nginx 1.30.6 and 1.27.5 (official Alpine images, identical results), Caddy 2.11.7, Traefik 3.7.15 and HAProxy 3.2.26 ran on one Docker network in front of a small Python backend that answered every request with the method, path, body length and Content-Type it received. The clients were Chromium 140, Firefox 141 and WebKit 26 (Playwright 1.55, an HTML form and fetch() in each), curl 8.23.0 and 7.68.0, Python requests 2.34.2, httpx 0.28.1 and urllib from CPython 3.13.17, Go 1.25.14 net/http, Node 24.22.0 fetch (undici 7.29.1) and axios 1.21.0 on Node. Each sent a 7-byte form body to redirects of every code. Flask 3.1.3 with Werkzeug 3.1.9 was used for the trailing slash case.
What RFC 9110 allows, and why clients take the option#
RFC 9110 gives 301 and 302 the same note: "For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request. If this behavior is undesired, the 308 (Permanent Redirect) status code can be used instead", with 307 named as the alternative for 302. A 303 is defined as the redirect that sends the client to fetch a different resource with GET. For 307 and 308 the wording is the reverse: the user agent "MUST NOT change the request method if it performs an automatic redirection".
The "MAY" is the source of every forum argument on this subject. One camp reads the standard and says a 301 keeps the POST; the other runs curl and sees a GET. The second camp describes what happens. Browsers made POST to GET the de facto rule for 301 and 302 long before 307 and 308 existed, and the WHATWG Fetch standard, which browsers and Node's fetch implement, writes it down as a requirement: if the status "is 301 or 302 and request's method is POST", or "is 303 and request's method is not GET or HEAD", then "set request's method to GET and request's body to null". curl's documentation says it does the same "to maintain consistency" with browsers, and the requests source carries the comment "Do what the browsers do, despite standards". So on a 301, expect a GET from any client you do not control.
Fetch is also precise about what it does not change. A PUT, PATCH or DELETE that gets a 301 or 302 keeps its method and its body in a browser. Only POST is converted, and only on those two codes. That detail is where the clients below start to disagree.
What each client did with a POST#
The cell is the request the backend received after the client followed the redirect. "GET" means a GET with no body.
| Client | 301 | 302 | 303 | 307 | 308 |
|---|---|---|---|---|---|
Chromium 140, Firefox 141, WebKit 26 (form submit and fetch) | GET | GET | GET | POST + body | POST + body |
Node 24 fetch (undici 7.29.1) | GET | GET | GET | POST + body | POST + body |
| axios 1.21.0 on Node (follow-redirects 1.16.2) | GET | GET | GET | POST + body | POST + body |
curl 8.23.0 and 7.68.0, -L -d | GET | GET | GET | POST + body | POST + body |
curl -L -X POST -d | POST, no body | POST, no body | POST, no body | POST + body | POST + body |
curl -L --post301 --post302 --post303 -d | POST + body | POST + body | POST + body | POST + body | POST + body |
| requests 2.34.2 | GET | GET | GET | POST + body | POST + body |
httpx 0.28.1, follow_redirects=True | GET | GET | GET | POST + body | POST + body |
Go 1.25.14, body from bytes.Reader | GET | GET | GET | POST + body | POST + body |
Go 1.25.14, body from io.Pipe | GET | GET | GET | not followed | not followed |
| Python urllib (CPython 3.13) | GET | GET | GET | HTTPError 307 | HTTPError 308 |
For POST the eleven clients agree far more than the forum threads suggest. The differences are at the edges: the two that refuse to follow, and curl with -X.
curl -X POST -L sends a POST with no body#
This is the one combination that produces the other half of the symptom, a request that is still a POST but arrives empty. With -d alone curl follows a 301, 302 or 303 with a GET, as documented. Adding -X POST overrides the method for every request curl makes, and the documentation says so: "The method set with --request overrides the method curl would otherwise select to use." But it does not bring the body back. In -v output curl prints Stick to POST instead of GET, then sends POST /echo with no Content-Length and no Content-Type. The backend sees a POST of zero bytes, and a JSON API typically answers 400 or 415, which looks like a broken request, not a redirect. curl 7.68.0 behaved identically.
The correct way to keep a POST through a 301, 302 or 303 in curl is --post301, --post302 and --post303, which resent the full body in every case. Use them only for a server you know expects it; the better fix is still a 307 or 308 on the server. More curl flags that matter through a proxy are in curl through a proxy.
Go and urllib refuse rather than guess#
Go's redirectBehavior in client.go switches every method except GET and HEAD to GET on 301, 302 and 303, and keeps it with the body on 307 and 308. Keeping the body needs Request.GetBody, which http.NewRequest fills in only for bytes.Buffer, bytes.Reader and strings.Reader bodies. With a streamed body (an io.Pipe, a file, a network reader) there is no way to send it twice, and the source says what happens then: it returns the 3xx response "to the user instead of an error". The test got the nginx 307 page back as the response, status 307, with no error. Code that only checks err treats that as success.
CPython's urllib is stricter. Its redirect_request follows any code for GET and HEAD, and 301, 302 and 303 for POST (as GET). Everything else raises: a POST to a 307 or 308 gave HTTPError, and so did a PUT to every redirect code.
httpx does not follow at all unless asked#
httpx 0.28.1 returned the 301, 302, 303, 307 or 308 itself in every case until follow_redirects=True was set, and then matched the table. If a client built on httpx reports a 301 where requests would have shown the backend's answer, that is why. One cosmetic difference: when httpx and Go converted a POST to GET they still sent the original Content-Type header with the empty GET. requests, curl, urllib, Node and the browsers removed it.
PUT and PATCH: where the clients split#
Only POST is covered by the "historical reasons" note, so libraries had to decide what to do with other methods themselves, and they decided differently. A PUT with the same 7-byte body:
| Client | 301 | 302 | 303 | 307 | 308 |
|---|---|---|---|---|---|
Browsers, Node fetch, axios on Node | PUT + body | PUT + body | GET | PUT + body | PUT + body |
| httpx 0.28.1 | PUT + body | GET | GET | PUT + body | PUT + body |
| requests 2.34.2 | PUT, no body | GET | GET | PUT + body | PUT + body |
| Go 1.25.14 | GET | GET | GET | PUT + body | PUT + body |
curl -L -X PUT -d | PUT, no body | PUT, no body | PUT, no body | PUT + body | PUT + body |
curl -L -T file (upload) | PUT + body | PUT + body | GET | PUT + body | PUT + body |
| Python urllib | HTTPError | HTTPError | HTTPError | HTTPError | HTTPError |
The requests row is the one to remember. rebuild_method only converts a 301 to GET when the method is POST, so a PUT stays a PUT, but resolve_redirects deletes the body and the Content-Length, Content-Type and Transfer-Encoding headers for every code that is not 307 or 308. The backend receives an empty PUT. Go goes the other way and turns PUT, PATCH and DELETE into GET on 301 to 303, with a source comment that this is kept "to maintain compatibility". So the same API call through the same 301 is a PUT with a body from a browser, an empty PUT from Python, and a GET from Go. Only 307 and 308 produce the same request everywhere.
Which code each proxy sends by default#
nginx: return, rewrite and the slash nobody wrote#
The rewrite module documentation is exact. return code URL accepts 301, 302, 303, 307 and 308, and a bare return URL; is "a temporary redirect with the code 302". A rewrite whose replacement starts with http://, https:// or $scheme returns a 302 even with no flag; the flag redirect is 302 and permanent is 301. There is no rewrite flag for 307 or 308. To redirect a POST path, replace the rewrite with a return 308 in a location that matches it. Status 308 has been treated as a redirect since nginx 1.13.0, and 307 since 1.1.16.
All five forms were checked on 1.27.5 and 1.30.6. A relative target such as return 301 /echo; went out as an absolute Location: http://<host>/echo built from the request (Caddy and HAProxy sent /echo as written); either way the client makes a second request and the method rule applies.
The one people do not write is the automatic redirect. When a prefix location ends with a slash and is served by proxy_pass (or fastcgi_pass, uwsgi_pass, scgi_pass, memcached_pass or grpc_pass), the core module says a request for the same URI without the slash gets "a permanent redirect with the code 301". With location /api/ { proxy_pass http://app/; }, a POST /api got a 301 to /api/, and curl reached the backend with a GET. The documented fix is an exact location next to it, which the test confirmed kept the POST:
location /api/ { proxy_pass http://app/; }
location = /api { proxy_pass http://app/; }How the two locations then map onto upstream paths is the subject of nginx proxy_pass and the trailing slash. The error_page 497 redirect for plain HTTP on a TLS port is also a 302 by default, and its =301 versus =308 choice is covered in the plain HTTP sent to HTTPS port guide.
Caddy: 302 by default, 308 for HTTPS#
The redir documentation lists temporary as "302 Found, this is the default" and permanent as "301 Moved Permanently", and warns that "both temporary and permanent allow the user agent to change the method". Any 3xx number can be given instead. Caddy's own automatic HTTP to HTTPS redirect uses 308, and the test got 308 for GET, HEAD, POST and PUT. So with Caddy the HTTPS upgrade is safe and hand-written redir lines are not, unless they say 307 or 308 as the redir /api/ 308 in the Caddy reverse proxy guide does.
Traefik: chooses the code from the method#
Traefik's documentation lists only scheme, permanent (default false) and port for redirectScheme, and says nothing about status codes. The handler in redirect.go, shared by redirectScheme and redirectRegex, decides: 302, or 307 "if req.Method != http.MethodGet"; with permanent, 301, or 308 for anything that is not GET. The test matched it on 3.7.15 for both middlewares. Traefik is therefore the one proxy here that cannot turn a POST into a GET with its default settings. It does treat HEAD as "not GET", so a HEAD gets 307 or 308. Where middlewares attach is in Traefik routers, services and middlewares.
HAProxy: 302 unless code is set#
The HAProxy 3.2 manual on redirect: "Only codes 301, 302, 303, 307 and 308 are supported, with 302 used by default if no code is specified." It applies to redirect location, redirect prefix and redirect scheme, and the manual describes http-request redirect as "exactly the same as the redirect statement". Both the legacy and the http-request form sent 302 for every method in the test. The usual HTTPS line therefore needs the code written in:
http-request redirect scheme https code 308 unless { ssl_fc }The rest of a typical frontend is in HAProxy configuration explained.
Redirects inside nginx also change the method#
Some POSTs become GETs without any redirect reaching the client. nginx has internal redirects, where a request is handed to another location in the same server, and two of them change the method. The core module says of error_page that it "causes an internal redirect to the specified uri with the client request method changed to GET (for all methods other than GET and HEAD)". The documentation does not say the same for X-Accel-Redirect, but the source does: ngx_http_upstream.c sets r->method = NGX_HTTP_GET unless the method is HEAD before it calls ngx_http_internal_redirect, and ngx_http_send_error_page in ngx_http_special_response.c contains the same three lines. In both files the change sits in the branch for a URI. A target beginning with @ goes to ngx_http_named_location instead, untouched.
What the backend received for a POST with a 7-byte form body, on 1.27.5 and 1.30.6:
| Internal redirect | Method at the backend | Body at the backend |
|---|---|---|
error_page 500 /internal/echo; with proxy_intercept_errors on | GET | 7 bytes, Content-Type kept |
error_page 404 /internal/echo; after return 404 | GET | 7 bytes |
error_page 500 @echo; | POST | 7 bytes |
X-Accel-Redirect: /internal/echo from the backend | GET (also for PUT) | 7 bytes |
X-Accel-Redirect: @echo | POST | 7 bytes |
rewrite ^ /echo last; | POST | 7 bytes |
The proxy module documentation shows the clean-up in its example for proxy_pass_request_body: a location named /x-accel-redirect-here/ that sets proxy_method GET, proxy_pass_request_body off and proxy_set_header Content-Length "". Use that when the target is a file or a GET-only service. When the target must see the POST, use a named location: error_page 500 @fallback; or X-Accel-Redirect: @fallback. That has its own cost, covered in custom error pages when the backend is down: a named location that serves a static page answers a POST with 405.
The redirect the backend sends through the proxy#
A proxy passes the backend's own 3xx through unchanged, so the code can come from the application. Two cases account for most of them.
Trailing slash redirects in the framework. Werkzeug's RequestRedirect, raised for strict_slashes routes, uses code = 308; its changelog records the change from 301 to 308, "preserving the verb and request body", in 0.15.0 (2019). Flask 3.1.3 with Werkzeug 3.1.9 sent a 308 for POST /items to a /items/ route, and the followed request was still a POST with its body. Frameworks still sending 301 or 302 for slashes behave like the nginx automatic redirect above.
An HTTPS redirect after TLS ends at the proxy. When nginx terminates TLS and proxies plain HTTP, an application that does not trust X-Forwarded-Proto thinks the request was HTTP and redirects to https://. If that is a 301 or 302, the POST is lost on the redirect, and a loop often follows. The proxy side of that fix is in X-Real-IP, X-Forwarded-Proto, Host and Port. The same thing happens when an API client is configured with http:// and the port 80 server answers return 301 https://....
Finding which hop changed the method#
The proxy's access log shows it in two lines. A client-side redirect logs the original method and the 3xx, then a second request with the new method:
301 POST /old up=-
200 GET /new up=200up=- (no $upstream_status) means nginx itself sent the 301; a number means the backend did. An internal redirect logs one line, already with GET, and two upstream statuses separated by : when the first location also proxied. From the client, curl -v -d x=1 -L URL shows every hop: > POST /old, then < HTTP/1.1 301, then > GET /new. Browser developer tools show the same sequence in the network panel, with the 3xx row's status and the next row's method.
Failure modes#
A form or API POST arrives as GET with no body. Something in the chain answered 301, 302 or 303. Find it in the access log and change it to 308 or 307.
The backend gets a POST or PUT with an empty body. Either curl with -X POST -L or -X PUT -L followed a 301 to 303, or requests followed a 301 for a PUT. Fix the redirect; in curl, drop -X or use --post301.
POST /api works with a slash and fails without one. nginx's automatic 301 for location /api/. Add location = /api.
The backend logs a GET with a body, or a GET where the client sent POST, and no 3xx in the log. An error_page URI or an X-Accel-Redirect URI. Use a named location, or clear the body with proxy_pass_request_body off.
Go code gets status 307 with no error. The request body had no GetBody. Use a bytes.Reader or set GetBody, or send the request to the final URL.
Python urllib raises HTTPError 307 or 308 on a POST. By design. Call the final URL, or use requests or httpx.
Changing 301 to 308 did not change what one browser does. A 301 can be cached (the HAProxy manual: "a browser may cache the Location"), so a browser that saw the old one may not ask again. Test with a fresh profile or with curl.
Where this answer stops applying#
The redirect codes are from nginx 1.27.5 and 1.30.6, Caddy 2.11.7, Traefik 3.7.15 and HAProxy 3.2.26, and match each project's documentation or source. The client results are for the versions listed, on HTTP/1.1, same-origin redirects. A cross-origin redirect can also drop headers such as Authorization, which Fetch requires and which was not part of this test. axios was tested on Node, where it uses follow-redirects; in a browser it uses XMLHttpRequest and follows the browser's rules, which here are identical for POST. Microsoft Edge, Safari on macOS and iOS, Java's HttpClient, .NET's HttpClient and Envoy were not tested.
Frequently asked questions#
Why does my POST request turn into a GET after a redirect?#
The server answered with 301, 302 or 303, and clients resend a POST to those as a GET without its body. RFC 9110 allows it for 301 and 302, and browsers, curl, requests, Go, Node fetch and axios all do it. Use 307 or 308 for any URL that receives POST.
What is the difference between 301 and 308 for a POST?#
Both are permanent redirects. On a 301 the client may change POST to GET, and in practice every tested client did. On a 308 the client must not change the method, and every tested client resent the POST with its body. 302 and 307 are the temporary pair with the same difference.
What redirect code does nginx return by default?#
return URL;with no code,rewrite ... redirectand a rewrite to an absolute URL with no flag all send 302.rewrite ... permanentsends 301, and a request for/apito alocation /api/served byproxy_passgets an automatic 301. Onlyreturn 307orreturn 308keeps a POST.
Why does curl -L change POST to GET?#
curl follows the browser convention on purpose: with -d and -L it resends 301, 302 and 303 as GET. --post301, --post302 and --post303 keep the POST. Do not use -X POST for this: it keeps the method but sends no body.
Does nginx change the method itself?#
Not for redirects it sends to the client; the client does. It does for internal redirects: error_page and X-Accel-Redirect to a URI turn every method except HEAD into GET, and still pass the original body to the backend. A named location (@name) keeps the method.
Does Python requests keep the body after a redirect?#
Only on 307 and 308. On 301, 302 and 303 it removes the body. A POST becomes a GET on all three; a PUT becomes a GET on 302 and 303 but stays an empty PUT on 301.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- RFC 9110, HTTP Semantics, sections 15.4.2 to 15.4.9
- WHATWG Fetch Standard, HTTP-redirect fetch
- nginx ngx_http_rewrite_module (return, rewrite flags)
- nginx ngx_http_core_module (error_page, location trailing slash redirect)
- nginx source, ngx_http_upstream.c (X-Accel-Redirect method change)
- nginx source, ngx_http_special_response.c (ngx_http_send_error_page)
- Caddy documentation, redir directive
- Traefik RedirectScheme middleware
- Traefik source, pkg/middlewares/redirect/redirect.go (v3.7)
- HAProxy 3.2 configuration manual (redirect, code)
- curl documentation, --location and --post301
- Go source, net/http client.go (redirectBehavior), release-branch.go1.25
- requests source, sessions.py (rebuild_method, resolve_redirects)
- CPython source, Lib/urllib/request.py (redirect_request), 3.13
- follow-redirects source, index.js (used by axios in Node)
- Werkzeug source, routing/exceptions.py (RequestRedirect)
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in troubleshooting proxies#
- 502 vs 503 vs 504: reading proxy errors correctly
- ERR_TUNNEL_CONNECTION_FAILED: what the proxy answered
- Cntlm vs Px: which one for an NTLM or Kerberos proxy?
- Corporate proxies and developer tooling
- Access-Control-Allow-Origin multiple values: the fix
- curl through a proxy
- Header and body size limits at the proxy
- HTTPS_PROXY: http:// or https://? Tested in 12 clients
- netsh winhttp vs Windows proxy settings: who reads which?
- nginx 413 Request Entity Too Large: find the limit
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx 502 after Docker restart: fix the stale IP
- nginx in Docker: 502 on proxy_pass localhost, fixed
- nginx drops headers with underscores: why, and the fix
- nginx proxy_cache always MISS: every cause, tested
- Nginx Proxy Manager 502 Bad Gateway: find your cause
- nginx connect() failed to [::1] with localhost: the fix
- nginx (13: Permission denied) to upstream: SELinux fix
- nginx SSL handshaking to upstream errors: fix each one
- nginx cuts off large downloads: find which of 5 causes
- nginx upstream sent no valid HTTP/1.0 header: the fix
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- A proxy debugging playbook