An HTTP request looks like one clean unit of work: a method, a path, some headers, and a body. But on the modern web your request almost never reaches a single server. It passes through a front end first, a proxy or load balancer or content delivery network, which then forwards it to a back end. http request smuggling is what happens when those two servers read the same bytes and disagree about where one request stops and the next one begins. When they disagree, an attacker can hide a second request inside the first, and the back end will glue it onto whatever victim request arrives next. This post walks the mechanics precisely: why two length headers fight, what one smuggled prefix does to the next person in line, how HTTP/2 reopens the wound through downgrades, and how to shut it.
One connection, two readers, two opinions
The front end and the back end usually keep a connection open between themselves and reuse it for many requests from many users. This is normal and efficient. It also means the back end is reading a continuous stream of bytes and slicing it into requests on its own. The front end already sliced the same stream. As long as both slice it at the same byte, everything is fine and nobody notices the machinery underneath.
The attack lives in the moment they slice at different bytes. If the front end thinks request A ended at byte 100 but the back end thinks it ended at byte 80, then 20 bytes the front end believed were part of A are sitting at the front of the back end’s buffer, waiting. Those 20 bytes are attacker chosen. When the next real request arrives, the back end reads the leftover 20 bytes first, then the victim’s bytes, and treats the whole thing as one request. The victim’s request has been prefixed with the attacker’s smuggled content, and the victim never sent it.
Request smuggling is not a parsing bug in one server. It is a disagreement between two servers about a question they both think has an obvious answer: where does this request end?
Why a request has two ways to say how long it is
To send a body in HTTP/1.1 you have to tell the server how many bytes to read. The protocol gives you two ways to do that, and that redundancy is the whole problem.
The first way is Content-Length. You count the bytes of the body and put the number in a header. Content-Length: 11 means read exactly eleven bytes after the blank line, and that is the body. Simple and exact.
The second way is Transfer-Encoding: chunked. Instead of declaring the total up front, you send the body as a series of chunks. Each chunk starts with its own size written in hexadecimal on its own line, then the chunk data, then a blank line. A chunk of size zero marks the end of the body. So a chunked body that carries the text q=smuggling looks like this:
Transfer-Encoding: chunked b q=smuggling 0
The b is hexadecimal for 11, the length of q=smuggling. The 0 on its own line is the terminator. The reader is supposed to stop there. Everything before the 0 chunk is the body, and everything after it is the start of the next request.
Two ways to declare length is one way too many. What is a server supposed to do when a single request arrives carrying both a Content-Length and a Transfer-Encoding: chunked header that point at different boundaries? The standard has an answer. RFC 9112 section 6.3 says that when both are present, Transfer-Encoding wins and Content-Length is ignored. The same standard warns that a request carrying both may be an attempt at request smuggling. The trouble is that not every server in the chain obeys the rule, and the ones that disagree are the ones you can attack.
Walking one http request smuggling example byte by byte
The cleanest way to see http request smuggling is to follow one example slowly. The variants are named after which header each server trusts. CL.TE means the front end honors Content-Length and the back end honors Transfer-Encoding. Watch what that mismatch does to one crafted request.
The attacker sends a single request that includes both length headers on purpose:
POST / HTTP/1.1 Host: acme-notes.example Content-Length: 6 Transfer-Encoding: chunked 0 GET /admin HTTP/1.1 Host: acme-notes.example Foo: x
Now read it twice, once as each server.
The front end trusts Content-Length: 6. It counts six bytes of body after the blank line. Those six bytes are the 0, then the line ending, then the blank line that follows. As far as the front end is concerned the body is the short chunked terminator and nothing more. It decides the request ends right there and forwards the whole thing, every byte, to the back end on the shared connection. The front end believes it forwarded one ordinary POST.
The back end trusts Transfer-Encoding: chunked and ignores the Content-Length entirely. It reads the body as chunks. The very first chunk it sees is 0, the terminator. So the back end decides the body is empty and the POST is finished at that point. But the bytes after the 0 chunk did not vanish. The back end now has this still sitting in its buffer, unread:
GET /admin HTTP/1.1 Host: acme-notes.example Foo: x
The back end treats those leftover bytes as the beginning of the next request on the connection. It does not get attributed to the attacker. It gets stitched onto whatever arrives next. The smuggled GET /admin is the prefix, and it is missing a final piece, the rest of its headers, which is why the attacker leaves Foo: x dangling with no value terminated. That dangling header swallows the first line of the next victim’s request so the smuggled request stays valid.
What the prefix does to the next victim
Say an ordinary user sends a normal request a moment later:
GET / HTTP/1.1 Host: acme-notes.example Cookie: session=victim-session-here ...
The back end already had the smuggled prefix waiting. So what it actually parses is the attacker’s lines followed by the victim’s lines fused together. The Foo: header absorbs the victim’s request line, and the request the back end runs is the attacker’s GET /admin carrying the victim’s session cookie. The victim asked for the home page and instead drove a request the attacker authored. Depending on the app, this poisons the response queue so the victim gets back a page meant for someone else, or it captures the victim’s own request data into a place the attacker can read, or it slips a request past the front end’s access rules because the front end only ever saw the harmless looking POST.
That last point is the sharp one. Front ends are often where access control and request filtering live. They block /admin, strip dangerous headers, enforce rate limits. A smuggled request never passes the front end as a request at all. It rides inside the body of a request the front end approved, then becomes a request only after it is already past the gate. The control was real. It was just looking at the wrong bytes. This is the same shape of problem we describe in our web security glossary: a check that runs on a different view of the data than the action it is meant to protect.
It helps to be precise about the three ways a smuggled prefix turns into damage, because they are not the same attack and they do not need the same conditions.
- Bypassing front end controls. The smuggled request reaches paths and methods the front end was supposed to refuse. The attacker smuggles a request to a restricted route, and because the front end only inspected the approved outer request, the inner one runs with no filter between it and the back end.
- Capturing another user’s request. The attacker smuggles a prefix that ends with a header expecting a long value, like a comment field or a search parameter, so the victim’s incoming request, cookies and all, is captured as that value and stored where the attacker can later read it back.
- Poisoning the response queue. Once the boundary between requests is off by one, the back end’s responses fall out of step with who asked for them. The attacker’s smuggled request consumes a response slot, and the next user receives a response meant for a different request. Chain this with a reflected input or a cached page and a single smuggle can serve a poisoned response to many users.
The mirror image, and the obfuscation trick
TE.CL is the same idea flipped. The front end honors Transfer-Encoding and the back end honors Content-Length, so the attacker crafts a chunked body whose declared size leaves bytes the back end reads as a new request. The roles swap but the outcome is identical: a prefix left in the back end’s buffer.
TE.TE is sneakier. Both servers support Transfer-Encoding, so in theory they agree. The attacker breaks that agreement by obfuscating the header so that one server recognizes it and the other does not. A header written as Transfer-Encoding: xchunked, or with odd spacing, or duplicated, or with a tab in a place a strict parser rejects but a lenient one accepts, can make one server fall back to Content-Length while the other still reads chunks. The instant one server stops honoring Transfer-Encoding, you are back to a CL versus TE split, and the smuggle works again. The lesson is that small differences in how strictly each server parses a header name are enough to desync the chain.
HTTP/2 was supposed to fix this, and then it did not
HTTP/2 removes the ambiguity at its root. It does not send headers and bodies as a text stream you have to slice. Each message body is carried in binary data frames, and every frame has a built in length field. The protocol knows exactly where a message ends because the framing tells it, not because two text headers happen to agree. End to end HTTP/2 has no place for a length disagreement to hide. If the whole chain spoke HTTP/2 from the browser to the back end, this class of bug would mostly be over.
The chain does not speak HTTP/2 the whole way. Most front ends accept HTTP/2 from the internet and then rewrite each request as HTTP/1.1 before handing it to the back end, because the back end still speaks the older protocol. That rewrite is called a downgrade, and it is where James Kettle’s research, presented as HTTP/2: The Sequel is Always Worse, showed the bug coming back to life.
When the front end downgrades, it has to invent the HTTP/1.1 length headers from the HTTP/2 frame data. It writes a Content-Length, or it copies across a Transfer-Encoding the request carried. If the front end does this carelessly, the back end is once again reading length from a text header that may not match reality.
H2.CL and H2.TE
H2.CL is the downgrade version of a Content-Length desync. In HTTP/2 the true body length is fixed by the data frames, so the content-length field a client sends is just a claim the server is supposed to validate against the frames. If the front end fails to check it and trusts the attacker supplied value during the downgrade, it writes that wrong Content-Length into the HTTP/1.1 request it forwards. The back end then reads too few or too many bytes, and the leftover becomes a smuggled prefix, exactly as in CL.TE.
H2.TE is the Transfer-Encoding version. The HTTP/2 standard says a request carrying a transfer-encoding header should be treated as malformed and rejected, because chunked encoding has no meaning inside HTTP/2 framing. A front end that forwards that header anyway hands the back end a Transfer-Encoding: chunked on a downgraded request. The back end honors it, reads the body as chunks regardless of the front end’s idea of the length, and desyncs. Same prefix, same poisoned queue, reached through a header the front end should have thrown away.
The reason the downgrade case is worth so much attention is that it widened the target list. Pure HTTP/1.1 smuggling needs two HTTP/1.1 servers that parse length differently, which careful operators had started to fix. The downgrade reopened the bug on chains that looked modern and safe from the outside, where the public facing server speaks HTTP/2 and only the hop you cannot see still speaks HTTP/1.1. Kettle’s research also showed that HTTP/2 carries its own smuggling surface beyond length, because attackers can smuggle through header names, header values, and even the pseudo headers that HTTP/2 uses for the method and path, all of which have to be flattened into a single text line during a downgrade. Anywhere a special character survives that flattening, a new request boundary can be forged.
Defenses that actually hold
The fixes are not clever payloads to block. There is no signature for a smuggled request, because every byte in it is valid on its own and the attack is purely in how two servers slice the stream. So the defenses do not try to spot bad content. They are about making the two servers agree on boundaries, or refusing to forward anything the two of them might read differently.
- Reject ambiguous requests instead of guessing. A request that carries both
Content-LengthandTransfer-Encodingis not a request to interpret, it is a request to refuse. RFC 9112 lets a server reject it outright, and it requires the server to close the connection after responding to such a request so no leftover bytes can poison the next one. Closing the connection is the part that breaks the smuggle, because the prefix has nowhere to wait. - Make the front end normalize and own the framing. The front end should rewrite every request into one unambiguous form before forwarding, with exactly one length header that it computed itself, so the back end never has to choose. If the front end will not honor a
Transfer-Encodingit should strip it, not pass it along for the back end to honor differently. - Reject the
Transfer-Encodingyou will not honor. A front end that does not implement chunked the way the back end does should reject requests that use it, including obfuscated spellings, rather than forwarding a header it parses loosely. - Use HTTP/2 end to end where you can. If the connection to the back end also speaks HTTP/2, there is no downgrade and no place to forge a length header. When you must downgrade, validate the
content-lengthagainst the real frame data and drop anytransfer-encodingthe HTTP/2 standard says is malformed. - Reuse back end connections carefully. Much of the impact comes from one shared connection carrying many users. Some deployments reduce blast radius by not pooling back end connections across users, so a leftover prefix cannot land on a stranger’s request.
These are not hypothetical. In March 2025 Akamai disclosed CVE-2025-32094, a request smuggling flaw James Kettle reported in their edge platform. It chained an HTTP/1.x OPTIONS request, an Expect: 100-continue header, and obsolete line folding so that two in path Akamai servers read one request two different ways. Akamai fixed it across the platform with no known exploitation, but the cause is the same one this post has circled the whole time: two servers, one stream, two opinions about where a request ends.
The assumption underneath
Every link in this chain is built by people doing something reasonable. The front end forwards requests to be fast. The back end reads length from a header because that is how the protocol works. The standard offers two ways to declare length because both are genuinely useful. None of those choices is wrong on its own. The bug is the assumption that connects them: that the front end and the back end will always agree on where a request ends, because the question feels like it has one obvious answer.
It does not. The attack lives entirely in the gap between two readers of the same bytes, a gap nobody put there on purpose and nobody tested for, because each server was certain the other saw what it saw. That is the kind of flaw you find by asking what each component assumes about the one next to it, then arranging for the assumption to be false, rather than by scanning for a known bad string. It is the same trust in a validated view of a request that powers bugs like server side request forgery, and it is exactly the class of bug an autonomous researcher that tests assumptions is built to surface. The two servers think they agree. The whole exploit is proof that they do not.
Frequently asked questions
What is HTTP request smuggling in simple terms?
It is an attack that works when a front end server and a back end server read the same bytes on a shared connection but disagree about where one request ends and the next begins. The attacker crafts a request that the front end treats as finished while the back end thinks part of it is the start of a new request. Those leftover bytes wait in the back end buffer and get stitched onto the next user’s request, so the back end runs a request the attacker wrote. The PortSwigger Web Security Academy covers the mechanics in depth in its request smuggling guide.
Why do the two length headers cause the problem?
HTTP/1.1 gives two ways to declare how long a body is. Content-Length states the byte count up front, while Transfer-Encoding: chunked sends the body as sized chunks ending in a zero length chunk. When one request carries both headers and they point at different boundaries, servers can disagree. RFC 9112 section 6.3 says Transfer-Encoding wins and warns the request may be a smuggling attempt, but not every server obeys, and the ones that disagree are the ones an attacker chains against.
What are CL.TE, TE.CL, and TE.TE?
They name which header each server trusts. CL.TE means the front end honors Content-Length and the back end honors Transfer-Encoding, so the back end stops at the zero chunk and leaves the rest as a smuggled prefix. TE.CL is the reverse. TE.TE is when both support chunked, so the attacker obfuscates the Transfer-Encoding header, for example with odd spacing or a misspelling, so one server stops honoring it and the chain desyncs again.
Doesn’t HTTP/2 prevent request smuggling?
End to end HTTP/2 mostly does, because it carries each body in binary frames with a built in length, leaving no room for two text headers to disagree. The risk returns when a front end accepts HTTP/2 from the internet and downgrades each request to HTTP/1.1 for the back end. If it forges a wrong content-length (H2.CL) or forwards a transfer-encoding it should have rejected as malformed (H2.TE), the back end desyncs just like before. Using HTTP/2 to the back end too, or validating length against the real frames, removes the gap.
