28 May 2025 · 6 min read
The five HTTP status codes people get wrong
401 versus 403, 302 versus 307, and why returning 200 with an error body breaks things you cannot see.
The status code is the part of a response that infrastructure reads. Browsers, proxies, CDNs, retry libraries and monitoring systems all act on it without looking at the body. Choosing badly means those systems act wrongly, and it usually goes unnoticed until it matters.
401 and 403
401 Unauthorized means not authenticated — despite the name. You do not know who the caller is, and re-authenticating would help. The specification requires a WWW-Authenticate header explaining how.
403 Forbidden means authenticated and still refused. You know exactly who they are and the answer is no. Retrying with the same credentials is pointless, and a client that responds to 403 by clearing the session and redirecting to login has created a loop.
302 and 307
302 Found is defined as temporary, but browsers historically changed the method to GET when following it — a POST becomes a GET and the body is dropped. Enough software depends on that behaviour that it cannot be fixed.
307 Temporary Redirect preserves the method and body. 308 is the permanent equivalent. If a redirect might be followed by anything other than a GET, use 307 and be explicit.
301 is worse than permanent — it is remembered. Browsers cache it aggressively, sometimes indefinitely, and users who received a wrong 301 keep following it after you fix the server.
200 with an error inside
Returning HTTP 200 with a body of { "success": false } is common and quietly expensive. Every layer above your application now believes the request succeeded.
- Monitoring reports a healthy error rate while users see failures.
- Retry logic does not retry, because nothing failed.
- Caches store the error and serve it to everyone else.
- Client libraries resolve the promise instead of rejecting it, so error handling never runs.
400 and 422
400 Bad Request means the request itself is malformed — unparseable JSON, a missing required header. 422 Unprocessable Content means the syntax was fine and the content is not: a validation failure, an email that is not an email.
The distinction is useful because it tells a client whether the problem is in how it built the request or in what the user typed. Collapsing everything into 400 loses that, which is defensible, but doing it inconsistently is not.
429 without Retry-After
429 Too Many Requests tells a client to slow down. Without a Retry-After header it has no idea how long, so it guesses — usually badly, and usually by retrying immediately. Send the header, and clients will do the right thing without any effort on their part.
The underlying rule
The status code is a machine-readable summary; the body is for humans and for detail. When they disagree, everything between you and your user believes the status code.
Tools mentioned