The developer’s guide to HTTP error codes
Understanding the intent behind 401 vs 403, 400 vs 422, and other misunderstood status codes.
HTTP status codes are one of the oldest protocols in web development, so old that most engineers treat them as solved problems. But in practice, status codes are routinely misused. Production APIs return 200 for failed logins, 403 for missing tokens, or 500 for validation errors. These aren’t stylistic preferences; they’re semantic breaks that make debugging, observability, and security enforcement harder.
When used correctly, HTTP status codes are a shared language between clients and servers. They don’t just say “something went wrong.” They tell what kind of wrong, who is at fault, and whether retrying makes sense.
In this article, we will review some common mistakes and give practical advice on which HTTP status code you should return for each scenario. Let’s dive right in.
HTTP codes 101
Before diving into the subtleties, let’s establish some groundwork.
The HTTP specification (RFC 9110) defines status codes as three-digit numbers grouped by their first digit, each representing a “class” of response:
In essence:
- 4xx → the client is wrong (syntax, permissions, etc.)
- 5xx → the server is wrong (bugs, timeouts, overloads)
Yet within those classes, there’s enormous nuance.
Authentication vs Authorization: The eternal 401 vs 403 debate
This is the most infamous pair of error codes. They appear in every backend system, and they are routinely used incorrectly.
401 Unauthorized: “I don’t know who you are”
Despite the misleading name, 401 Unauthorized doesn’t mean the user lacks permission. It means authentication failed or was not provided.
Per RFC 9110 §15.5.2: “The request has not been applied because it lacks valid authentication credentials for the target resource.”
Key properties:
- The client is unauthenticated.
- The server must include a
WWW-Authenticateheader specifying how to authenticate. - The client can retry the request with valid credentials.
Example:
Common causes:
- Missing access token
- Invalid or expired JWT
- Missing
Authorizationheader
403 Forbidden: “I know who you are, but you can’t do this”
Once authentication succeeds, the question becomes: is this user allowed?
403 is the answer when the server knows the identity but explicitly refuses authorization.
Per RFC 9110 §15.5.4: “The server understood the request but refuses to authorize it.”
Example:
The token was valid, but the user lacks the required role.Common real-world mistakes:
- Returning
403for missing credentials (should be401) - Returning
401for valid but underprivileged users (should be403)
!!TL;DR: Not logged in → 401, Logged in but disallowed → 403!!
404 vs 410: The resource paradox
404 Not Found
The most famous error code in the world, and the most overloaded.
404 simply means the resource is not available at this location. It doesn’t tell the client whether it ever existed.
Example:
A 404 can mean:
- The resource never existed.
- The resource exists but is hidden from this user (security through obscurity).
- The resource was deleted, but the server isn’t acknowledging that explicitly.
410 Gone
A more expressive cousin of 404.
410 signals: “This resource used to exist, but it was intentionally removed and will not return.”
Example:
It’s a great fit for:
- API deprecations: older endpoints intentionally retired.
- Deleted entities: resources that were once valid but removed.
- SEO correctness: telling crawlers not to retry or index.
As a rule of thumb:
- Use
410when the absence is deliberate and permanent. - Use
404when the absence might be temporary or ambiguous.
Success with semantics: 200 vs 204 vs 202
Not every “success” looks the same.
200 OK: Everything worked, here’s your data.201 Created: New resource successfully created (include aLocationheader).204 No Content: Request succeeded, no response body needed.202 Accepted: Request received and queued, but not yet processed (ideal for async systems).
Example:
This tells the client: “The session was deleted, nothing else to return.”
Returning 200 with {} works, but it’s semantically sloppy.
Bad input vs Invalid state: 400 vs 422
Another subtle but important distinction.
400 Bad Request
Use this when the client’s request is syntactically invalid: the server can’t even process it.
Examples:
- Missing
Content-Type - Malformed JSON
- Invalid parameter type
422 Unprocessable Entity
Adopted from WebDAV (RFC 4918 §11.2), now popularized by APIs like GitHub, Stripe, and Shopify.
Use this when:
- The request syntax is valid.
- But the data violates a business rule or can’t be processed semantically.
Rule of thumb:
- Broken format →
400 - Valid format, invalid meaning →
422
Server errors: Who’s actually at fault?
A 5xx response doesn’t just mean “the server failed.” It carries who in the chain failed.
For example, for the flow Client → API Gateway → Auth Service → Database, the following 5xx responses should be returned:
- Database crash → Auth Service returns
500. - Gateway receives invalid response → returns
502. - Gateway overloaded →
503. - Database hung →
504.
!!In distributed systems, correct 5xx codes make debugging faster in observability stacks like Grafana, Honeycomb, or Datadog.!!
Real-world anti-patterns
1. APIs returning 200 for everything
A legacy pattern that refuses to die. Some APIs, especially older monoliths or systems built before REST conventions, respond with 200 OK even when an operation fails, stuffing an error object into the body instead:
At first glance, it looks harmless: the client can still inspect the payload for errors. But this practice breaks the core premise of HTTP: that the status line communicates the state of the response, not its content.
Some of the reasons why this anti-pattern is problematic:
- Clients lose semantic meaning. HTTP clients, SDKs, and libraries rely on status codes for control flow (e.g.,
fetch,axios,curl,OkHttp). - Caching and observability fail silently. A reverse proxy like Cloudflare or Fastly won’t differentiate between real successes and application errors if everything is
200. - Monitoring and alerting collapse. Your SLO dashboards will show 100% “success” while users can’t log in.
Instead, return meaningful codes. If authentication fails, use 401; if validation fails, 422; if the resource doesn’t exist, 404. Keep the JSON body for
context, not semantics.
Example:
2. 401 Without a WWW-Authenticate header
According to RFC 9110 §15.5.2, every 401 response must include a WWW-Authenticate header that describes how to authenticate:
This header isn’t just informational, it’s how clients learn how to fix the error.
Why this is a problem:
- Many HTTP clients (especially older or enterprise SDKs) automatically retry or prompt for credentials based on this header. Without it, they can’t recover.
- Security gateways and identity middlewares (like OAuth2, SAML, or OpenID Connect) often depend on it for redirect behavior.
- Some API documentation tools even parse it to infer authentication schemes.
Always include a descriptive WWW-Authenticate header, even for APIs that don’t use Basic or Bearer tokens. For example:
This helps clients differentiate between “I don’t know who you are” and “you’re using the wrong auth method.”
3. Swallowing 5xx errors in proxies
In modern distributed architectures (microservices, API gateways, service meshes) it’s common for an upstream dependency to fail. Unfortunately, many gateways normalize these errors into a generic 500 Internal Server Error.
Why this is a problem:
- Loss of diagnostic fidelity. A
502 Bad Gatewayor504 Gateway Timeouttells you where the failure occurred. A500hides that signal. - Makes observability harder. Tracing tools and metrics (Prometheus, Honeycomb, Datadog) rely on specific 5xx patterns to identify failure domains.
- Hurts SLAs. If all errors look the same, you can’t distinguish between platform outages and application bugs.
What you should do instead, is propagate the true error code whenever possible:
- Upstream timeout →
504 Gateway Timeout - Upstream malformed response →
502 Bad Gateway - Overloaded service →
503 Service Unavailable
Include contextual headers for debugging, such as:
That single bit of metadata can save hours of cross-team investigation.
4. Using 400 Bad Request as a catch-all
400 often becomes the dumping ground for every kind of client failure, from malformed syntax to invalid business logic. While technically not “wrong,” it’s overly broad.
This is a problem because the clients can’t tell if the issue is structural (missing field) or semantic (duplicate email). It also prevents fine-grained handling (for example, retrying syntactically valid requests).
Reserve 400 for structural problems (bad JSON, missing params). Use 422 Unprocessable Entity for logical validation errors or rule violations.
5. Hiding authorization logic behind 404
Some APIs return 404 Not Found instead of 403 Forbidden to avoid leaking resource existence.
For instance, when a user queries /users/123 but doesn’t have permission to view that profile.
Why this is a problem:
- This pattern is fine for public-facing APIs, but overuse can cause confusion in internal or authenticated systems.
- It makes debugging harder. The client can’t distinguish between “resource doesn’t exist” and “resource exists but access is denied.”
- It often breaks admin tooling and internal dashboards that depend on clear authorization semantics.
Use 404 selectively: when hiding the existence of resources is a real security requirement. Otherwise, be explicit with 403 Forbidden and return a structured error like:
6. Misusing 429 Too Many Requests
Rate limiting is another place where status codes get fuzzy. Some APIs incorrectly use 503 or even 400 when clients exceed quota.
Why this is a problem:
429exists specifically for this case and communicates rate-limiting intent clearly.- Clients can implement smarter backoff and retry logic if they receive the right code.
- Monitoring systems can separate throttling from real server failures.
When clients exceed quota, return:
That Retry-After header tells clients exactly when they can try again, no guessing required.
HTTP as a contract
Every status code is part of your API’s contract.
A frontend developer reading 401 should know it means “retry with credentials”, not “call the backend team.”
The goal isn’t perfection, it’s predictability. Clients should know:
- When to retry
- When to reauthenticate
- When to give up
Here’s a practical mapping table:
A decision framework for choosing the proper HTTP status code
When designing APIs, consistency matters more than perfection, but both help. Here’s a decision framework:
- Who caused the error?
- Client →
4xx - Server →
5xx
- Client →
- Is the user authenticated?
- No →
401 - Yes but not authorized →
403
- No →
- Is the request malformed or invalid?
- Malformed →
400 - Valid but violates logic →
422
- Malformed →
- Is the issue permanent or temporary?
- Temporary →
503or504 - Permanent →
410
- Temporary →
HTTP error code best practices checklist
- Treat status codes as API contracts: be consistent across all endpoints and services.
- Authenticate, then authorize
401→ invalid or missing credentials403→ authenticated but not allowed
- Differentiate structure vs. semantics
400→ malformed request422→ valid format, invalid meaning
- Signal resource state clearly
404→ unknown or missing410→ intentionally removed201/204→ successful create or delete
- Propagate real failure causes: use specific
5xxcodes (500,502,503,504) instead of a generic500. - Use
429 Too Many Requestsfor rate limits: always include aRetry-Afterheader. - Keep meaning in the status line, context in the body: JSON explains why, HTTP explains what.
- Monitor 4xx and 5xx separately: client issues vs. server issues require different action.
- Be predictable: a
401should always mean the same thing, no matter who implements it. - Fail gracefully: precise, consistent codes help clients recover and teams debug faster.
Closing thoughts
HTTP status codes aren’t decoration; they’re design.
They define how clients recover, how systems scale, and how teams debug.
When chosen carefully, they transform your API from something that merely works into something that communicates.
Or, to put it another way: “A precise status code today saves a thousand Slack messages tomorrow.”