Multipart/form-data is one HTTP representation containing several independently described parts. A client chooses a boundary parameter, places that value in the Content-Type header, and writes matching delimiter lines into the body. The arrangement lets one request carry text fields and binary files without pretending that all bytes share one JSON or text grammar. The boundary is framing metadata, not a security decision or a file-type detector.

A boundary frames the parts

Each delimiter begins with two hyphens and the declared boundary value. A closing delimiter adds two more hyphens. The boundary parameter is required for multipart media types, may need quoting, and must be one to seventy permitted characters without trailing whitespace. RFC 7578 requires that the delimiter not occur inside an encapsulated part. A long, unpredictable value makes accidental collisions unlikely, but a generator should still follow the grammar and not claim that randomness is an absolute guarantee.

The bytes before the first delimiter are a preamble and bytes after the closing delimiter are an epilogue. MIME readers ignore those areas as content; an application must not treat them as another form field. They are nevertheless part of the HTTP request body and should count toward an overall request budget. A parser reaching multipart EOF has not necessarily consumed the entire HTTP body.

Parts carry their own headers

Every form-data part needs Content-Disposition with a name parameter. File parts commonly add filename and Content-Type, but a filename is untrusted input: do not use directory components or write it directly into a filesystem path. Repeated field names are meaningful and must remain in wire order; coalescing them into one map can silently change the submitted form. Part headers are not a replacement for validating the decoded file, extension, size, or content.

Use a standards-aware parser instead of splitting the entire body on a string. It needs to recognize CRLF rules, quoted boundary parameters, the final delimiter, duplicate names, malformed headers, and binary bytes. Read each part with a per-part limit, bound the total body, cap the part count, and wait for complete validation before committing a database row or moving an upload. Streaming reduces buffering, but bounded resource use still requires limits on reading and temporary storage. Clean up staged files when later parsing fails.

When it breaks

A common bug is hand-setting Content-Type: multipart/form-data in browser code. FormData then has a boundary in its body but no matching parameter in the header, leaving the server unable to parse it. Another is a copied boundary whose delimiter line also occurs inside a file; the parser sees a false part boundary and truncates the upload. A missing closing delimiter must be an error, not an invitation to accept a prefix.

Large fields can exhaust memory even when each part looks harmless. A request can also contain many small parts, a huge filename, an unsupported transfer-encoding header, or an epilogue large enough to cross the request limit. Do not infer that a successful NextPart means the entire message is valid. Reject malformed input before side effects, and log counts and sizes without logging file contents or credentials.

Check it quickly

Save this as multipart_probe.go and run go run multipart_probe.go with Go 1.23 or newer. The loopback fixture uses a quoted boundary, repeated names, a binary byte sequence, an epilogue, a total limit, per-part limits, and an acceptance counter. The counter is only a simulated commit; no file or database is modified. It requires nonempty names and rejects Content-Transfer-Encoding, using NextRawPart to avoid implicit quoted-printable decoding.

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"reflect"
	"runtime"
	"sync/atomic"
	"time"
)

const boundary = "probe:2026"
const totalLimit int64 = 2048
const partLimit int64 = 32
const maxParts = 3

