~/blog

A 302 turns your POST into a GET

published

#http#redirects#debugging

TL;DR

301, 302 and 303 let a client rewrite your POST into a GET and throw the body away. 307 and 308 do not. If an endpoint you POST to redirects — http to https, a trailing slash, an old path — the payload can vanish while the transfer still ends in 200.

The problem

You POST JSON to an API and the server behaves as if the body were empty. Validation fails, or worse, a listing endpoint answers cheerfully:

POST /api/v1/orders  →  302 Found, Location: /api/v1/orders/
GET  /api/v1/orders/ →  200 OK, []

Two requests went out. The second one is a GET, has no body, and returns 200. Any client that only checks res.ok records a success. Webhook senders are the worst version of this: they POST once, get followed to a GET, log 200, and never retry — the payload is gone and both sides think delivery worked.

The redirect is often not one you wrote. A load balancer normalising trailing slashes, an http to https upgrade, a CDN canonical-host rule, or a framework’s “add the missing slash” default all emit 301 by reflex.

Why it happens

It is in the spec, and it is deliberate. RFC 9110 says of both 301 and 302:

For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request.

303 See Other goes further — the client is told to fetch the target with GET. Only 307 and 308 guarantee the method and body survive.

StatusMethod preserved?Body preserved?Meaning
301 Moved PermanentlyNo — POST may become GETNoPermanent new URL
302 FoundNo — POST may become GETNoTemporary new URL
303 See OtherNo — becomes GET by designNoFetch the result elsewhere
307 Temporary RedirectYesYesTemporary, method-safe
308 Permanent RedirectYesYesPermanent, method-safe

“MAY” is what makes this expensive to debug: two clients hitting the same endpoint can disagree. In practice the major ones all rewrite. The Fetch standard’s redirect algorithm sets the method to GET and the body to null for 301/302 after a POST, and for 303 on anything except GET and HEAD — so browsers, fetch() in Node, and everything built on undici behave the same way. curl -L does the same rewrite for 301, 302 and 303.

There is a second, quieter loss. When a redirect crosses origins, the Fetch standard strips the Authorization header. So a redirect can turn an authenticated POST into an anonymous GET, and the 401 you get back sends you hunting for a token bug that does not exist.

What to do

See it first. curl will tell you what the final request actually was:

curl -sS -L -d 'id=1' -o /dev/null \
  -w 'final: %{method} %{response_code} %{url_effective}\n' \
  https://api.example.com/v1/orders

If that prints final: GET, you found it. To confirm the rewrite is the cause, tell curl to keep POSTing across the redirect:

curl -sS -L --post301 --post302 --post303 -d 'id=1' \
  -w 'final: %{method} %{response_code}\n' \
  https://api.example.com/v1/orders

Fix the URL, not the client. The redirect costs a round trip on every call even when it works. Point the client at the exact canonical URL — right scheme, right host, right trailing slash.

If you own the server and must redirect an API, use 308. In Express:

app.post('/api/v1/orders', (req, res) => {
  res.redirect(308, '/api/v2/orders');
});

In nginx, for a host that serves an API, prefer the method-safe code:

return 308 https://$host$request_uri;

Make the client refuse instead of guessing. For anything with a body, do not follow redirects silently:

const res = await fetch(url, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(payload),
  redirect: 'manual',
});

if (res.status >= 300 && res.status < 400) {
  throw new Error(`refusing to follow ${res.status} to ${res.headers.get('location')}`);
}

A thrown error at the redirect beats a 200 that silently did nothing.

Caveats

References