All posts

401 vs 403, 400 vs 422: which status code to use

When to return 401, 403, 404, 400, 422, 409, 415, 405 or 429, the headers each one needs, Problem Details bodies and what common frameworks do by default.

By Last reviewed 7 min readHTTPAPIsREST

The status code is the first thing an API client branches on. A mobile app that gets a 401 refreshes its token; one that gets a 403 should say "you don't have access" and stop. Send the wrong one and you get refresh loops and users logged out for no reason. The definitions in RFC 9110, the current HTTP semantics standard, make the choice straightforward.

Keep the HTTP status code reference open as you read: search for a code or a word like "conflict" and expand it for common causes, an example response and related codes.

The decision table

SituationStatusHeader to send
No credentials, or the token is expired, revoked or malformed401WWW-Authenticate (required)
The caller is identified but isn't allowed to do this403—
Not allowed, and confirming the resource exists would leak information404—
The body can't be parsed: broken JSON, bad framing400—
The body's media type isn't one the endpoint takes415Accept (optional)
The body parses, but its values are invalid422, or 400 used consistently—
Valid request that clashes with the current state409—
The URL exists but doesn't support the method405Allow (required)
The client is over its rate limit429Retry-After (optional; send it anyway)

When several apply, check authentication first, so anonymous callers learn nothing about your validation rules, then permission, the body's format, its meaning, and state.

401: the server doesn't know who you are

RFC 9110 defines 401 Unauthorized as a request that "lacks valid authentication credentials for the target resource". Despite the name, it means unauthenticated: no Authorization header, or one the server rejects, such as an expired token. One requirement is easy to miss: the server "MUST send a WWW-Authenticate header field" with at least one challenge, telling the client which scheme to use.

For OAuth bearer tokens, RFC 6750 defines that challenge. A request with no token gets a bare WWW-Authenticate: Bearer realm="api" (the RFC says to leave out the error code when no credentials were sent). An expired token gets error="invalid_token":

GET /v1/invoices/inv_8f2c HTTP/1.1
Host: api.example.com
Authorization: Bearer <expired-access-token>

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token expired"
Content-Type: application/problem+json

{"title": "Unauthorized", "status": 401, "detail": "The access token expired. Get a new one and retry."}

A well-behaved client treats 401 as "fix your credentials": refresh the token once, retry once, then send the user to sign in. If your access tokens are JWTs, the exp claim shows whether expiry is the cause; how to read JWT claims walks through it.

403: the server knows who you are, and the answer is no

403 Forbidden means the server "understood the request but refuses to fulfill it". If credentials were sent, the server considers them insufficient, and the client "SHOULD NOT automatically repeat the request with the same credentials". The reason needn't be the user at all: an IP allowlist or a failed CSRF check is a 403 too. A client that refreshes its token on every 403 loops forever.

RFC 6750 maps a token that lacks a scope to 403 with error="insufficient_scope", optionally naming the scope needed:

DELETE /v1/projects/42 HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="api", error="insufficient_scope", scope="projects:delete"
Content-Type: application/problem+json

{"title": "Forbidden", "status": 403, "detail": "This token can read projects but not delete them."}

When 404 is the better answer

A 403 confirms that the resource exists, which helps anyone enumerating IDs. RFC 9110 allows the alternative: a server hiding a forbidden resource "MAY instead respond with a status code of 404", and 404 itself covers a server "not willing to disclose that one exists". GitHub's REST API does this for private repositories, so as not to confirm they exist.

The rule I use: if the caller has no legitimate way to know the resource exists, answer 404 with exactly the body you send for an ID that doesn't exist. If they can already see it (they can read the project, just not delete it), answer 403 with a reason.

400 vs 422: syntax versus meaning

400 Bad Request is the general client error; RFC 9110's examples are "malformed request syntax, invalid request message framing, or deceptive request routing". 422 Unprocessable Content is narrower: the server understands the content type, "the syntax of the request content is correct", but it can't act on what the content says. It began as WebDAV's "Unprocessable Entity" (RFC 4918) and is now core HTTP.

A trailing comma is a syntax problem, so it gets 400:

POST /v1/users HTTP/1.1
Content-Type: application/json

{"email": "ana@example.com", "age": 42,}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"title": "Bad Request", "status": 400, "detail": "Body is not valid JSON: trailing comma at line 1, column 39."}

Well-formed JSON with impossible values gets 422. RFC 9457's validation example adds an errors member with JSON Pointers, so clients can show each message beside its field:

