Middleware
Middleware wraps every request: it can inspect the request, call the next layer, and post-process the response — for cross-cutting concerns like security headers, logging, timing, redirects, and auth gates.
The model
A middleware receives the request and a next function. It may call next to continue down the chain, or short-circuit by returning a response itself:
fn handle(&self, req: &Request, next: &dyn Fn(&Request) -> Reply) -> Reply;
Layers compose into a MiddlewareStack and run outermost-first — the first one pushed is the outermost, so it sees the request first and the response last.
Running with a stack
The stack is additive: Server::run(handler) is unchanged, and Server::run_with(stack, handler) adds the chain.
let stack = MiddlewareStack::new()
.push(SecurityHeaders) // outermost: stamps even short-circuited replies
.push(Timing); // adds X-Response-Time-Us
server.run_with(stack, handler)?;
Order matters: put SecurityHeaders outside any blocklist or auth gate so even a rejected request gets hardened headers.
Built-ins
SecurityHeaders—X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer.
Timing— adds anX-Response-Time-Usresponse header.
These are active on the framework's own site — every response carries the security and timing headers (try curl -I against any page).
Access logging
Request logging is no longer a middleware — the server itself writes one access line per request to stderr (journald-friendly under systemd), on by default for every framework app:
127.0.0.1:44428 GET /docs/middleware?x=1 -> 200 8412B 1.2ms
127.0.0.1:46930 <malformed> -> 400 15B 8us
Each line carries the peer address, method and target (query included), final status, body bytes, and wall-clock duration. Requests whose head fails to parse are logged as <malformed> with the 400 the server returned — something no middleware could see. Handlers can also read the client address from req.peer. Opt out with Server::bind(addr)?.access_log(false).
Writing your own
Any closure Fn(&Request, &dyn Fn(&Request) -> Reply) -> Reply is a middleware, so a one-off layer needs no new type:
stack.push(|req: &Request, next: &dyn Fn(&Request) -> Reply| {
if req.path == "/blocked" {
return Reply::Response(Response::new(403).with_text("nope"));
}
next(req)
})
WebSocket and SSE upgrade replies pass through untouched.