Middleware
serbewr/middleware is the whole stack, and the pieces play nice together. A Middleware is a plain func(http.Handler) http.Handler, so anything from the wider Go ecosystem slots in next to these.
handler := middleware.Chain(finalHandler,
middleware.RequestID(), // outermost: runs first on the way in, last on the way out
middleware.Logger(),
middleware.Recovery(),
)
Chain wraps right to left, so the first middleware in the list sees the request first. The server uses Chain for global, group and route middleware.
Order matters in two places. Put RequestID before Logger, or the request ID never reaches the log line. Put CORS before anything that rejects requests (auth, content type checks), so browser preflights get their answer.
Every middleware logs through ctxscope.GetLogger(ctx). Logging explains how the request fields get onto every line.
RequestID
middleware.RequestID()
Takes the incoming X-Request-ID if it is at most 128 characters of [a-zA-Z0-9._-], otherwise generates a UUID4. It sets the ID on the response header, on the request context (read it with aichteeteapee.GetRequestID(r)), and on the log scope as requestId. The client forwards it on outbound calls made with that context.
Logger
One log line per request, written after the handler returns.
middleware.Logger(
middleware.WithLogLevel(slog.LevelDebug),
middleware.WithLogMessage("request done"),
middleware.WithSkipPaths("/health", "/metrics"),
middleware.WithExtraFields(map[string]any{"service": "api"}),
middleware.WithIncludeQuery(false),
middleware.WithIncludeHeaders(aichteeteapee.HeaderNameXCorrelationID),
)
| Option | Default | Effect |
|---|---|---|
WithLogLevel | slog.LevelInfo | Level of the request line. |
WithLogMessage | HTTP request | Message of the request line. |
WithSkipPaths | none | Paths that are not logged. Matched after path.Clean, so /health/ and /health both match. |
WithExtraFields | none | Fixed fields added to every line. |
WithIncludeQuery | true | Adds the raw query string as query. Turn it off if tokens ever travel in query strings. |
WithIncludeHeaders | none | Adds each named request header as header_<Name> when present. |
Each line carries method, path, ip (from X-Forwarded-For, then X-Real-IP, then the remote address), status, duration and userAgent. Logger also puts method, path and ip on the scope, so handlers downstream log them too.
Recovery
Catches a panic, logs it, and answers with an error response instead of killing the connection.
middleware.Recovery(
middleware.WithRecoveryStatusCode(http.StatusInternalServerError),
middleware.WithRecoveryResponse(customErrorBody),
middleware.WithRecoveryContentType(aichteeteapee.ContentTypeJSON),
middleware.WithIncludeStack(false),
middleware.WithRecoveryLogLevel(slog.LevelWarn),
middleware.WithRecoveryLogMessage("handler blew up"),
middleware.WithRecoveryExtraFields(map[string]any{"component": "api"}),
)
By default it logs at error level with the panic value as error and the stack trace as stack, then answers 500 with ErrorResponseInternalServerError as JSON. With a non-JSON content type, the response body is written only if it is a string.
WithCustomRecoveryHandler(func(recovered any, w http.ResponseWriter, r *http.Request)) replaces all of that, logging included.
BasicAuth
HTTP Basic authentication.
middleware.BasicAuth(
middleware.WithBasicAuthUsers(map[string]string{
"admin": "secret",
}),
middleware.WithBasicAuthRealm("admin-area"),
middleware.WithBasicAuthSkipPaths("/health", "/metrics"),
middleware.WithBasicAuthChallenge(true),
middleware.WithBasicAuthUnauthorizedMessage("nope"),
)
| Option | Default | Effect |
|---|---|---|
WithBasicAuthUsers | none | User name to password map. Repeated calls merge. |
WithBasicAuthValidator | none | func(user, pass string) bool that replaces the user map entirely. |
WithBasicAuthRealm | restricted | Realm in WWW-Authenticate. |
WithBasicAuthChallenge | true | Send WWW-Authenticate, which makes browsers show the login prompt. |
WithBasicAuthUnauthorizedMessage | Unauthorized | message in the 401 body. |
WithBasicAuthSkipPaths | none | Paths that skip auth, matched after path.Clean. |
WithConstantTimeComparison | true | Compare SHA-256 hashes of the passwords in constant time, so neither timing nor length leaks. |
A failure answers 401 with the JSON envelope (code UNAUTHORIZED). With no users and no validator, every request fails. On success the user name goes on the request context under aichteeteapee.ContextKeyUser, which EchoHandler shows.
BearerAuth
Authorization: Bearer <token> for APIs, the companion to BasicAuth for the usual “API key from an env var” case.
middleware.BearerAuth(
middleware.WithBearerAuthTokens(cfg.APIToken), // one or more accepted tokens
middleware.WithBearerAuthSkipPaths("/health", "/metrics"),
middleware.WithBearerAuthUnauthorizedMessage("nope"),
)
| Option | Default | Effect |
|---|---|---|
WithBearerAuthTokens | none | Accepted tokens. Empty strings are ignored, so an unset env var never becomes a valid token. |
WithBearerAuthValidator | none | func(token string) bool that replaces the token set entirely. |
WithBearerAuthSkipPaths | none | Paths that skip auth, matched after path.Clean. |
WithBearerAuthUnauthorizedMessage | Unauthorized | message in the 401 body. |
WithBearerAuthConstantTimeComparison | true | Compare SHA-256 hashes against every token without an early exit. |
The Bearer scheme is matched case-insensitively. A failure answers 401 with the JSON envelope and no WWW-Authenticate header. With no tokens and no validator, every request fails.
CORS
Blocks unknown origins by default, because that’s what a sane default looks like. Use WithAllowAllOrigins() or aichteeteapee.FuckSecurity() when you need to go permissive.
middleware.CORS(
middleware.WithAllowedOrigins("https://app.example.com", "https://admin.example.com"),
middleware.WithAllowedMethods(http.MethodGet, http.MethodPost),
middleware.WithAllowedHeaders(
aichteeteapee.HeaderNameContentType,
aichteeteapee.HeaderNameAuthorization,
),
middleware.WithExposedHeaders("X-Total-Count"),
middleware.WithMaxAge(3600),
middleware.WithAllowCredentials(true),
)
| Option | Default |
|---|---|
WithAllowedOrigins | none, so no origin is allowed |
WithAllowAllOrigins | off, unless FuckSecurity() was called before CORS() |
WithAllowedMethods | GET, POST, PUT, PATCH, DELETE, OPTIONS |
WithAllowedHeaders | Authorization, Content-Type, X-Request-ID |
WithExposedHeaders | none |
WithMaxAge | 86400 seconds |
WithAllowCredentials | false |
An allowed origin is echoed back in Access-Control-Allow-Origin together with Vary: Origin, so caches keep per-origin copies. With all origins allowed it sends *; browsers refuse * on credentialed requests, so list the origins if you need cookies. The methods, headers and max age go out on every response. Every OPTIONS request gets 204 No Content and never reaches your handler.
SecurityHeaders
Adds the usual security headers. Each one can be changed or turned off.
middleware.SecurityHeaders(
middleware.WithContentSecurityPolicy("default-src 'self'"),
middleware.DisableHSTS(),
)
| Header | Default | Change | Turn off |
|---|---|---|---|
X-Content-Type-Options | nosniff | WithXContentTypeOptions | DisableXContentTypeOptions |
X-Frame-Options | DENY | WithXFrameOptions | DisableXFrameOptions |
X-XSS-Protection | 1; mode=block | WithXXSSProtection | DisableXXSSProtection |
Strict-Transport-Security | max-age=31536000; includeSubDomains | WithStrictTransportSecurity | DisableHSTS |
Referrer-Policy | strict-origin-when-cross-origin | WithReferrerPolicy | DisableReferrerPolicy |
Content-Security-Policy | not sent | WithContentSecurityPolicy | DisableCSP |
Timeout
Gives each request a deadline and answers 504 Gateway Timeout when it passes.
middleware.Timeout(middleware.WithTimeout(15 * time.Second))
// Presets
middleware.Timeout(middleware.WithShortTimeout()) // 5s
middleware.Timeout(middleware.WithDefaultTimeout()) // 10s, also what Timeout() uses
middleware.Timeout(middleware.WithLongTimeout()) // 30s
The handler runs in its own goroutine with a context that is cancelled at the deadline. If the handler has not written anything by then, the client gets 504 with code GATEWAY_TIMEOUT. If it already started writing, the client keeps what was written. Either way, every write after the deadline fails with http.ErrHandlerTimeout.
Handlers must respect ctx.Done(). The timeout cancels the context but can’t kill a goroutine that ignores it. If your handler doesn’t check the context, that’s on you.
EnforceRequestContentType
Rejects requests whose Content-Type is not in the list. GET, HEAD and DELETE pass without a check.
middleware.EnforceRequestContentType(
aichteeteapee.ContentTypeJSON,
aichteeteapee.ContentTypeXML,
)
// Convenience
middleware.EnforceRequestContentTypeJSON()
Parameters such as ; charset=utf-8 are ignored and the match is case-insensitive. A missing header answers 400 MISSING_CONTENT_TYPE. A wrong one answers 415 UNSUPPORTED_CONTENT_TYPE with details listing what was received and what is allowed. OPTIONS is checked too, so put CORS in front of it.
Helpers
middleware.GetRequestID(r)andmiddleware.GetClientIP(r)behave like the root package’saichteeteapee.GetRequestIDandaichteeteapee.GetClientIP.middleware.BaseResponseWriteris the embeddable base for your own response writer wrappers. It passesHijack,FlushandUnwrapthrough to the real writer, so WebSocket upgrades and streamed responses keep working behind your wrapper. Logger and Timeout use it.