The Middleware You Will Actually Need

Where the middleware seam is, what the server puts in the chain for you, and where your own attaches: rate limiting, CORS, body caps and client IP, each starting from whether the ingress in front of the process already handles it.


Before writing any middleware, answer a different question: is something in front of this process already doing it? A service that implements everything itself carries code its platform already runs, and occasionally code that fights it: two components both answering a Cross-Origin Resource Sharing (CORS) preflight send two Access-Control-Allow-Origin headers, and a browser rejects that.


The boundary

ConcernUsually lives atWhat the service still owns
Transport Layer Security (TLS) terminationThe edgeSupporting TLS for direct and mesh-internal exposure, and knowing which mode it is in
Response compressionThe edgeNot compressing twice, and knowing which end negotiates
Rate limitingGateway, ingress, content delivery network (CDN) or web application firewall (WAF)A last-resort limit for direct and internal callers
CORS and preflightThe gateway, when there is oneAnswering preflight when there is not
Request body capsThe ingress, commonlyIts own cap, because the edge cap does not cover internal callers
Client Internet Protocol (IP) addressThe proxy sets the header; the platform decides the trusted hop depthBeing told the hop count rather than guessing it
AuthenticationFrequently an edge concernReading an established identity, per page 17

Swimlane diagram of the points where a request can be rejected, split between the edge lane and the service lane, in the order they are reached. Both lanes are described in the sections of this page.

The diagram runs top to bottom in rejection order, and the rule it encodes is cheapest rejection first: parsing a 10MB body before finding the caller is over its rate limit is work done for nothing. The edge lane holds TLS termination and compression, a body cap answering 413, a rate limit answering 429 with Retry-After, and X-Forwarded-For being set. The service lane holds MaxBytesHandler, the trace ID and per-request logger, a preflight answered with 204 only if the edge did not, a per-instance rate limit, client IP resolution at the configured depth, authentication answering 401 or 403, and route matching answering 404 or 405.


The seam

Page 07 covered what the server builds for you: the body cap, the request logger and the per-route timeout, in that order. Your own attaches at two points, both taking the same function type.

type MiddlewareFn func(args MiddlewareArgs, next http.Handler) http.Handler

WithMiddlewareFn adds a layer to every route on a server; the Middleware field on a Route adds one to a single route. Everything you add sits inside what the server built, in the order you pass it.

MiddlewareArgs carries the route’s method, path and description, so a middleware knows which endpoint it wraps without inspecting the request, plus the server’s logger, trace ID header name and redaction function:

middleware := func(args httpserver.MiddlewareArgs, next http.Handler) http.Handler {
	return m.InstrumentHandler(args.Path, next.ServeHTTP)
}

Strip that argument and you have func(http.Handler) http.Handler, which every other Go middleware already is, so anything written for chi, gorilla or the standard library wraps into one in two lines.

The three listeners have three separate chains, so the public one can carry a rate limiter the monitoring one does not need.

Ordering

Panic recovery is outermost. It can only catch panics raised inside the handlers it wraps. Here it sits at the router level, outside every middleware, so a panic in your own middleware is still answered with a 500.

Instrumentation wraps the handler, not the router. The metrics middleware needs the route pattern, /items/{id}, rather than the request path, /items/019813a0-.... Around the router it would see only the latter, and a counter labelled with the raw path produces a new time series per item, which is how a metrics backend falls over. Hence args.Path above, and page 13 on label cardinality.

A middleware that writes to the response body ends the response for everything outside it. Once a rate limiter has written 429, the handler never runs and anything outside that expected to modify the response cannot. So a middleware either rejects and returns, or delegates and leaves the body alone. Doing both is where “superfluous WriteHeader call” comes from.


Rate limiting

A gateway, ingress, CDN or WAF sees all the traffic where a single instance sees its own share, so the real limit belongs there. Inside the process it is a floor: a public gateway does not cover a caller reaching the private listener directly, or a misbehaving internal client.

The mechanism is a token bucket per client key, and golang.org/x/time/rate is the standard answer: a rate.Limiter per key, allocated on first sight, asked Allow() on each request, wrapped in a MiddlewareFn that refuses with 429 and a Retry-After header. Closing the connection instead tells a well-behaved client nothing, so it retries immediately. Page 11 is the same exchange from the other side.

The map of limiters grows. A public endpoint keyed on client IP under a distributed scan accumulates entries until the process runs out of memory. Sweep what has not been seen for a while, from a goroutine registered with page 05’s wait group and shutdown signal, or hold the limiters in a bounded cache such as sfcache, at the cost of dropping one for an active client.

