Why your multipart/form-data request fails: CRLF, LF, and the \r\r\n trap

The RFC 2046 multipart standard mandates CRLF line endings. When your HTTP client mixes them up, parsers reject the body with no diagnostic — here's how to find the bug and fix it.

Your webhook captured a request body, and the Yellorn inspector flagged its line-ending style as mixed or CR only. Switching the Req Body tab to Whitespace mode shows glyphs like (carriage return), (line feed), or the bug-pattern combination ␍␍␊ at the end of one or more lines.

If the receiver of that body is a strict multipart parser — most server frameworks, every CDN's edge multipart handler, and every spec-compliant SDK — the body is being rejected with a 400 Bad Request, a 415 Unsupported Media Type, or an even less helpful generic "could not parse form" error. The confusing part: the raw text looks fine. Line endings are invisible in a plain <pre> block, so the smoking gun only shows up once you reveal the whitespace.

RFC 2046 §5.1 (the multipart media-type standard, referenced by RFC 7578 for multipart/form-data specifically) is explicit: every boundary, header line, and the header-to-body separator inside a multipart body must end with CRLF — the two-byte sequence 0x0D 0x0A (\r\n).

A correctly formed part looks like this when invisible characters are revealed:

--BOUNDARY␍␊
Content-Disposition: form-data; name="file"; filename="x.pdf"␍␊
Content-Type: application/pdf␍␊
␍␊
<binary bytes here>␍␊
--BOUNDARY--␍␊

Note the empty ␍␊ line between the headers and the body — that's the spec-mandated separator, consisting of exactly one CRLF on its own line. Two CRLFs back-to-back. Not \r\n\n, not \n\n, not \r\r\n.

Multipart parsers do not guess. They scan the byte stream for the literal boundary sequence \r\n--BOUNDARY\r\n and reject anything that doesn't match the expected envelope. Three failure modes are common:

  • Lone \n (Unix line ending) — a permissive parser may accept the part, but strict parsers (Go's mime/multipart, Rust's multer, several Cloudflare Workers helpers) return "unexpected EOF" because they never find the CRLF-prefixed boundary.
  • Lone \r followed by \r\n (the \r\r\n bug) — almost always caused by an HTTP client that double-encoded the line separator. Parsers see the first \r as the start of a CRLF and never find the matching \n; the body looks corrupt from byte 0.
  • Mixed CRLF and LF in the same body — boundary lines use CRLF, header lines use LF (or vice versa). The boundary scanner finds the first boundary correctly, then misses the second because its byte prefix doesn't match.

None of these failures surface a useful diagnostic. That's why the Yellorn inspector calls them out explicitly — the bug is invisible until you look for it.

Manually-constructed multipart bodies

Anywhere you wrote `--BOUNDARY\nContent-Disposition…\n` by hand, you almost certainly produced an LF-only body. Most languages' string literals don't include CRLF unless you explicitly write \r\n.

Double-encoding line separators

Producing a body in code that already uses CRLF, then running it through a library that prepends another \r at every line boundary, yields \r\r\n across the entire body. Common offenders: Windows file readers run in binary mode and then re-written through a text-mode helper, manually re-joined headers with "\r" + "\r\n", badly-configured proxy intermediaries.

Wrong cURL flag

curl --data (or -d) strips newlines silently. curl --data-binary preserves the body exactly as it appears on disk. If you're piping a captured request through cURL, always use --data-binary.

Boundary mismatch

Less common but related: the boundary= parameter in the Content-Type header doesn't match the literal boundary used in the body, or the body's boundary is missing the leading -- prefix that RFC 2046 mandates for the in-body marker. Yellorn's stats line surfaces the parsed boundary so you can spot this at a glance.

JavaScript / TypeScript (fetch)

Always use FormData — never serialize a multipart body by hand. The browser's implementation gets line endings, encoding, and boundary generation right every time.

const fd = new FormData();
fd.append("file", fileBlob, "report.pdf");
fd.append("title", "Q3 numbers");

await fetch(url, {
  method: "POST",
  body: fd,
  // Do NOT set Content-Type manually — fetch derives it
  // (including the boundary) from the FormData instance.
});

Python (requests)

Use the files= parameter, not data=. requests handles CRLF and boundary generation internally.

import requests