type item struct {
	Name, Filename string
	Body           []byte
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func build(items []item) ([]byte, string) {
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	must(writer.SetBoundary(boundary)) // Deterministic fixture, not a production boundary policy.
	for _, entry := range items {
		var part io.Writer
		var err error
		if entry.Filename == "" {
			part, err = writer.CreateFormField(entry.Name)
		} else {
			part, err = writer.CreateFormFile(entry.Name, entry.Filename)
		}
		must(err)
		_, err = part.Write(entry.Body)
		must(err)
	}
	must(writer.Close())
	return body.Bytes(), writer.FormDataContentType()
}

func main() {
	want := []item{
		{Name: "tag", Body: []byte("first")},
		{Name: "tag", Body: []byte("second")},
		{Name: "upload", Filename: "sample.bin", Body: append([]byte("inline--probe:2026-marker"), 0, 255)},
	}
	valid, contentType := build(want)
	check(contentType == "multipart/form-data; boundary=\"probe:2026\"", "boundary must be quoted")
	var committed, handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		defer r.Body.Close()
		if r.Method != http.MethodPost {
			w.Header().Set("Allow", "POST")
			http.Error(w, "POST required", 405)
			return
		}
		media, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
		if err != nil || (media == "multipart/form-data" && parameters["boundary"] == "") {
			http.Error(w, "invalid multipart declaration", 400)
			return
		}
		if media != "multipart/form-data" {
			http.Error(w, "multipart/form-data required", 415)
			return
		}
		fail := func(err error) {
			var oversized *http.MaxBytesError
			if errors.As(err, &oversized) {
				http.Error(w, "total body limit", 413)
			} else {
				http.Error(w, "invalid multipart body", 400)
			}
		}
		limited := http.MaxBytesReader(w, r.Body, totalLimit)
		reader := multipart.NewReader(limited, parameters["boundary"])
		var received []item
		for {
			part, err := reader.NextRawPart()
			if err == io.EOF {
				break
			}
			if err != nil {
				fail(err)
				return
			}
			if len(received) == maxParts {
				http.Error(w, "part count limit", 413)
				return
			}
			if part.FormName() == "" || part.Header.Get("Content-Transfer-Encoding") != "" {
				http.Error(w, "named unencoded form part required", 400)
				return
			}
			data, err := io.ReadAll(io.LimitReader(part, partLimit+1))
			if err != nil {
				fail(err)
				return
			}
			if int64(len(data)) > partLimit {
				http.Error(w, "part size limit", 413)
				return
			}
			must(part.Close())
			received = append(received, item{part.FormName(), part.FileName(), data})
		}
		// Multipart EOF need not be HTTP-body EOF: epilogue bytes still count.
		if _, err := io.Copy(io.Discard, limited); err != nil {
			fail(err)
			return
		}
		committed.Add(1) // Simulated acceptance; no files or database writes.
		w.Header().Set("Content-Type", "application/json")
		must(json.NewEncoder(w).Encode(received))
	}))
	server.Config.ReadHeaderTimeout = time.Second
	server.Config.ReadTimeout = 2 * time.Second
	server.Start()
	defer server.Close()
	transport := &http.Transport{}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
	probe := func(label, media string, payload []byte, status int) {
		before := committed.Load()
		response, err := client.Post(server.URL, media, bytes.NewReader(payload))
		must(err)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.Proto == "HTTP/1.1" && response.StatusCode == status, label+": wrong status/protocol")
		delta := int64(0)
		if status == 200 {
			var received []item
			must(json.Unmarshal(body, &received))
			check(reflect.DeepEqual(received, want), label+": names, order, or binary bytes changed")
			delta = 1
		}
		check(committed.Load() == before+delta, label+": rejected body was accepted")
		fmt.Printf("%s: status=%d accepted=%d\n", label, status, committed.Load())
	}
	truncated := bytes.TrimSuffix(valid, []byte("\r\n--"+boundary+"--\r\n"))
	check(len(truncated) < len(valid), "fixture must remove the closing delimiter")
	many, _ := build(append(append([]item(nil), want...), item{Name: "extra", Body: []byte("x")}))
	large := append([]item(nil), want...)
	large[2].Body = bytes.Repeat([]byte("x"), int(partLimit+1))
	largePart, _ := build(large)
	withEpilogue := func(extra []byte) []byte { return append(append([]byte(nil), valid...), extra...) }
	probe("valid quoted boundary", contentType, valid, 200)
	probe("small epilogue", contentType, withEpilogue([]byte("ignored epilogue")), 200)
	probe("missing boundary", "multipart/form-data", valid, 400)
	probe("wrong boundary", "multipart/form-data; boundary=other", valid, 400)
	probe("truncated delimiter", contentType, truncated, 400)
	probe("wrong media type", "application/octet-stream", valid, 415)
	probe("too many parts", contentType, many, 413)
	probe("part too large", contentType, largePart, 413)
	probe("total with epilogue", contentType, withEpilogue(bytes.Repeat([]byte("x"), int(totalLimit))), 413)
	probe("recovery", contentType, valid, 200)
	check(handled.Load() == 10 && committed.Load() == 3, "ten requests must yield three acceptances")
	fmt.Println(runtime.Version())
}

The ten requests produce three acceptances. The first keeps duplicate tag fields in order and preserves the zero and 255 bytes in the upload. A small epilogue is ignored by the multipart parser but remains part of the HTTP body. Missing or wrong boundaries, a truncated closing delimiter, the wrong media type, too many parts, an oversized part, and a total-limit breach are rejected without increasing the commit counter. The final valid request demonstrates recovery after failures. All assertions passed on macOS with Go 1.27.0, including race and vet checks. The fixture does not test browser FormData, disk cleanup, malformed part headers, collision injection, or production load.

Further reading

Sources

  • RFC 7578: multipart/form-data, boundary grammar, form names, filenames, and ordering, checked 2026-09-09.
  • RFC 2046: Media Types, delimiter collision, length, preamble, and epilogue rules, checked 2026-09-09.
  • Go mime/multipart, streaming readers, raw parts, and writer boundaries, checked 2026-09-09.
  • Go mime, media-type parameter parsing and boundary extraction, checked 2026-09-09.
  • Go net/http, total-body limits, timeouts, and request lifecycle, checked 2026-09-09.
  • MDN: FormData, browser boundary-header behavior, checked 2026-09-09.