In this article
November 12, 2025
November 12, 2025

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:

Range Class Meaning Common Examples
1xx Informational Request received, continuing 100 Continue, 101 Switching Protocols
2xx Success Request completed successfully 200 OK, 201 Created, 204 No Content
3xx Redirection Further action needed 301 Moved Permanently, 307 Temporary Redirect
4xx Client Error Fault in the request 400 Bad Request, 401 Unauthorized, 404 Not Found
5xx Server Error Server failed to fulfill a valid request 500 Internal Server Error, 503 Service Unavailable

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-Authenticate header specifying how to authenticate.
  • The client can retry the request with valid credentials.

Example:

	
GET /api/me
→ 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="Token expired"
	

Common causes:

  • Missing access token
  • Invalid or expired JWT
  • Missing Authorization header

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:

	
GET /api/admin/reports
Authorization: Bearer valid_token_for_basic_user
→ 403 Forbidden
	

The token was valid, but the user lacks the required role.Common real-world mistakes:

  • Returning 403 for missing credentials (should be 401)
  • Returning 401 for valid but underprivileged users (should be 403)

!!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:

	
GET /users/abc123
→ 404 Not Found
	

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:

	
GET /docs/v1
→ 410 Gone
	

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 410 when the absence is deliberate and permanent.
  • Use 404 when 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 a Location header).
  • 204 No Content: Request succeeded, no response body needed.
  • 202 Accepted: Request received and queued, but not yet processed (ideal for async systems).

Example:

	
DELETE /sessions/xyz
→ 204 No Content
	

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
	
POST /users
{ "email": "notanemail" }
→ 400 Bad Request
	

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.
	
POST /users
{ "email": "existing@example.com" }
→ 422 Unprocessable Entity
	

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.

Code Name Use Case
500 Internal Server Error Generic fallback — unhandled exception, bug, crash
502 Bad Gateway Reverse proxy or load balancer received an invalid response from upstream
503 Service Unavailable Server temporarily unavailable (maintenance, overload)
504 Gateway Timeout Upstream service didn’t respond in time

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:

	
{ "success": false, "error": "Invalid password" }
	

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:

	
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{ "error": "Invalid password" }
	

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:

	
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="Token expired"
	

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:

	
WWW-Authenticate: Bearer realm="user-api", error="invalid_token", error_description="Missing access token"
	

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 Gateway or 504 Gateway Timeout tells you where the failure occurred. A 500 hides 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:

	
X-Upstream-Service: auth-service
X-Failure-Type: timeout
	

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:

	
{ "error": "forbidden", "reason": "insufficient_permissions" }
	

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:

  • 429 exists 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:

	
HTTP/1.1 429 Too Many Requests
Retry-After: 120
	

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:

Situation Response Notes
Missing or invalid token 401 Client unauthenticated
Authenticated but lacks permission 403 Authorization denied
Malformed JSON or missing field 400 Syntax issue
Valid input but fails business logic 422 Semantic issue
Resource never existed 404 Unknown path
Resource deleted permanently 410 Gone intentionally
Internal exception 500 Server bug
Downstream service failure 502 Gateway issue
Server under maintenance 503 Retry later
Dependency timeout 504 Retry after delay

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
  • Is the user authenticated?
    • No → 401
    • Yes but not authorized → 403
  • Is the request malformed or invalid?
    • Malformed → 400
    • Valid but violates logic → 422
  • Is the issue permanent or temporary?
    • Temporary → 503 or 504
    • Permanent → 410

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 credentials
    • 403 → authenticated but not allowed
  • Differentiate structure vs. semantics
    • 400 → malformed request
    • 422 → valid format, invalid meaning
  • Signal resource state clearly
    • 404 → unknown or missing
    • 410 → intentionally removed
    • 201 / 204 → successful create or delete
  • Propagate real failure causes:  use specific 5xx codes (500, 502, 503, 504) instead of a generic 500.
  • Use 429 Too Many Requests for rate limits:  always include a Retry-After header.
  • 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 401 should 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.”

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.