400 Bad Request
Client error response defined by RFC 9110 §15.5.1.
Last updated July 27, 2026
Common causes at a glance
- Invalid percent-encoding or raw control characters in the request target
- Header block exceeding the server's buffer, often an oversized Cookie
- Conflicting Content-Length and Transfer-Encoding, rejected as smuggling
- A JSON or form body the application could not deserialise
Reproduce it
curl -i --path-as-is 'https://api.example.com/v1/items?q=%zz'What 400 Bad Request means
400 is two different errors wearing the same number. The protocol-level
400 comes from an HTTP parser: a request line it cannot tokenise, a header block larger
than the buffer, an invalid percent-escape in the target, a Content-Length
that disagrees with a Transfer-Encoding. Your application never runs.
The application-level 400 is a handler saying the JSON did not parse. Deciding which of the two you have is the first job in triage, and the body tells you: a terse HTML page from nginx means the request died at the edge, a JSON envelope means it reached code you wrote.
What the spec says
RFC 9110 §15.5.1 is deliberately broad, listing
“malformed request syntax, invalid request message framing, or deceptive request
routing” as examples. The syntax rules that get violated live in HTTP/1.1’s grammar:
RFC 9112 §3 for the request line, §5 for field
lines, and §6.3 for how body length is determined when
Content-Length and Transfer-Encoding conflict — an ambiguity a
server must reject rather than guess at, because guessing is request smuggling. The spec
demands nothing of the response, so if you generate 400 from an API,
RFC 9457 problem details is the only widely implemented convention for
naming the offending field.
What actually causes it
The one that wastes the most time is the oversized header block,
because it is invisible in application logs — nginx answers 400 before it
proxies anything. A session cookie that grew past
large_client_header_buffers (4×8k by default, per the
core module docs) produces a
single user who gets 400 on every page until they clear cookies while everyone else is
fine. A close second is a client library that pastes an unencoded URL into the request
target, so a literal space or | reaches the parser. Cloud load balancers are
also stricter than the origin behind them, so a request that works against localhost can
400 in production with no code change involved.
How to debug it
Establish where the 400 is generated before touching application code. Send the request to the origin directly, bypassing the CDN, and compare. If only the edge rejects it, the problem is syntax or size, not logic.
To test the header-size theory, strip everything optional and add pieces back:
curl -i -H 'Cookie:' -H 'User-Agent:' https://api.example.com/v1/items
If that returns 200, dump the real request with curl -v and measure the
header block. For target-encoding problems use --path-as-is so curl does not
silently normalise the URL and hide the bug. The
header inspector shows what actually went on the wire.
Working examples
curl
# --path-as-is stops curl normalising the target, so bad encoding survives.
curl -i --path-as-is 'https://api.example.com/v1/items?q=%zz'
# Bisect an oversized header block by dropping cookies and UA.
curl -i -H 'Cookie:' -H 'User-Agent:' https://api.example.com/v1/items
# Compare edge vs origin to find who generated the 400.
curl -i --resolve api.example.com:443:203.0.113.10 https://api.example.com/v1/items
Python (requests)
import requests
# Distinguish an edge 400 (HTML body, Server header from the proxy) from an
# application 400 (JSON envelope naming the offending field).
resp = requests.post(
"https://api.example.com/v1/items",
json={"name": None}, # deliberately invalid
timeout=10,
)
print(resp.status_code, resp.headers.get("Server"))
print(resp.headers.get("Content-Type"))
print(resp.text[:300])
# Measure your own header block; nginx defaults to 8 KB per buffer.
size = sum(len(k) + len(v) + 4 for k, v in resp.request.headers.items())
print("request header bytes:", size)
Node (fetch)
// fetch() rejects some malformed input client-side, so build the bad
// request explicitly and read the body to see who answered.
const res = await fetch("https://api.example.com/v1/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{not valid json",
});
console.log(res.status, res.headers.get("server"));
console.log(await res.text());
// A problem+json body (RFC 9457) means the application produced the 400.
if (res.headers.get("content-type")?.includes("problem+json")) {
console.log("application-level validation failure");
}Reference and tooling
The authoritative list of assigned status codes is theIANA HTTP Status Code Registry. For a searchable copy with the semantics summarised, use theHTTP status code reference on this site. To see exactly what a server is sending, theheader inspector shows the raw status line and response headers, and thecurl builder assembles the request flags for you.
Other 4xx codes
- 401 Unauthorized
- 402 Payment Required
- 403 Forbidden
- 404 Not Found
- 405 Method Not Allowed
- 406 Not Acceptable
- 407 Proxy Authentication Required
- 408 Request Timeout
- 409 Conflict
- 410 Gone
- 411 Length Required
- 412 Precondition Failed
- 413 Content Too Large
- 414 URI Too Long
- 415 Unsupported Media Type
- 416 Range Not Satisfiable
- 417 Expectation Failed
- 418 (Unused)
- 419 Page Expired
- 421 Misdirected Request
- 422 Unprocessable Content
- 423 Locked
- 424 Failed Dependency
- 425 Too Early
- 426 Upgrade Required
- 428 Precondition Required
- 429 Too Many Requests
- 431 Request Header Fields Too Large
- 444 No Response
- 451 Unavailable For Legal Reasons
- 494 Request Header Too Large
- 495 SSL Certificate Error
- 496 SSL Certificate Required
- 497 HTTP Request Sent to HTTPS Port
- 499 Client Closed Request