It does not compose across replicas. Ten requests per second per client on six replicas is sixty, and 120 when the autoscaler doubles them, with nobody changing the configuration. Coordinating through Redis or Valkey costs a round trip per request and adds a dependency whose failure has to be decided in advance: fail open and lose the limit, or fail closed and lose the service. The first is usually right.

Set the in-process limit well above the edge limit, and read a 429 in your own logs as evidence that the edge was bypassed.


CORS

Exactly one component must own it, and where there is a gateway, that is it.

A browser refuses to let JavaScript on app.example.com read a response from api.example.com unless the response says it may, and for anything beyond a simple GET it asks first with an OPTIONS preflight carrying Origin and Access-Control-Request-Method. MDN’s CORS guide is the reference for which headers each side sends. The exchange is advisory, so Access-Control-Allow-Origin protects your users’ browsers and offers nothing against a script with curl.

Use a maintained package rather than the header logic by hand: three details are easy to get wrong and each fails only in a browser. Echo the origin from an allowlist instead of reflecting whatever arrived, or the policy permits credentialed requests from any site. Send Vary: Origin, or a shared cache serves one origin’s headers to another. Treat an OPTIONS request as a preflight only when Access-Control-Request-Method is present, or you break clients that use OPTIONS for discovery.

The routing wrinkle. A preflight arrives as OPTIONS /items on a route registered as POST /items, and whether the chain sees it depends on the router. httprouter answers OPTIONS before the middleware chain runs, so the preflight never reaches your code. Where it does not answer, the request lands on the method-not-allowed handler from page 07 and returns a 405 with no CORS headers, so the browser reports a CORS failure that looks nothing like a routing problem. Register an explicit OPTIONS route per path, or handle CORS outside the chain, and verify in a browser.


Request body limits

The ingress commonly caps bodies at around 1MB, which covers only traffic arriving through it, and not a caller on the private listener or a request in an integration test. Without a cap in the process, a single POST can make the service allocate until the container’s memory limit kills it: a denial of service costing the attacker one request. Of the four concerns here, this is the one where the in-process implementation is required rather than merely defensible.

The server-wide cap installs http.MaxBytesHandler as the outermost middleware, ahead of the logger so that a debug request dump cannot buffer an unbounded body in order to log it:

httpserver.WithMaxRequestBodyBytes(1 << 20) // 1MiB

Per-endpoint, a handler wraps r.Body in http.MaxBytesReader before decoding, sized to what that endpoint accepts, four kilobytes for an item. The server-wide one is a backstop in megabytes; the tighter one stops an attacker using a legitimate endpoint to allocate a megabyte at a time. Page 15 sizes one and shows the decode. An over-sized body then fails inside Decode, and answering it 400 along with every other unreadable body is the short version; matching *http.MaxBytesError with errors.As first is what earns the client a 413 and a reason.

When the two caps disagree by an order of magnitude, a payload accepted in a test is refused in production by a component the developer does not control. Write both numbers in the same place.


Client IP, and the trust question

r.RemoteAddr is the address of whatever connected to the socket. Behind a load balancer that is the load balancer. To get the client you read a header, and a header is something the client can send.

X-Forwarded-For: 203.0.113.7, 70.41.3.18, 150.172.238.178

The list grows left to right as it passes through proxies, so the leftmost entry is the oldest. That is why “take the first entry” is wrong: a client can send X-Forwarded-For: 1.2.3.4 in the original request, and a proxy appends rather than replaces, so the first entry is whatever the client chose. Rate limiting is then defeated by a random value per request, audit logs record an attacker-chosen address, and an IP allowlist is bypassed by claiming an allowed address.

Count from the right instead, discarding as many entries as there are proxies you trust. That hop count is configuration, per page 04, because the process cannot work it out: one ingress means 1, an ingress behind a CDN means 2, and a directly reachable service means 0, which has to mean “ignore the header entirely” rather than “take the last entry”. Trusting too few hops gives you your proxy’s address for every client, which is bad and visible. Trusting too many is silent and exploitable.

Forwarded standardises the same idea with a different syntax and the same trust model. If your proxy sets it, parse that instead; the counting rule is unchanged. The count changes when somebody puts a CDN in front, so it belongs in the same manifest as the topology, which page 19 does.


What the toolkit contributes

Nothing except the option name and the MiddlewareFn signature. Rate limiting policy, CORS origins and proxy depth are properties of a deployment, so the library provides the place to attach them, the order they run in, and the guarantee that a panic anywhere inside is still answered. The full list of options is in the httpserver package documentation, and the article on its edges covers the built-in chain.


Next: One Request, All the Way Down.