Blog / Go

  • go
  • net-http
  • maxheaderbytes
  • security
  • http2
  • debugging

Go's http.MaxHeaderBytes: The Limit That Counts More Than Headers

I set MaxHeaderBytes to 1024 on a test server, sent it a request with a 5000 byte cookie, and got a cheerful 200 OK. That is not what the field name suggests, so I read the source, then wrote a probe to check I had read it correctly. The result is a bit more interesting than "the limit is slightly off".

What the field says, and what it does

The documentation for http.Server.MaxHeaderBytes says it controls the maximum number of bytes the server will read parsing the request header's keys and values, including the request line. It does not limit the request body. If zero, DefaultMaxHeaderBytes is used, which is 1 << 20, so 1 MB.

Three things in that description are worth pulling apart.

  1. Including the request line. A very long URL spends the same budget as a very long cookie. The path and query string of a GET are "headers" as far as this limit is concerned.
  2. It is bytes read, not bytes of keys and values. The limit sits on the raw connection reader, so colons, spaces and the CRLF after every line count as well.
  3. The default is 1 MB. That is generous. Reverse proxies tend to be much stricter (nginx and Apache both default to something around 8 KB per line, but check your own config), so Go will happily accept things the proxy in front of it would have refused.

The 4096 bytes nobody mentions

In net/http/server.go there is this:

func (srv *Server) initialReadLimitSize() int64 {
	return int64(srv.maxHeaderBytes()) + 4096 // bufio slop
}

Before parsing each request, the server sets a read limit on the underlying connection to MaxHeaderBytes + 4096. The extra 4 KiB is there because the request is read through a bufio.Reader that fills its buffer in chunks, and the limiter sees those chunks rather than the parser's idea of "headers so far". So the limit is a limit on raw bytes pulled off the socket while the header is being read, with a fudge factor on top.

If the parser fails and the limiter has been exhausted, the server turns that into a 431 Request Header Fields Too Large, writes it straight to the connection, and closes. Before the limit is hit, nothing happens at all: no error, no log line, and no early exit on the first header that pushes past your configured number.

Checking it

Here is a small probe. It starts a server with MaxHeaderBytes set to 1024, then sends raw requests over TCP with either a padded Cookie header or a padded path, and prints the status line it gets back.

package main

import (
	"bufio"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
)

func try(addr, path string, cookieLen int) string {
	c, err := net.Dial("tcp", addr)
	if err != nil {
		return err.Error()
	}
	defer c.Close()

	req := "GET " + path + " HTTP/1.1\r\nHost: x\r\n"
	if cookieLen > 0 {
		req += "Cookie: a=" + strings.Repeat("b", cookieLen) + "\r\n"
	}
	req += "\r\n"
	fmt.Fprint(c, req)

	line, err := bufio.NewReader(c).ReadString('\n')
	if err != nil {
		return fmt.Sprintf("total=%d err=%v", len(req), err)
	}
	return fmt.Sprintf("total=%d %s", len(req), strings.TrimSpace(line))
}

func main() {
	srv := httptest.NewUnstartedServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") }))
	srv.Config.MaxHeaderBytes = 1024
	srv.Start()
	defer srv.Close()

	addr := srv.Listener.Addr().String()
	for _, n := range []int{1000, 3000, 5000, 5100} {
		fmt.Println("cookie", n, try(addr, "/", n))
	}
	for _, n := range []int{1000, 5000, 6000} {
		fmt.Println("path", n, try(addr, "/"+strings.Repeat("p", n), 0))
	}
}

On Go 1.22.2 I got this (trimmed to the interesting rows):

cookie 1000 total=1039 HTTP/1.1 200 OK
cookie 5000 total=5039 HTTP/1.1 200 OK
cookie 5100 total=5139 HTTP/1.1 431 Request Header Fields Too Large
path 1000 total=1027 HTTP/1.1 200 OK
path 5000 total=5027 HTTP/1.1 200 OK
path 6000 total=6027 HTTP/1.1 431 Request Header Fields Too Large

1024 + 4096 is 5120, and the boundary sits between 5039 and 5139 total bytes, exactly where the source says it should. The path test lands the same way, which confirms the request line is spending the same budget. So a server configured for 1 KB of headers will really take about 5 KB. I would not build anything that depends on the exact edge, since the slop is an implementation detail and not documented, but it is useful to know that the number you configure is a floor, not a ceiling.

Where this bites in practice

The realistic failure is not an attacker, it is cookies. Several apps on the same parent domain each set a few kilobytes of cookie, the browser sends all of them to every subdomain, and one day a particular user crosses your limit. What they see is a bare text page saying "431 Request Header Fields Too Large".

Two consequences worth knowing:

  • Your handler never runs. Middleware, access logging, request IDs, metrics: none of it sees the request, because parsing failed before a Request existed. If you want to know how often it happens, count the 431s at the proxy, or use Server.ConnState to notice connections that go from new to closed without ever becoming active.
  • The client may not read the response at all. The server writes the 431 and closes while the client may still be sending; the source comment on that path says as much, calling the outcome undefined. Some clients will report a connection reset rather than a 431.

Picking a number

The 1 MB default is worth lowering, since a slow client can hold that much of your memory hostage per connection while it trickles headers in. Pair a smaller limit with ReadHeaderTimeout, because the two cover different halves of the same problem: one caps how much, the other caps how long.

srv := &http.Server{
	Addr:              ":8443",
	Handler:           mux,
	MaxHeaderBytes:    16 << 10, // effective limit is 16 KiB + 4096
	ReadHeaderTimeout: 5 * time.Second,
}

16 KiB comfortably fits a normal browser request, including a fair number of cookies, and is still far below 1 MB. If you sit behind a proxy that already enforces a smaller limit, there is no harm in matching it, so that both layers reject the same requests in the same way.

The limit is per server, not per route. If one endpoint legitimately needs huge headers (a signed token in Authorization, say) you cannot raise it for that path only; run it as a separate http.Server or accept the higher global limit.

HTTP/2 counts differently

Over TLS with HTTP/2 the same field is reused, but by a different mechanism. The bundled HTTP/2 server derives its advertised SETTINGS_MAX_HEADER_LIST_SIZE from MaxHeaderBytes and, in the Go 1.22 source, adds 320 bytes to it: 32 bytes of per-field overhead for an assumed ten headers. HTTP/2's own accounting (RFC 9113) counts each field as name length plus value length plus 32, and it operates on decompressed HPACK output, not on wire bytes. So the same request can be under the limit on HTTP/1.1 and over it on HTTP/2, or the other way round. Test both if the exact boundary matters to you.

The client has its own version

For completeness, http.Transport has MaxResponseHeaderBytes, the same idea pointed the other way. Zero means a default of 10 MB in the current source, and exceeding it gives an error of the form "net/http: server response headers exceeded N bytes; aborted". If you write clients that talk to untrusted servers, lowering that is a one-line change, and a much better idea than trusting whatever is on the other end to send sensible headers.