Skip to content
Control Plane Labs

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.

Other 4xx codes