POST /v1/users HTTP/1.1
Content-Type: application/json

{"email": "not-an-email", "age": -3}

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation",
  "title": "Your request is not valid.",
  "status": 422,
  "errors": [
    {"pointer": "#/email", "detail": "must be an email address"},
    {"pointer": "#/age", "detail": "must be 0 or more"}
  ]
}

Many APIs return 400 for validation failures too, as Spring and Django REST Framework do by default, and that's defensible: 400 covers any client error. What matters is one convention per API, and a body that tells the client whether to fix its JSON or its fields. For the syntax errors themselves, see fix common JSON syntax errors.

409, 415, 405 and 429

409 Conflict is a valid request that clashes with the resource's current state, where "the user might be able to resolve the conflict and resubmit": signing up with an email that's already registered, or saving an edit made against a stale version. If the client sent If-Match and the ETag no longer matches, 412 Precondition Failed is more specific.

415 Unsupported Media Type is for a Content-Type the endpoint doesn't accept, such as form data posted to a JSON-only endpoint. Unlike a generic 400, it names the problem, and RFC 9110 lets you list the accepted types in an Accept response header:

HTTP/1.1 415 Unsupported Media Type
Accept: application/json

405 Method Not Allowed means the URL exists but the method doesn't apply to it. The server "MUST generate an Allow header field" listing the methods it does support:

DELETE /v1/invoices HTTP/1.1

HTTP/1.1 405 Method Not Allowed
Allow: GET, POST

429 Too Many Requests comes from RFC 6585, which says the response MAY include Retry-After (seconds or an HTTP date) and "MUST NOT be stored by a cache". Send the header; without it a client can only guess how long to back off.

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

One error format: Problem Details

These bodies follow RFC 9457, Problem Details for HTTP APIs (July 2023, replacing RFC 7807). It defines application/problem+json and five optional members:

  • type: a URI naming the kind of problem. If missing, it means "about:blank" (nothing beyond the status code), and title should be the standard phrase, such as "Not Found".
  • title: a short summary that doesn't change between occurrences.
  • status: a copy of the status code, which must match the real one.
  • detail: this occurrence, explained so the client can fix it.
  • instance: a URI for this occurrence, handy in support tickets.

Extra members such as errors are allowed, and clients must ignore ones they don't recognise. Branch on type and status, not the detail text.

What frameworks do by default

Defaults differ. I checked these against the source of Express 5.3, FastAPI 0.162, Spring MVC's DefaultHandlerExceptionResolver and Django REST Framework 3.18:

CaseExpress 5FastAPISpring MVCDRF
Malformed JSON400, from express.json()422, type json_invalid400400
Validation failsUp to your code422400 (@Valid)400
Wrong Content-TypeBody not parsed; req.body is undefined422415 with Accept415
Unsupported method404, "Cannot DELETE /items"405 with Allow405 with Allow405 with Allow

Note that Express has no automatic 405: a path registered for GET and POST answers DELETE with a 404. And DRF answers an unauthenticated request with 403, not 401, when the view's first authentication class can't produce a WWW-Authenticate header, as with SessionAuthentication.

Checking your API from the browser

To see what an endpoint really returns, send requests from the browser-based API Tester with Bearer, Basic or API-key auth and read the status and response headers. Requests go straight from your browser, so CORS applies, and it affects exactly these codes:

  • For cross-origin requests, browser scripts can only read a few response headers, such as Content-Type and Cache-Control, unless the API lists more in Access-Control-Expose-Headers. WWW-Authenticate, Allow and Retry-After aren't readable by default, so their absence in the tester proves nothing. curl -i shows every header.
  • If authentication or rate limiting runs before your CORS middleware, its 401s and 429s go out without Access-Control-Allow-Origin. The browser hides the status, and the tester reports "Blocked by CORS or the network". Your real frontend hits the same wall.
  • An Authorization header or a JSON content type triggers an OPTIONS preflight. If your auth middleware answers it with 401, the real request is never sent.

Next steps

Look up any code you're unsure of in the status code reference; each card links its related codes, so 409 leads you to 412 and 401 to 403. If your 401s come from JWTs, JWT decoder vs JWT verifier explains why a server has to verify a token, not just read it.

Paste anything. devprix picks the tool.

Press ⌘K on any page, paste JSON, a JWT, a timestamp or a URL, and the right tool opens with your data filled in.