400 Bad Request
Client error response defined by RFC 9110 §15.5.1.
Written and maintained by Ben Ennis
Last reviewed July 27, 2026 · How we verify this
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.
CL.TE and TE.CL: why a front-end and back-end can disagree about where a request ends
RFC 9112 §6.3 resolves a
request carrying both Content-Length and Transfer-Encoding by
requiring the receiver to use Transfer-Encoding and reject or strip the
Content-Length, and it names the reason directly: such a message “might
indicate an attempt to perform request smuggling.” A compliant single server never has
to make this choice ambiguous, but a request usually crosses two HTTP implementations,
a front-end proxy and a back-end origin, and the danger is each one resolving the
conflict differently.
In a CL.TE attack the front end trusts Content-Length and forwards
what it thinks is the whole request; the back end trusts Transfer-Encoding
and reads a chunked body, stopping at the terminating zero-length chunk and leaving
bytes unread on the connection. Those leftover bytes get prepended to the next request
the back end reads on that same reused connection, letting an attacker’s request smuggle
in as if it were part of a different user’s traffic. A TE.CL attack is the same
idea inverted: the front end honours Transfer-Encoding, the back end
honours Content-Length, and again a boundary is measured with two different
rulers on the same wire.
This is why the correct server behaviour is to reject the ambiguous request outright with 400 rather than pick one header and proceed. The PortSwigger HTTP request smuggling reference documents both variants against real reverse proxies. If you operate a proxy in front of an origin you do not control, confirm both sides parse framing headers identically, or strip whichever one you do not intend to honour before forwarding.
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.
Related reading
- Every HTTP status code, and when it shows up
The wider map: which codes you actually meet in production, and the pairs everyone confuses.
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