# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, セキュリティ, 可観測性, アーキテクチャ設計, ミドルウェア
- URL: https://tomodahinata.com/en/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- Middleware is func(next echo.HandlerFunc) echo.HandlerFunc. In the order pre-processing → next(c) → post-processing, the registration order is stacked from outside to inside.
- The order decides the behavior. Recover at the outermost, then RequestID→RequestLogger→Secure→CORS→RateLimiter→auth→BodyLimit is the standard.
- CORS changed to a variadic argument in v5. Since specifying a wildcard origin and AllowCredentials:true simultaneously is vulnerable, Echo prevents it with a panic.
- JWT is the out-of-core echo-jwt/v5 module. KeyAuth's Validator signature changed to func(c, key, source)(bool,error).
- RateLimiter takes the identifier with the IdentifierExtractor (c.RealIP, etc.) and returns a 429 with the DenyHandler. Exclude multiple application with the Skipper.

---

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](/blog/go-echo-framework-production-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](https://echo.labstack.com/middleware/) 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.**

```go
type MiddlewareFunc func(next echo.HandlerFunc) echo.HandlerFunc
```

**Before** calling `next(c)` is request pre-processing, and **after** is response post-processing.

```go
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

```go
// ① ルート全体（最外周）— 全リクエストに効く
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.

```go
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.

```go
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 `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](/blog/go-echo-request-binding-validation-error-handling-guide). **It's mandatory in production** and placed at **the outermost.**

```go
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`.

```go
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](/blog/opentelemetry-observability-production-tracing-metrics-logs)).

---

## 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.**

```go
// 簡易：許可するオリジンを列挙
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 `"*"` 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.

```go
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).

```go
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.**

```go
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.

```bash
go get github.com/labstack/echo-jwt/v5
```

```go
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.**

```go
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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide) 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).

```go
// 簡易：毎秒 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.**

```go
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 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](/blog/nextjs-serverless-rate-limiting-vercel-guide) handles the same trap too).

---

## 10. Other standards: BodyLimit / Gzip / RequestID / Static

```go
// 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`.

```go
// テナント境界をヘッダから解決し、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](/blog/go-echo-framework-production-guide#9-アーキテクチャハンドラを薄く保ち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](/blog/go-echo-request-binding-validation-error-handling-guide). You can return to the [Go Echo production-operation guide](/blog/go-echo-framework-production-guide) to confirm the overall picture.
