Middleware is the mechanism in Echo to insert cross-cutting concerns — logging, authentication, CORS, rate limiting, security headers — without polluting the handler body. Stacking these carelessly directly ties to production accidents like CORS not working, CSRF rejecting even legitimate requests, the process crashing on a panic, and heavy processing running before authentication.
This article is the middleware edition of the Go Echo production-operation guide. In the order of mechanism → order → individual configuration, it explains Echo v5's major middleware, going deep into "when and how to configure it."
The rules of this article: the API is based on the Echo official documentation (v5, as of June 2026). v5 is an area with large diffs from v4 — the middleware signature changed to
*echo.Context,middleware.Logger()andmiddleware.Timeout()were removed from the core,CORSbecame variadic, andKeyAuth's Validator signature changed. Always confirm the latest at the official before production. Secrets (the JWT signing key, etc.) are on the premise of environment variables.
1. The mechanism of middleware: understand the onion's layers
Echo's middleware is a function that receives a handler and returns a handler.
type MiddlewareFunc func(next echo.HandlerFunc) echo.HandlerFunc
Before calling next(c) is request pre-processing, and after is response post-processing.
func timing(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
start := time.Now() // ── 前処理
err := next(c) // ── 次のミドルウェア/ハンドラへ
// ── 後処理
c.Logger().Info("handled", "path", c.Request().URL.Path, "took", time.Since(start))
return err // エラーは必ず伝播させる(握りつぶさない)
}
}
They're stacked from outside to inside in the registration order. With e.Use(A); e.Use(B), control flows in the order A → B → handler → B → A. This "onion structure" is the true nature of why the order matters.
1.1 The 3 application levels
// ① ルート全体(最外周)— 全リクエストに効く
e.Use(middleware.Recover())
// ② グループ単位 — そのプレフィックス配下だけ
api := e.Group("/api", middleware.CORS("https://app.example.com"))
// ③ 個別ルート — その1本だけ
e.GET("/admin/report", report, requireAdmin)
The standard is to express the privilege boundary (public / auth-required / admin-only) with a group. You can guarantee "this API group always has authentication" with the code's structure.
1.2 Skipper: pass only specific requests through
Almost all middleware has Skipper func(c *echo.Context) bool, and returning true skips that middleware. It's a standard pattern for excluding health checks and metrics collection.
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Skipper: func(c *echo.Context) bool {
return strings.Contains(c.Path(), "metrics") // /metrics は圧縮しない
},
}))
Each middleware has a config-equipped constructor XxxWithConfig(XxxConfig{...}), and the argument-less Xxx() is syntactic sugar for the default config.
2. The order: the recommended production stack
From the conclusion. On the whole route of a production API, stack roughly in the following order. The order has reasons.
e := echo.New()
e.Use(middleware.Recover()) // ① panic を最外周で回収(最初=最も外)
e.Use(middleware.RequestID()) // ② 相関 ID を採番(以降のログに載る)
e.Use(middleware.RequestLogger()) // ③ アクセスログ(slog)
e.Use(middleware.Secure()) // ④ セキュリティヘッダ
e.Use(middleware.CORS(allowedOrigins...)) // ⑤ CORS(プリフライトを早く返す)
e.Use(middleware.RateLimiter(store)) // ⑥ レート制限(重い処理の前で弾く)
e.Use(middleware.BodyLimit(2_097_152)) // ⑦ 本文サイズ上限
e.Use(middleware.Gzip()) // ⑧ レスポンス圧縮(最も内側に近い)
// 認証は「全体」ではなくグループに掛けることが多い(公開ルートを除外するため)
| Order | Middleware | Why this position |
|---|---|---|
| ① | Recover | to recover wherever a panic occurs, the outermost is mandatory |
| ② | RequestID | early, to put a correlation ID on all subsequent logs/errors |
| ③ | RequestLogger | outside, to measure the time from the request's start to end |
| ④ | Secure | response headers should be attached even to error responses |
| ⑤ | CORS | return the preflight (OPTIONS) before auth or heavy processing |
| ⑥ | RateLimiter | block attacks/excessive access before heavy processing |
| ⑦ | BodyLimit | reject a giant body by size before reading it (DoS mitigation) |
| ⑧ | Gzip | compression just needs to apply to the response written out last |
A common accident: placing the authentication middleware outside
RateLimiterlets an unauthenticated attacker make you step on the authentication processing (DB lookup, hash computation) freely. The principle is "the cheaper to reject, the more outside you reject it."
3. Recover: don't kill the process on a panic
In Go, an unrecovered panic inside a goroutine crashes the entire process. Recover recovers wherever a panic occurs in the chain, prints a stack trace, and passes control to the centralized error handler. It's mandatory in production and placed at the outermost.
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
StackSize: 4 << 10, // 4KB(デフォルト)
DisableStackAll: false, // 他 goroutine のスタックも含める
DisablePrintStack: false, // スタックトレースを出力する
}))
StackSize is the buffer length for capturing the trace. Since it directly ties to investigation efficiency on a panic, I recommend not turning off the output in production.
4. RequestLogger: structured access logs with slog
v5 unified the logger to the standard log/slog. middleware.RequestLogger() outputs to the default slog logger, but in production make the items you want to output explicit in JSON with LogValuesFunc.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogStatus: true,
LogURI: true,
LogError: true,
LogLatency: true,
HandleError: true, // エラーを集中ハンドラへ正しく伝播
LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
attrs := []slog.Attr{
slog.String("uri", v.URI),
slog.Int("status", v.Status),
slog.Duration("latency", v.Latency),
}
if v.Error == nil {
logger.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST", attrs...)
} else {
logger.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR",
append(attrs, slog.String("err", v.Error.Error()))...)
}
return nil
},
}))
RequestLoggerValues holds Status/URI/Method/Latency/Error/RemoteIP, etc. If you forget HandleError: true, the error may be consumed on the logging side before reaching the centralized handler, and the status can be off. Combined with middleware.RequestID(), putting request_id on every line lets you trace one request all the way through (the practice of observability).
5. CORS: the most frequent accident point where the arguments changed in v5
If you hit an API of a different origin from the browser, CORS is mandatory. In v5, middleware.CORS() changed to take the allowed origins as variadic arguments.
// 簡易:許可するオリジンを列挙
e.Use(middleware.CORS("https://app.example.com", "https://admin.example.com"))
// 詳細設定
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://labstack.com", "https://labstack.net"},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
MaxAge: 3600, // プリフライト結果を1時間キャッシュ
}))
| Field | Meaning | Default |
|---|---|---|
AllowOrigins | the origins to emit in Access-Control-Allow-Origin (required) | — |
AllowMethods | allowed methods | GET/HEAD/PUT/PATCH/POST/DELETE |
AllowHeaders | allowed request headers | empty |
AllowCredentials | whether to allow requests with Cookies/credentials | false |
MaxAge | the cache seconds of the preflight | 0 (header not sent) |
Security warning (important): specifying the wildcard
"*"andAllowCredentials: truesimultaneously is vulnerable. This means the state where "any site can hit the API with Cookies." Echo intentionally rejects this dangerous combination with a panic. If credentials are involved, explicitly enumerate the trusted origins.
6. Secure: add security headers all at once
Add response headers for XSS, clickjacking, and MIME-sniffing countermeasures together.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block", // X-XSS-Protection
ContentTypeNosniff: "nosniff", // X-Content-Type-Options
XFrameOptions: "SAMEORIGIN", // X-Frame-Options(クリックジャッキング対策)
HSTSMaxAge: 31536000, // Strict-Transport-Security(1年)
HSTSPreloadEnabled: true,
ContentSecurityPolicy: "default-src 'self'", // CSP
}))
XSSProtection/ContentTypeNosniff/XFrameOptions have safe-side values even by default, but HSTSMaxAge and ContentSecurityPolicy are disabled by default (0 / empty), so make them explicit in HTTPS production. Note that passing an empty string disables that protection. Since CSP's design depends largely on the app, it's safe to start from default-src 'self' and gradually loosen it.
7. CSRF: the mandatory countermeasure for cookie-session-type apps
CSRF is needed for apps that use cookie-based session authentication (usually unnecessary for a pure API that sends a token in the Authorization header).
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "header:X-CSRF-Token", // どこからトークンを探すか
ContextKey: "csrf", // c.Get("csrf") で取得
CookieName: "_csrf",
CookiePath: "/",
CookieSecure: true, // HTTPS のみ送出
CookieHTTPOnly: true, // JS から読めない
CookieSameSite: http.SameSiteStrictMode,
CookieMaxAge: 86400, // 24時間
}))
On the server side, extract the generated token with c.Get("csrf"), embed it in the HTML template, and pass it to the client. The client puts it on the X-CSRF-Token header (or the configured TokenLookup) on the next request and sends it back. For a SPA, the method of reading the CSRF cookie and re-attaching it to the header (double-submit cookie) is common.
8. Authentication: KeyAuth (API key) and JWT (echo-jwt)
8.1 KeyAuth: API key / Bearer token verification
KeyAuth extracts the key from the header, query, or cookie and verifies it. In v5, the Validator signature changed, and the extraction source (ExtractorSource) is now passed too.
e.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
KeyLookup: "header:Authorization:Bearer ", // "Bearer " を剥がして鍵を取得
Validator: func(c *echo.Context, key string, source middleware.ExtractorSource) (bool, error) {
// 定数時間比較で鍵を検証(タイミング攻撃対策)
valid, err := apiKeys.Verify(c.Request().Context(), key)
return valid, err
},
ErrorHandler: func(c *echo.Context, err error) error {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid api key")
},
}))
KeyLookup is in "<source>:<name>" format, and can also specify stripping a prefix like "header:Authorization:Bearer ".
8.2 JWT: the out-of-core echo-jwt/v5 module
In v5 too, the JWT middleware isn't included in Echo core but is a separate module. Note that the import is the v5 family.
go get github.com/labstack/echo-jwt/v5
import echojwt "github.com/labstack/echo-jwt/v5"
// 簡易:署名鍵だけ(鍵は必ず環境変数から)
e.Use(echojwt.JWT([]byte(os.Getenv("JWT_SIGNING_KEY"))))
// 詳細設定
e.Use(echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(os.Getenv("JWT_SIGNING_KEY")),
SigningMethod: "HS256", // デフォルト HS256
TokenLookup: "header:Authorization:Bearer ",
ContextKey: "user", // 検証済みクレームの格納先(デフォルト "user")
ErrorHandler: func(c *echo.Context, err error) error {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired token")
},
}))
Extract the verified claims with c.Get("user"). To not drag in public routes (login, health check), the standard is to apply JWT only to the auth-required group.
api := e.Group("/api/v1")
{
api.POST("/login", login) // 認証不要
}
secured := api.Group("")
secured.Use(echojwt.JWT([]byte(os.Getenv("JWT_SIGNING_KEY"))))
{
secured.GET("/me", me) // 認証必須
}
If you seriously operate verification with an asymmetric key (RS256), JWKS, or IdP integration like Cognito, the JWT RS256 verification article is a reference for the pitfalls of signature verification. Since an HS256 shared key directly ties leak = impersonation, always manage it with a secrets manager / environment variables.
9. RateLimiter: block excessive access before heavy processing
Rate limiting is the layer for rejecting brute force and excessive access early at a cheap cost. An in-memory store comes standard (a shared store like Redis is needed for multiple instances).
// 簡易:毎秒 20 リクエスト(rate が float のとき burst は rate の切り捨て)
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))
In production, make explicit whom to rate-limit (IP/user) and the response on exceeding.
config := middleware.RateLimiterConfig{
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{
Rate: 10, // 毎秒 10
Burst: 30, // バースト許容
ExpiresIn: 3 * time.Minute, // 識別子エントリの保持時間
},
),
IdentifierExtractor: func(c *echo.Context) (string, error) {
return c.RealIP(), nil // プロキシ背後では RealIP を使う
},
ErrorHandler: func(c *echo.Context, err error) error {
// 識別子抽出に失敗(通常は起きない)
return echo.NewHTTPError(http.StatusForbidden)
},
DenyHandler: func(c *echo.Context, identifier string, err error) error {
// 制限超過時。429 を返す
return c.JSON(http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
},
}
e.Use(middleware.RateLimiterWithConfig(config))
| Field | Role |
|---|---|
Store | the rate-limiting logic (memory / your own RateLimiterStore implementation) |
IdentifierExtractor | the identifier of whom to limit (c.RealIP(), the authenticated user ID, etc.) |
DenyHandler | the response on exceeding (returning 429 is the standard) |
ErrorHandler | the response when identifier extraction fails |
Skipper | exclude specific requests |
c.RealIP()looks atX-Forwarded-For/X-Real-IP. Note that it functions correctly only behind a trusted proxy (ALB, Cloudflare, etc.), and otherwise the client can spoof it. Configure the trusted upstream withEcho.IPExtractor. In a multi-instance configuration, since the in-memory store is independent per instance, swap it for a Redis-based shared store (the way of thinking about serverless rate limiting handles the same trap too).
10. Other standards: BodyLimit / Gzip / RequestID / Static
// BodyLimit:本文サイズ上限。v5 は「バイト数(整数)」で指定(v4 の "2M" 文字列から変更)
e.Use(middleware.BodyLimit(2_097_152)) // 2MB を超えると 413
// Gzip:レスポンス圧縮
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5, // 圧縮レベル(デフォルト -1)
MinLength: 1024, // これ未満のレスポンスは圧縮しない
}))
// RequestID:相関 ID を採番(無ければ生成)。RequestLogger と必ずセットで
e.Use(middleware.RequestID())
// Static:静的ファイル配信
e.Static("/static", "web/assets") // /static/* → web/assets/ 以下
The integer-byte specification of BodyLimit is a v5 change. The "2M" string specification on the net is the v4 way of writing, so be careful when copy-pasting.
11. Custom middleware: write your own cross-cutting concerns
For requirements the built-ins don't cover (tenant resolution, audit logs, APM integration), write your own. The practice is to always propagate the return value of next(c) and prepare an early return equivalent to Skipper.
// テナント境界をヘッダから解決し、Context に詰めるミドルウェア
func TenantResolver(repo TenantRepository) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
tenantID := c.Request().Header.Get("X-Tenant-ID")
if tenantID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing tenant")
}
tenant, err := repo.Find(c.Request().Context(), tenantID)
if err != nil {
return echo.ErrForbidden // 存在しない/権限なし
}
c.Set("tenant", tenant) // ハンドラ・後続ミドルウェアへ受け渡す
return next(c) // ← 戻り値を握りつぶさない
}
}
}
// グループに適用
api := e.Group("/api/v1")
api.Use(TenantResolver(tenantRepo))
Making it a closure method that receives dependencies (TenantRepository, etc.) as arguments lets you insert a mock at test time. This is the same idea as clean architecture + DI, keeping middleware unit-testable too.
Summary: the 7 principles of middleware design
Recoveris the outermost and first. Don't kill the process on a panic.- The cheaper to reject, the more outside.
RateLimiterandBodyLimitgo before heavy processing and authentication. - CORS is variadic in v5.
"*"×AllowCredentials:trueis a dangerous combination that Echo rejects with a panic. Secure's HSTS and CSP are disabled by default. Make them explicit in HTTPS production.- JWT is
echo-jwt/v5(out of core), and KeyAuth's Validator signature changed in v5. The key is in an environment variable. - Apply authentication to a group. Don't drag in public routes.
- Custom middleware always propagates
next(c), and injects dependencies via a closure to be testable.
Middleware isn't something where "stacking it makes it work" but a layer where the behavior is decided by the order and configuration. Starting from this article's stack, adjust to your app's authentication method (cookie or Bearer) and infrastructure (single or multiple instances). Next, go to input validation and error formatting beyond the middleware — binding, validation, and error design. You can return to the Go Echo production-operation guide to confirm the overall picture.