Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
セキュリティ
可観測性
アーキテクチャ設計
ミドルウェア

Echo middleware complete guide: assembling Recover, RequestLogger, CORS, CSRF, Secure, JWT/KeyAuth, and RateLimiter at production quality

An implementation guide for assembling Go Echo's (v5) middleware at production quality. Faithful to the official documentation, in real code it explains the mechanism and application levels of middleware, Skipper, the recommended order, and the configuration of Recover/RequestLogger(slog)/CORS/CSRF/Secure/BodyLimit/Gzip/RateLimiter/KeyAuth/JWT(echo-jwt), plus how to write custom middleware.

Published
Reading time
12 min read
Author
友田 陽大
Share

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() and middleware.Timeout() were removed from the core, CORS became variadic, and KeyAuth'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.


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())                  // ⑧ レスポンス圧縮(最も内側に近い)
// 認証は「全体」ではなくグループに掛けることが多い(公開ルートを除外するため)
OrderMiddlewareWhy this position
Recoverto recover wherever a panic occurs, the outermost is mandatory
RequestIDearly, to put a correlation ID on all subsequent logs/errors
RequestLoggeroutside, to measure the time from the request's start to end
Secureresponse headers should be attached even to error responses
CORSreturn the preflight (OPTIONS) before auth or heavy processing
RateLimiterblock attacks/excessive access before heavy processing
BodyLimitreject a giant body by size before reading it (DoS mitigation)
Gzipcompression just needs to apply to the response written out last

A common accident: placing the authentication middleware outside RateLimiter lets 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時間キャッシュ
}))
FieldMeaningDefault
AllowOriginsthe origins to emit in Access-Control-Allow-Origin (required)
AllowMethodsallowed methodsGET/HEAD/PUT/PATCH/POST/DELETE
AllowHeadersallowed request headersempty
AllowCredentialswhether to allow requests with Cookies/credentialsfalse
MaxAgethe cache seconds of the preflight0 (header not sent)

Security warning (important): specifying the wildcard "*" and AllowCredentials: true simultaneously 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.


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))
FieldRole
Storethe rate-limiting logic (memory / your own RateLimiterStore implementation)
IdentifierExtractorthe identifier of whom to limit (c.RealIP(), the authenticated user ID, etc.)
DenyHandlerthe response on exceeding (returning 429 is the standard)
ErrorHandlerthe response when identifier extraction fails
Skipperexclude specific requests

c.RealIP() looks at X-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 with Echo.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

  1. Recover is the outermost and first. Don't kill the process on a panic.
  2. The cheaper to reject, the more outside. RateLimiter and BodyLimit go before heavy processing and authentication.
  3. CORS is variadic in v5. "*" × AllowCredentials:true is a dangerous combination that Echo rejects with a panic.
  4. Secure's HSTS and CSP are disabled by default. Make them explicit in HTTPS production.
  5. JWT is echo-jwt/v5 (out of core), and KeyAuth's Validator signature changed in v5. The key is in an environment variable.
  6. Apply authentication to a group. Don't drag in public routes.
  7. 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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading