How to test multipart form data

Send the upload to a server that prints the raw request bytes: curl -F "file=@note.txt" http://127.0.0.1:8934/upload. Then compare the boundary value in the Content-Type header with the delimiter lines in the body, which carry two extra hyphens in front, end with CRLF, and close with two more hyphens.

Why check this

Run this when an upload endpoint is built, when it moves behind a new proxy, and whenever a client stops using a form encoder and assembles the body itself. The failure it prevents is a request that looks right in an access log and carries nothing a parser can find: the endpoint answers 400 with "file is required" while the file is plainly in the body.

Two properties hide it. The delimiter is a random string that must match in the Content-Type header and in the body, and the framing lines end with CRLF, which no log viewer or network panel displays.

Prerequisites

Two fixtures: the 18 byte note.txt, and lf-body.txt, a hand-built body with LF line endings for step 7.

printf 'line one\nline two\n' > note.txt
printf -- '--X\nContent-Disposition: form-data; name="title"\n\nrelease notes\n--X--\n' > lf-body.txt

The server marks every CRLF, saves the bytes to last-request.bin, and answers 200 without parsing. A raw socket server is needed here, because an HTTP library hides the framing.

// echo-8934.js - prints one raw HTTP request with every CRLF marked, then answers 200.
const net = require('net');
const fs = require('fs');
net.createServer((sock) => {
  let buf = Buffer.alloc(0);
  sock.on('data', (d) => {
    buf = Buffer.concat([buf, d]);
    const sep = buf.indexOf('\r\n\r\n');
    if (sep === -1) return;
    const head = buf.subarray(0, sep).toString('latin1');
    const m = /content-length: *(\d+)/i.exec(head);
    const total = sep + 4 + (m ? Number(m[1]) : 0);
    if (buf.length < total) return;
    fs.writeFileSync('last-request.bin', buf.subarray(0, total));
    const raw = buf.subarray(0, total).toString('latin1');
    console.log(raw.replace(/\r\n/g, '\\r\\n\n'));
    console.log('--- end of request, ' + total + ' bytes ---');
    sock.end('HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok\n');
  });
}).listen(8934, '127.0.0.1', () => console.log('raw echo on 127.0.0.1:8934'));

Steps 6 and 9 use Node 22's own multipart parser.

// parse.js - feeds a captured request to Node 22's own multipart parser. node parse.js <file>
const fs = require('node:fs');
const raw = fs.readFileSync(process.argv[2]);
const sep = raw.indexOf('\r\n\r\n');
const head = raw.subarray(0, sep).toString('latin1');
const ct = /^content-type: *(.*)$/im.exec(head)[1].trim();
const body = raw.subarray(sep + 4);
new Response(body, { headers: { 'content-type': ct } })
  .formData()
  .then((f) => {
    for (const [k, v] of f) {
      console.log(k + ' = ' + (v instanceof File ? `File(name=${v.name}, type=${v.type}, size=${v.size})` : JSON.stringify(v)));
    }
  })
  .catch((e) => console.log('parse failed: ' + e.message));

Steps

  1. Step 1.

    Start the server in its own terminal.

    node echo-8934.js | tee upload.log
    
    raw echo on 127.0.0.1:8934
  2. Step 2.

    Send a form with one text field and one file.

    curl -s http://127.0.0.1:8934/upload -F "title=release notes" -F "file=@note.txt;type=text/plain"
    
    ok
  3. Step 3.

    Read the bytes the server received.

    tail -n +2 upload.log
    
    POST /upload HTTP/1.1\r\n
    Host: 127.0.0.1:8934\r\n
    User-Agent: curl/8.21.0\r\n
    Accept: */*\r\n
    Content-Length: 329\r\n
    Content-Type: multipart/form-data; boundary=------------------------OeKC5xOk20aR0VyuzlEaBr\r\n
    \r\n
    --------------------------OeKC5xOk20aR0VyuzlEaBr\r\n
    Content-Disposition: form-data; name="title"\r\n
    \r\n
    release notes\r\n
    --------------------------OeKC5xOk20aR0VyuzlEaBr\r\n
    Content-Disposition: form-data; name="file"; filename="note.txt"\r\n
    Content-Type: text/plain\r\n
    \r\n
    line one
    line two
    \r\n
    --------------------------OeKC5xOk20aR0VyuzlEaBr--\r\n
    
    --- end of request, 527 bytes ---

    The boundary appears in the header and at the start of each delimiter line, with two hyphens in front. Each part has a Content-Disposition: form-data line naming the field, a blank line, then its content. The file keeps its own LF endings: part content is opaque bytes the framing never touches.

  4. Step 4.

    Confirm the boundary values agree and the closing delimiter is present.

    node -e "const b=require('fs').readFileSync('last-request.bin').toString('latin1');const bd=/boundary=(.*)\r/.exec(b)[1];console.log('boundary in header : '+bd);console.log('characters         : '+bd.length);console.log('delimiter lines    : '+(b.split('\r\n--'+bd).length-1));console.log('closing delimiter  : '+b.includes('\r\n--'+bd+'--\r\n'));"
    
    boundary in header : ------------------------OeKC5xOk20aR0VyuzlEaBr
    characters         : 46
    delimiter lines    : 3
    closing delimiter  : true

    Two parts produce three delimiter lines. The third is the closing one, and its absence is what an interrupted upload looks like.

  5. Step 5.

    Read the same request from the client side, for when the server cannot be instrumented.

    curl -s -o /dev/null --trace-ascii - http://127.0.0.1:8934/upload -F "title=release notes" -F "file=@note.txt;type=text/plain"
    
    => Send header, 198 bytes (0xc6)
    0000: POST /upload HTTP/1.1
    0017: Host: 127.0.0.1:8934
    002d: User-Agent: curl/8.21.0
    0046: Accept: */*
    0053: Content-Length: 329
    0068: Content-Type: multipart/form-data; boundary=--------------------
    00a8: ----FMMXXqZzs9koBb45v44L8L
    00c4:
    => Send data, 329 bytes (0x149)
    0000: --------------------------FMMXXqZzs9koBb45v44L8L
    0032: Content-Disposition: form-data; name="title"
    0060:
    0062: release notes
    0071: --------------------------FMMXXqZzs9koBb45v44L8L
    00a3: Content-Disposition: form-data; name="file"; filename="note.txt"
    00e5: Content-Type: text/plain
    00ff:
    0101: line one.line two.
    0115: --------------------------FMMXXqZzs9koBb45v44L8L--
    * upload completely sent off: 329 bytes
    …

    The boundary differs from step 2 because curl generates a new one per request, so no test may hard code it. The left column is the byte offset, and it is the only place the line endings survive here: the delimiter is 48 characters and the next offset is 0x32, that is 50, so a CRLF sits between them.

  6. Step 6.

    Hand the captured bytes to a real parser.

    node parse.js last-request.bin
    
    title = "release notes"
    file = File(name=note.txt, type=text/plain, size=18)

    The 18 byte size matches note.txt on disk.

  7. Step 7.

    Send the same form with a body you built by hand, using LF where CRLF belongs.

    curl -s http://127.0.0.1:8934/upload -H "Content-Type: multipart/form-data; boundary=X" --data-binary @lf-body.txt
    
    ok

    The server answers 200, because it stores bytes without parsing them. Nothing at the HTTP level marks the body as broken.

  8. Step 8.

    Read the bytes of that second request.

    tail -n 14 upload.log
    
    POST /upload HTTP/1.1\r\n
    Host: 127.0.0.1:8934\r\n
    User-Agent: curl/8.21.0\r\n
    Accept: */*\r\n
    Content-Type: multipart/form-data; boundary=X\r\n
    Content-Length: 70\r\n
    \r\n
    --X
    Content-Disposition: form-data; name="title"
    
    release notes
    --X--
    
    --- end of request, 222 bytes ---

    The request headers carry their \r\n markers and the body lines have none. That difference is the whole defect.

  9. Step 9.

    Parse the LF body with the same parser that accepted step 6.

    node parse.js last-request.bin
    
    parse failed: Failed to parse body as FormData.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | The header boundary value opens every delimiter line with two hyphens in front | The framing is consistent | Nothing. Move on to the part headers. | | delimiter lines : 3 for a two-part form | Every part is delimited and the body is closed | Nothing. This is the shape a parser expects. | | closing delimiter : false | The body ends without the terminating delimiter | Compare Content-Length with the bytes received. An upload cut short ends this way. | | Body lines with no \r\n marker in step 8 | The client used LF line endings | Rebuild the body with CRLF, or hand the form to the client's own encoder. | | parse failed: Failed to parse body as FormData. | The parser found no usable part | Check the header boundary first, then the line endings. The message covers both. | | A part with no Content-Disposition line | The part has no field name | Fix the client. RFC 7578 section 4.2 requires the header and the name parameter on every part. |

Common mistakes

Sign: The raw body clearly contains the file and the server answers that no file was sent.Cause: The delimiter lines end with LF. RFC 2046 defines the delimiter as CRLF followed by two hyphens and the boundary, so a parser scanning for that sequence finds the first delimiter (the CRLF ending the request headers supplies it) and never finds the next. Steps 8 and 9 are that failure end to end.
Sign: The boundary in the body has two more hyphens than the one in the Content-Type header, and it gets reported as a bug.Cause: It is correct. The two hyphens belong to the delimiter line, not to the boundary value. curl also pads its own boundary with 24 hyphens, so the header value is 46 characters and the delimiter line starts with 26 hyphens.
Sign: Setting Content-Type by hand and the parser rejecting a body that looks fine.Cause: A -H 'Content-Type: multipart/form-data' with no boundary parameter replaces the header curl built. Node 22 then answers Failed to parse body as FormData., the same message it gives for LF endings, so the message does not tell you which of the two is wrong.
Sign: curl --trace-ascii shows the body and the line endings look fine in it.Cause: trace-ascii prints every non-printable byte as a dot, and a dot at the end of a line reads as nothing at all. The byte offsets in the left column carry the information instead, which is why step 5 counts them.

Thresholds

A boundary is at most 70 characters, not counting the two leading hyphens Source: RFC 2046 section 5.1.1

The boundary curl generated in step 4 is 46 characters, inside that limit.

What to check next

FAQ

What is boundary in multipart form data?

A delimiter string chosen by the client and announced in the Content-Type header. Every part starts with two hyphens plus that string, and the body ends with the same line plus two more hyphens. It must not appear inside any part, so curl generates a random one per request.

How to upload a file with curl?

curl -F "file=@note.txt;type=text/plain" http://host/upload. The @ reads the file and sets filename from the path. The ;type= suffix sets that part's Content-Type, which RFC 7578 section 4.4 defaults to text/plain when it is absent.

Is content disposition form data the same header as on a download?

Same header name, different place and meaning. In a request part it names the form field, as in Content-Disposition: form-data; name="file". On a response, disposition type attachment tells the browser to save rather than render, which is How to check content disposition header.

How to test file upload in postman?

Body tab, form-data, set the key type to File and pick the file. Postman builds the boundary and the part headers itself, so it exercises the server and not your client's framing. Use the Console, not the Body panel, for the bytes this page reads.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny