Skip to content
DDevToolery

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