with open("report.pdf", "rb") as f:
    response = requests.post(
        url,
        files={"file": ("report.pdf", f, "application/pdf")},
        data={"title": "Q3 numbers"},
    )

Python (urllib / hand-rolled)

If you must construct the body yourself (testing, weird edge cases), use b"\r\n" — bytes, not str — for every separator:

CRLF = b"\r\n"
boundary = b"----yellorn-example"
body = (
    b"--" + boundary + CRLF
    + b'Content-Disposition: form-data; name="file"; filename="x.pdf"' + CRLF
    + b"Content-Type: application/pdf" + CRLF
    + CRLF  # blank line separating headers from body
    + payload_bytes + CRLF
    + b"--" + boundary + b"--" + CRLF
)

cURL

Prefer -F (form upload) — it builds a spec-compliant body for you:

curl -X POST "$URL" \
  -F "file=@report.pdf;type=application/pdf" \
  -F "title=Q3 numbers"

When you have a captured raw body to replay, use --data-binary @file (not --data):

curl -X POST "$URL" \
  -H "Content-Type: multipart/form-data; boundary=$BOUNDARY" \
  --data-binary @captured-body.bin

Go

Use mime/multipart; the writer takes care of CRLF + boundary placement.

var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, _ := w.CreateFormFile("file", "report.pdf")
io.Copy(part, fileReader)
_ = w.WriteField("title", "Q3 numbers")
_ = w.Close()

req, _ := http.NewRequest("POST", url, &buf)
req.Header.Set("Content-Type", w.FormDataContentType())

Jinja templates (and similar templating engines)

Jinja and similar engines (used by API request builders, integration platforms, no-code workflows) render newlines as LF by default — so a body that looks right in the editor is wrong on the wire. Try POSIX style first; if the receiver rejects it, switch to the Windows-style version below. The Windows style always works.

POSIX style — try this first. Write the body naturally; newlines render as LF.

--MyBoundary
Content-Disposition: form-data; name="example_key_1"

example_value_1
--MyBoundary
Content-Disposition: form-data; name="attachment"; filename="{{ resume_filename }}"
Content-Type: application/pdf

{{ binary_content }}
--MyBoundary--

Windows style — use if POSIX is rejected. Declare a CRLF variable once, end every line with {{-br-}}:

{%- set br = "\r\n" | safe -%}
--MyBoundary{{-br-}}
Content-Disposition: form-data; name="example_key_1"{{-br-}}
{{-br-}}
example_value_1{{-br-}}
--MyBoundary{{-br-}}
Content-Disposition: form-data; name="attachment"; filename="{{ resume_filename }}"{{-br-}}
Content-Type: application/pdf{{-br-}}
{{-br-}}
{{ binary_content }}{{-br-}}
--MyBoundary--

Why this works:

  • "\r\n" | safe — literal CRLF; |safe stops Jinja from escaping \r to &#13;.
  • {%- ... -%} / {{- ... -}} — the - modifiers strip the source whitespace (including the LF) around the tag, so only the injected CRLF survives.
  • One {{-br-}} at every line end, including blank lines. The empty line between headers and body (RFC 2046 §5.1) is the most common slip — miss it and the Windows version still fails.

Same trap, different syntax in other engines: Liquid → {%- assign br = "\r\n" -%} with {{ br }}; Nunjucks → reuses Jinja's {%- -%} markers; Handlebars / Mustache → no trim modifiers, so do a server-side \n → \r\n pass after rendering. Pattern: define CRLF once, inject at every line, trim the source newlines.

After patching the sender, replay the request against the same Yellorn webhook URL and open the request log entry. In the Req Body tab:

  1. The stats line should now read CRLF with no warning badge. A pure CRLF body is the canonical form RFC 2046 mandates.
  2. Click Whitespace. Every line should end with ␍␊ (CRLF) — no bare (LF) and no lone (CR) anywhere in the body.
  3. Click Hex. Spot-check a few line boundaries: the byte pair 0d 0a should appear at the end of every header and after each boundary marker.

If any of these checks fails, the bug is still in the sender — not in the receiver. Yellorn shows you exactly what the receiver saw byte-for-byte, which is the ground-truth baseline for diagnosis.

Open the editor to try out a fix, or browse the rest of the help library.