302
A temporary detour: the resource still belongs at the URL you asked for, but for now the server wants this request served from somewhere else — and your browser will take the detour without ever showing it to your code.
The same three digits are three different problems depending on which side of the request you are on. Read the block that describes you.
Why you are seeing it
The expensive property of 302 is that you never see it. `fetch()` follows redirects by default and XHR has no choice at all, so an API call that gets bounced to an HTML login page resolves as a *successful* 200 whose body is a page, and the error you actually observe is `SyntaxError: Unexpected token '<'` thrown by `res.json()` — pointing at your parser instead of at your expired session. The second symptom is the missing body: on a 302 the browser is permitted to re-issue your POST as a GET, so the server records a GET with no payload and every field you sent is gone. The third is cross-origin: the redirect target is a new request that must satisfy CORS by itself, so a bounce to another host that sends no `Access-Control-Allow-Origin` is reported as a CORS error on the URL you called, with no trace of the hop that caused it.
What to do
Before you parse anything, check `response.redirected` and compare `response.url` with the URL you requested; log both when they differ. Check `content-type` too, because an HTML body arriving where you asked for JSON is the login bounce with a different name. In DevTools turn on "Preserve log" so the redirect row survives the navigation it triggers. If you own the server, the real fix is upstream: an API should answer an expired session with 401 and a JSON body, not with a 302 to a login page, because only the former lets the client tell "sign in again" from "the server sent me a document". If you do not own it, `redirect: "manual"` gives you an opaque-redirect response — status 0, type `"opaqueredirect"`, no readable `Location` — which is enough to detect that you were bounced and to show a sign-in prompt, but never enough to tell you where.
Why you are seeing it
Either the 302 is deliberate and the client lands in the wrong place, or it is a loop. Wrong-place is a proxy problem: the application composes the absolute URL from what it thinks the request was, so behind a TLS-terminating proxy it emits `http://`, and inside a container it emits an internal hostname or a port that exists nowhere outside. nginx names the same failure three times — `absolute_redirect`, `server_name_in_redirect`, `port_in_redirect` — and application frameworks need `X-Forwarded-Proto` and `X-Forwarded-Host` to be both forwarded and trusted before they will get it right. The loop is nearly always a cookie: you redirect to `/login`, the login response sets a session cookie, and the redirect back bounces straight to `/login` again because the cookie was never stored — `Secure` sent over plain HTTP, `SameSite=None` without `Secure`, a `Domain` that does not match, or a redirect that crosses to a different host than the one the cookie was set on.
What to do
Read the actual `Location` off the wire instead of the one in your source; they disagree exactly when the bug is real. Then check the two ends of the proxy contract: is the proxy sending `X-Forwarded-Proto`, and is the framework configured to trust it? For the loop, look at `Set-Cookie` on the login response and then at whether the *next* request carried a `Cookie` header at all — if it did not, the attributes are the bug and no amount of redirect logic will fix it. Finally, choose the code deliberately rather than accepting the framework default: 302 for a plain temporary detour, 303 after a POST so the follow-up is specified to be a GET, and 307 when the method and body must be replayed unchanged.
Why you are seeing it
The site sent you somewhere else on purpose, and it is meant to be temporary — a sign-in page, a country or language edition, a maintenance notice. The address bar changing to something you did not type is the redirect working, not a hijack. The failure shape is the bounce: you sign in, you land back on the sign-in page, and around it goes, or the browser stops with "too many redirects". That is the site's session handling, and the usual culprit is a cookie that your browser is not keeping.
What to do
If you keep landing back on the login page, allow cookies for that site — the loop almost always means the sign-in cookie is being blocked, cleared or discarded. Check for an extension that clears cookies, and try the site in a private window with the site's cookies allowed; if it works there, an extension or a site-specific cookie setting is the difference. If you are being redirected to a country or language edition you did not want, use the language link on the page you landed on rather than editing the address, because the redirect will simply fire again on the next request.
Run this against the URL that failed. It prints the status without the error page, so you can see what the server said rather than what the browser rendered.
curl -sS -D - -o /dev/null -L -d 'user=me' -w '\nfinal %{http_code} after %{num_redirects} hops at %{url_effective}\n' https://example.com/login`-d` sends a real request body and makes the request a POST on its own — deliberately without `-X`, which curl documents as applying to every request in the chain and would therefore suppress the one rewrite this command exists to show. `-L` follows the chain, `-D -` dumps every hop's headers, and `-w` prints the final status, the hop count and the URL that answered. Four things in that dump decide what you do next. The first status line separates 302 from 303 and 307, which is the difference between "the method may change", "the next request is a GET" and "replay it unchanged". The `location:` value shows whether the application built the URL from the right request — `http://` on an HTTPS site, an internal hostname, or a port that only exists inside the container all point at a missing `X-Forwarded-Proto` or `X-Forwarded-Host`. `set-cookie:` on that same response is where a login loop is decided: a `Secure` cookie over plain HTTP, `SameSite=None` without `Secure`, or a `Domain` the destination does not share means the next hop arrives with no session and bounces again. And the final response's `content-type` is the giveaway your front end could not see: `text/html` where you asked for JSON is the login bounce. curl documents that it downgrades POST to GET when it follows a 301, 302 or 303 and keeps the method for any other 3xx, so re-running this with `--post302` and comparing what the server logs proves whether the method rewrite is what dropped your fields.
RFC 9110 §15.4.3 defines 302 as the target resource residing temporarily under a different URI, and adds the instruction that makes it different from 301: since the redirection might be altered on occasion, the client ought to continue to use the original target URI for future requests. The same section carries the historical caveat — a user agent MAY change the request method from POST to GET, and 307 (Temporary Redirect) is named as the code to use when that is undesired. What the specification does not say is as useful: 302 is absent from the list of heuristically cacheable status codes in RFC 9110 §15.1, so unless you attach an explicit `Cache-Control`, a 302 is not stored and every request asks again. That is the property that makes it the right redirect to iterate with. One more distinction is worth having straight before you write a login flow: §15.4.4 defines 303 (See Other) as "the server is redirecting the user agent to a different resource, as indicated by a URI in the Location field, which is intended to provide an indirect response to the original request", with the subsequent request specified to use GET. Most frameworks emit 302 for a post-redirect-get, and 303 is the code that actually means it.
| Confused with | How to tell them apart |
|---|---|
307 | 307 (Temporary Redirect) is 302 with the method guaranteed. RFC 9110 §15.4.3 permits a user agent to turn a POST into a GET on a 302 for historical reasons; §15.4.8 defines 307 precisely so that the method and body are replayed unchanged. Same temporariness, different contract — and if your redirect is in the middle of a form submission, that contract is your request body. |
303 | 303 (See Other, §15.4.4) says the answer to your request lives at another URI and the follow-up is a GET. That is what post-redirect-get actually means, and it is unambiguous where 302 only permits the same behaviour. If you are redirecting after a successful POST, 303 states the intent instead of relying on a historical allowance. |
| 301 | The user-visible difference is permanence; the operational one is caching. 302 is not in RFC 9110 §15.1's list of heuristically cacheable status codes, so without an explicit `Cache-Control` it is never stored and you can change the destination on the next deploy. A 301 is stored, and there is no way to purge it from a visitor's browser. Iterate on 302, promote when you are certain. |
| 401 | An API that answers an expired session with a 302 to a login page turns every authentication failure into a 200 full of HTML, because the browser follows the redirect before your code sees anything. 401 with a JSON body — and, per RFC 9110 §15.5.2, a `WWW-Authenticate` header — keeps the failure legible to the client that has to handle it. |
All of these run in your browser — nothing is uploaded.
Codes people usually end up reading in the same session as 302.
Read the response headers rather than the page. curl -sS -o /dev/null -D - https://example.com/path prints them without the body, and server, via and cf-ray between them name the layer that answered. A cf-ray value means Cloudflare handled the response and is the id their support will ask for. To take the edge out of the picture entirely, repeat the request with --resolve example.com:443:203.0.113.10, which connects to the origin address you name while still sending the original host name and SNI — if the answer changes, the edge and the origin disagree.
No, and you should never branch on it. RFC 9112 tells clients to ignore the reason phrase, servers are free to change it, and HTTP/2 and HTTP/3 do not carry one at all — so 404 Not Found over HTTP/1.1 arrives as a bare 404 over HTTP/2. The phrases on this site are the registered ones because they are what people search for and what appears in an HTTP/1.1 log, not because any software depends on them.
Retry 5xx and 429; do not retry 4xx, because nothing about the request will be different next time. If the response carries Retry-After, honour it — RFC 9110 defines it for exactly this, and it may be either a number of seconds or a date. Otherwise use exponential backoff with jitter and a hard cap, and only for idempotent methods: a retried POST can charge a card twice. A retry storm against a server that is already failing is how a brief incident becomes a long one.
Yes, and the only rule that matters is that the code has to be true. Anything that reads your responses automatically — search engines, monitoring, caches, client retry logic — makes decisions from the number and never from the page. Serving an error page with 200 hides the failure from your own alerting; serving 200 for a missing page gets the error indexed as content; returning 500 for a request that was simply malformed sends whoever is on call to the wrong half of the stack.
Still stuck on 302? browse every status code in the reference — or go back up to the block written for your side of the stack.