# Echo ミドルウェア完全ガイド：Recover・RequestLogger・CORS・CSRF・Secure・JWT/KeyAuth・RateLimiter を本番品質で組む

> Go Echo（v5）のミドルウェアを本番品質で組むための実装ガイド。公式ドキュメントに忠実に、ミドルウェアの仕組みと適用レベル、Skipper、推奨される並び順、Recover/RequestLogger(slog)/CORS/CSRF/Secure/BodyLimit/Gzip/RateLimiter/KeyAuth/JWT(echo-jwt)の設定、カスタムミドルウェアの書き方までを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, セキュリティ, 可観測性, アーキテクチャ設計, ミドルウェア
- URL: https://tomodahinata.com/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- ミドルウェアは func(next echo.HandlerFunc) echo.HandlerFunc。前処理→next(c)→後処理の順で、登録順が外側から内側に積まれる
- 並び順が挙動を決める。Recover を最外周、RequestID→RequestLogger→Secure→CORS→RateLimiter→認証→BodyLimit の順が定石
- CORS は v5 で variadic 引数に変化。ワイルドカード origin と AllowCredentials:true の同時指定は脆弱なので Echo が panic で防ぐ
- JWT はコア外の echo-jwt/v5 モジュール。KeyAuth の Validator 署名は func(c, key, source)(bool,error) に変わった
- RateLimiter は IdentifierExtractor(c.RealIP 等) で識別子を取り、DenyHandler で 429 を返す。多重適用は Skipper で除外する

---

ミドルウェアは、Echo で**横断的関心事（cross-cutting concerns）**——ログ・認証・CORS・レート制限・セキュリティヘッダ——を、ハンドラ本体を汚さずに差し込む仕組みです。ここを雑に積むと、**CORS が効かない・CSRF で正規リクエストまで弾く・panic でプロセスが落ちる・認証より先に重い処理が走る**といった本番事故に直結します。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)のミドルウェア編です。**仕組み → 並び順 → 個別の設定**の順に、Echo v5 の主要ミドルウェアを「いつ・どう設定するか」まで踏み込んで解説します。

