Skip to content
Control Plane Labs

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