> **この記事のルール**：API は **Echo 公式ドキュメント（v5・2026年6月時点）** に基づきます。v5 はミドルウェアの署名が `*echo.Context` に変わり、`middleware.Logger()` と `middleware.Timeout()` がコアから削除され、`CORS` が可変長引数化、`KeyAuth` の Validator 署名が変更されるなど、**v4 から差分が大きい**領域です。本番投入前に必ず[公式](https://echo.labstack.com/middleware/)で最新を確認してください。**シークレット（JWT 署名鍵など）は環境変数前提**です。

---

## 1. ミドルウェアの仕組み：玉ねぎの皮を理解する

Echo のミドルウェアは、**ハンドラを受け取ってハンドラを返す関数**です。

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

`next(c)` を呼ぶ**前**がリクエスト前処理、**後**がレスポンス後処理になります。

```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 // エラーは必ず伝播させる（握りつぶさない）
	}
}
```

登録した順に**外側から内側へ**積まれます。`e.Use(A); e.Use(B)` なら `A → B → ハンドラ → B → A` の順で制御が流れます。**この「玉ねぎ構造」が並び順の重要性の正体**です。

### 1.1 3つの適用レベル

```go
// ① ルート全体（最外周）— 全リクエストに効く
e.Use(middleware.Recover())

// ② グループ単位 — そのプレフィックス配下だけ
api := e.Group("/api", middleware.CORS("https://app.example.com"))

// ③ 個別ルート — その1本だけ
e.GET("/admin/report", report, requireAdmin)
```

権限境界（公開／認証必須／管理者専用）は**グループ**で表現するのが定石です。「この API 群には必ず認証が掛かる」をコードの構造で保証できます。

### 1.2 Skipper：特定リクエストだけ素通しする

ほぼ全てのミドルウェアは `Skipper func(c *echo.Context) bool` を持ち、`true` を返すと**そのミドルウェアを飛ばします**。ヘルスチェックやメトリクス収集を除外する定番パターンです。

```go
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
	Skipper: func(c *echo.Context) bool {
		return strings.Contains(c.Path(), "metrics") // /metrics は圧縮しない
	},
}))
```

各ミドルウェアには `XxxWithConfig(XxxConfig{...})` という**設定付きコンストラクタ**があり、引数なしの `Xxx()` はデフォルト設定の糖衣です。

---

## 2. 並び順：本番の推奨スタック

**結論から**。本番 API のルート全体には、おおむね次の順で積みます。順序には理由があります。

```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())                  // ⑧ レスポンス圧縮（最も内側に近い）
// 認証は「全体」ではなくグループに掛けることが多い（公開ルートを除外するため）
```

| 順 | ミドルウェア | なぜこの位置か |
| --- | --- | --- |
| ① | `Recover` | どこで panic しても回収するには**最外周**が必須 |
| ② | `RequestID` | 後続の全ログ・エラーに相関 ID を載せるため早めに |
| ③ | `RequestLogger` | リクエストの最初から最後までの時間を測るため外側に |
| ④ | `Secure` | レスポンスヘッダはエラー応答にも付けたい |
| ⑤ | `CORS` | プリフライト（OPTIONS）を**認証や重い処理の前**で返す |
| ⑥ | `RateLimiter` | 攻撃・過剰アクセスを**重い処理の前**で遮断する |
| ⑦ | `BodyLimit` | 巨大ボディを**読み込む前**にサイズで弾く（DoS 緩和） |
| ⑧ | `Gzip` | 圧縮は最後に書き出すレスポンスに掛かればよい |

> よくある事故：**認証ミドルウェアを `RateLimiter` より外側に置く**と、未認証の攻撃者が認証処理（DB 照会・ハッシュ計算）を踏ませ放題になります。**「安く弾けるものほど外側で弾く」**が原則です。

---

## 3. Recover：panic でプロセスを殺さない

Go では goroutine 内の未回収 panic は**プロセス全体を落とします**。`Recover` はチェーンのどこで panic しても回収し、スタックトレースを出して制御を[集中エラーハンドラ](/blog/go-echo-request-binding-validation-error-handling-guide)へ渡します。**本番では必須**、かつ**最外周**に置きます。

```go
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
	StackSize:         4 << 10, // 4KB（デフォルト）
	DisableStackAll:   false,   // 他 goroutine のスタックも含める
	DisablePrintStack: false,   // スタックトレースを出力する
}))
```

`StackSize` はトレースを取り込むバッファ長です。panic 時の調査効率に直結するため、本番では出力を切らないことを推奨します。

---

## 4. RequestLogger：slog で構造化アクセスログ

v5 はロガーを標準の `log/slog` に統一しました。`middleware.RequestLogger()` はデフォルトの slog ロガーへ出力しますが、本番では `LogValuesFunc` で**出したい項目を JSON で明示**します。

```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` は `Status`/`URI`/`Method`/`Latency`/`Error`/`RemoteIP` などを持ちます。`HandleError: true` を忘れると、エラーが集中ハンドラに渡る前にログ側で消費され、ステータスがズレることがあります。`middleware.RequestID()` と組み合わせ、`request_id` を全行に載せれば 1 リクエストを貫通して追跡できます（[可観測性の実践](/blog/opentelemetry-observability-production-tracing-metrics-logs)）。

---

## 5. CORS：v5 で引数が変わった最頻出の事故ポイント

ブラウザから別オリジンの API を叩くなら CORS は必須です。v5 では **`middleware.CORS()` が許可オリジンを可変長引数で受ける**形に変わりました。

```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時間キャッシュ
}))
```

| フィールド | 意味 | 既定 |
| --- | --- | --- |
| `AllowOrigins` | `Access-Control-Allow-Origin` に出すオリジン（**必須**） | — |
| `AllowMethods` | 許可メソッド | GET/HEAD/PUT/PATCH/POST/DELETE |
| `AllowHeaders` | 許可リクエストヘッダ | 空 |
| `AllowCredentials` | Cookie/認証情報を伴うリクエストを許すか | false |
| `MaxAge` | プリフライトのキャッシュ秒数 | 0（ヘッダ非送出） |

> **セキュリティ警告（重要）**：ワイルドカード `"*"` と `AllowCredentials: true` の**同時指定は脆弱**です。これは「あらゆるサイトが Cookie 付きで API を叩ける」状態を意味します。Echo はこの危険な組み合わせを**意図的に panic で拒否**します。認証情報を伴うなら、信頼できるオリジンを**明示列挙**してください。

---

## 6. Secure：セキュリティヘッダを一括付与

XSS・クリックジャッキング・MIME スニッフィング対策のレスポンスヘッダをまとめて付けます。

```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` はデフォルトでも安全側の値が入りますが、**`HSTSMaxAge` と `ContentSecurityPolicy` はデフォルトが無効（0 / 空）**なので、HTTPS 本番では明示設定します。空文字を渡すとその保護は無効化される点に注意してください。CSP の設計はアプリ依存が大きいため、まず `default-src 'self'` から段階的に緩める運用が安全です。

---

## 7. CSRF：Cookie セッション型アプリの必須対策

CSRF は、**Cookie ベースのセッション認証**を使うアプリで必要です（トークンを `Authorization` ヘッダで送る純粋な API では通常不要）。

```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時間
}))
```

サーバー側では、生成されたトークンを `c.Get("csrf")` で取り出し、HTML テンプレートに埋め込んでクライアントへ渡します。クライアントは次回リクエストでそれを `X-CSRF-Token` ヘッダ（または設定した `TokenLookup`）に載せて送り返します。SPA なら CSRF Cookie を読んでヘッダに付け替える方式（double-submit cookie）が一般的です。

---

## 8. 認証：KeyAuth（API キー）と JWT（echo-jwt）

### 8.1 KeyAuth：API キー / Bearer トークン検証

`KeyAuth` は、ヘッダ・クエリ・Cookie から鍵を取り出して検証します。**v5 で Validator の署名が変わり、抽出元（`ExtractorSource`）も渡される**ようになりました。

```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` は `"<source>:<name>"` 形式で、`"header:Authorization:Bearer "` のように**プレフィックスを剥がす**指定もできます。

### 8.2 JWT：コア外の `echo-jwt/v5` モジュール

v5 でも **JWT ミドルウェアは Echo コアに含まれず、別モジュール**です。import が v5 系であることに注意してください。

```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")
	},
}))
```

検証済みのクレームは `c.Get("user")` で取り出します。公開ルート（ログイン・ヘルスチェック）を巻き込まないよう、**JWT は認証必須グループにだけ掛ける**のが定石です。

```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) // 認証必須
}
```

> 非対称鍵（RS256）での検証や JWKS、Cognito 等の IdP 連携を本格運用するなら、署名検証の落とし穴は[JWT RS256 検証の記事](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)が参考になります。HS256 の共有鍵は**漏洩＝なりすまし**に直結するため、必ずシークレットマネージャ／環境変数で管理します。

---

## 9. RateLimiter：重い処理の前で過剰アクセスを遮断

レート制限は、ブルートフォースや過剰アクセスを**安いコストで早期に弾く**ための層です。インメモリストアが標準で付属します（複数インスタンスでは Redis 等の共有ストアが必要）。

```go
// 簡易：毎秒 20 リクエスト（rate が float のとき burst は rate の切り捨て）
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))
```

本番では、**誰を**レート制限するか（IP／ユーザー）と、**超過時の応答**を明示します。

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

| フィールド | 役割 |
| --- | --- |
| `Store` | レート制限のロジック（メモリ／自前の `RateLimiterStore` 実装） |
| `IdentifierExtractor` | 誰を制限するかの識別子（`c.RealIP()`、認証済みユーザー ID 等） |
| `DenyHandler` | 制限超過時の応答（**429** を返すのが定石） |
| `ErrorHandler` | 識別子抽出に失敗したときの応答 |
| `Skipper` | 特定リクエストを除外 |

> `c.RealIP()` は `X-Forwarded-For` / `X-Real-IP` を見ます。**信頼できるプロキシ（ALB・Cloudflare 等）の背後**でのみ正しく機能し、それ以外ではクライアントが偽装できる点に注意。`Echo.IPExtractor` で信頼できる前段の設定を行ってください。**複数インスタンス構成**ではインメモリストアはインスタンスごとに独立するため、Redis ベースの共有ストアに差し替えます（[サーバーレスのレート制限の考え方](/blog/nextjs-serverless-rate-limiting-vercel-guide)も同じ罠を扱っています）。

---

## 10. その他の定番：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/ 以下
```

`BodyLimit` の**整数バイト指定**は v5 の変更点です。ネット上の `"2M"` という文字列指定は v4 の書き方なので、コピペ時に注意してください。

---

## 11. カスタムミドルウェア：横断的関心事を自作する

内蔵で足りない要件（テナント解決・監査ログ・APM 連携）は、自作します。**`next(c)` の戻り値を必ず伝播**し、`Skipper` 相当の早期 return を用意するのが作法です。

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

依存（`TenantRepository` 等）を引数で受ける**クロージャ方式**にすると、テスト時にモックを差し込めます。これは[クリーンアーキテクチャ + DI](/blog/go-echo-framework-production-guide#9-アーキテクチャハンドラを薄く保ちdiでテスト可能にする)と同じ思想で、ミドルウェアもユニットテスト可能に保てます。

---

## まとめ：ミドルウェア設計の7原則

1. **`Recover` は最外周・最初**。panic でプロセスを殺さない。
2. **安く弾けるものほど外側で**。`RateLimiter`・`BodyLimit` は重い処理・認証の前に。
3. **CORS は v5 で可変長引数**。`"*"` × `AllowCredentials:true` は Echo が panic で拒否する危険な組み合わせ。
4. **`Secure` の HSTS と CSP はデフォルト無効**。HTTPS 本番では明示する。
5. **JWT は `echo-jwt/v5`（コア外）**、KeyAuth の Validator 署名は v5 で変更。鍵は環境変数。
6. **認証はグループに掛ける**。公開ルートを巻き込まない。
7. **カスタムミドルウェアは `next(c)` を必ず伝播**し、依存はクロージャで注入してテスト可能に。

ミドルウェアは「積めば効く」ものではなく、**順序と設定で挙動が決まる**層です。本記事のスタックを起点に、自分のアプリの認証方式（Cookie か Bearer か）とインフラ（単一か複数インスタンスか）に合わせて調整してください。次は、ミドルウェアを抜けた先の**入力検証とエラー整形**——[バインディング＆バリデーション＆エラー設計](/blog/go-echo-request-binding-validation-error-handling-guide)へ。全体像は[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)に戻って確認できます。
