# Echo 認証・認可 実装ガイド：パスワードハッシュ・JWT 発行/検証・リフレッシュトークン・RBAC を本番品質で作る

> Go Echo（v5）で認証・認可を本番品質に実装するガイド。bcrypt/argon2id のパスワードハッシュ、golang-jwt/v5 によるアクセストークン発行、echo-jwt/v5 での検証、リフレッシュトークンの回転と失効、ロールベースアクセス制御（RBAC）、alg混同・トークン保管場所などのセキュリティ落とし穴までを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, セキュリティ, 型安全, アーキテクチャ設計, 認証
- URL: https://tomodahinata.com/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- パスワードは平文比較せず bcrypt/argon2id でハッシュ化。bcrypt.CompareHashAndPassword で定数時間比較し、cost は本番で調整する
- アクセストークンは短命（15分）の JWT、リフレッシュは長命でDBに回転・失効管理。役割を分けると漏洩時の被害を最小化できる
- 発行は golang-jwt/v5（NewWithClaims+SignedString）、検証は echo-jwt/v5。RegisteredClaims に独自クレームを埋めて型安全に扱う
- 認可（RBAC）はクレームの roles をミドルウェアで検査し、足りなければ 403。認証（誰か）と認可（何ができるか）を分ける
- alg混同攻撃は SigningMethod 固定で防ぐ。リフレッシュトークンは httpOnly+Secure+SameSite Cookie に置き、localStorage を避ける

---

認証・認可は、**間違えると即座に重大インシデント**になる領域です。パスワードを平文で持つ、JWT の署名検証を緩める、リフレッシュトークンを失効させない——どれも「動いてしまう」ぶん危険です。動くことと安全であることは別物です。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)の認証・認可編です。[ミドルウェア完全ガイド](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide)が `echo-jwt` の「検証」を扱ったのに対し、ここでは**ログインからトークン発行・回転・失効・RBAC まで**の一気通貫を、セキュリティの落とし穴とともに実装します。

> **この記事のルール**：Echo の API は **公式ドキュメント（v5・2026年6月時点）** に基づきます。`golang-jwt/jwt/v5`・`echo-jwt/v5`・`golang.org/x/crypto` は更新されるため、本番投入前に各公式で最新 API を確認してください。**署名鍵・DB 認証情報は環境変数／シークレットマネージャ前提**で、コードに絶対に書きません。

---

## 0. 設計の地図：認証と認可を分けて考える

混同しがちな2語を最初に分けます。

- **認証（Authentication）**：「**あなたは誰か**」を確かめる。＝ログイン、トークン検証。
- **認可（Authorization）**：「**あなたは何をしてよいか**」を確かめる。＝RBAC、所有者チェック。

本記事の流れ：**①パスワードを安全に保存 → ②ログインでトークン発行 → ③保護ルートでトークン検証（認証）→ ④ロールで操作を制限（認可）→ ⑤トークンを回転・失効**。

---

## 1. パスワードハッシュ：平文も可逆暗号も禁止

パスワードは**ハッシュ化して保存**します。暗号化（復号できる）ではなく、**一方向ハッシュ + ソルト + コスト**です。Go では `bcrypt`（手軽で堅実）か `argon2id`（OWASP 推奨、より高コスト）を使います。

```go
import "golang.org/x/crypto/bcrypt"

// 登録時：ハッシュ化して保存（ソルトは bcrypt が内部生成）
func HashPassword(plain string) (string, error) {
	// cost はマシン性能に応じて調整（本番は 12 前後を実測で）
	hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
	return string(hash), err
}

// ログイン時：定数時間比較（タイミング攻撃に強い）
func VerifyPassword(hash, plain string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
```

| 原則 | 理由 |
| --- | --- |
| 平文保存は厳禁 | 漏洩＝全ユーザーのパスワード流出 |
| 可逆暗号も不可 | 鍵が漏れれば全件復号できる |
| `CompareHashAndPassword` を使う | 自前の `==` 比較はタイミング攻撃の的 |
| cost を実測で調整 | 高すぎ＝ログインが重い／低すぎ＝総当たりに弱い |

> より高い保証が要るなら `argon2id`（`golang.org/x/crypto/argon2`）。メモリハードでハードウェア総当たりに強い反面、パラメータ（メモリ・反復・並列度）の設計が必要です。PIN や独自要件での鍵導出は[Cognito カスタム認証（PBKDF2）の設計](/blog/aws-cognito-custom-authentication-pin-pbkdf2-passwordless-guide)も参考になります。

---

## 2. ログイン：アクセストークンを発行する（golang-jwt/v5）

ログイン成功時に **JWT（アクセストークン）** を発行します。独自クレーム（ユーザー ID・ロール）を `jwt.RegisteredClaims` に**埋め込んで**型安全に扱います。

```go
import "github.com/golang-jwt/jwt/v5"

// 独自クレーム：標準クレームを埋め込む
type AccessClaims struct {
	UserID string   `json:"uid"`
	Roles  []string `json:"roles"`
	jwt.RegisteredClaims
}

func IssueAccessToken(secret []byte, userID string, roles []string) (string, error) {
	now := time.Now()
	claims := AccessClaims{
		UserID: userID,
		Roles:  roles,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   userID,
			Issuer:    "api.example.com",
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(15 * time.Minute)), // ← 短命が肝
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(secret) // secret は os.Getenv("JWT_SIGNING_KEY")
}
```

Echo ハンドラはこれを薄く呼ぶだけです。

```go
func (h *AuthHandler) Login(c *echo.Context) error {
	var dto LoginDTO // {email, password}（DTO で受ける）
	if err := c.Bind(&dto); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
	}
	if err := c.Validate(&dto); err != nil {
		return err
	}

	user, err := h.users.FindByEmail(c.Request().Context(), dto.Email)
	// ユーザー不在でもパスワード不一致でも「同じ」エラーを返す（ユーザー列挙対策）
	if err != nil || !VerifyPassword(user.PasswordHash, dto.Password) {
		return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
	}

	access, _ := IssueAccessToken(h.secret, user.ID, user.Roles)
	refresh, _ := h.refresh.Issue(c.Request().Context(), user.ID) // 第4章
	setRefreshCookie(c, refresh)                                   // httpOnly Cookie
	return c.JSON(http.StatusOK, map[string]string{"access_token": access})
}
```

> **ユーザー列挙対策**：「メールが存在しません」と「パスワードが違います」を**区別して返さない**。攻撃者にアカウントの存在を教えないため、エラーは常に同一文言にします。

---

## 3. トークン検証（認証）：echo-jwt/v5 で保護ルートを守る

検証は[ミドルウェアガイド](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide)で触れた **`echo-jwt/v5`** に任せます。**発行時と同じ独自クレーム型**を `NewClaimsFunc` で指定するのが型安全のコツです。

```go
import (
	echojwt "github.com/labstack/echo-jwt/v5"
	"github.com/golang-jwt/jwt/v5"
)

func JWTMiddleware(secret []byte) echo.MiddlewareFunc {
	return echojwt.WithConfig(echojwt.Config{
		SigningKey:    secret,
		SigningMethod: "HS256", // ← alg 混同攻撃を防ぐため明示固定
		NewClaimsFunc: func(c *echo.Context) jwt.Claims {
			return new(AccessClaims) // 発行時と同じ型
		},
		ErrorHandler: func(c *echo.Context, err error) error {
			return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired token")
		},
	})
}

// 認証必須グループにだけ掛ける（公開ルートを巻き込まない）
secured := e.Group("/api/v1")
secured.Use(JWTMiddleware(secret))
```

ハンドラからクレームを取り出すヘルパを1つ用意し、型アサーションを1箇所に閉じ込めます（DRY）。

```go
func CurrentUser(c *echo.Context) (*AccessClaims, error) {
	token, ok := c.Get("user").(*jwt.Token) // echo-jwt の既定 ContextKey は "user"
	if !ok {
		return nil, echo.ErrUnauthorized
	}
	claims, ok := token.Claims.(*AccessClaims)
	if !ok {
		return nil, echo.ErrUnauthorized
	}
	return claims, nil
}
```

---

## 4. リフレッシュトークン：短命アクセス + 長命リフレッシュの回転

アクセストークンを短命（15分）にすると、漏洩しても被害が短時間で済みます。代わりに**リフレッシュトークン**（長命）で再発行します。ここを雑に作ると、JWT の利点が台無しになります。

**設計の要点**：

- リフレッシュトークンは**JWT にしない**（失効できないため）。**ランダムな不透明文字列**を生成し、**ハッシュして DB に保存**する。
- **回転（rotation）**：リフレッシュのたびに古いトークンを失効させ、新しいものを発行する。
- **盗難検知**：失効済みトークンが再使用されたら、そのユーザーの全リフレッシュを失効（攻撃者を締め出す）。
- 保管は **httpOnly + Secure + SameSite Cookie**（JS から読めない＝XSS で盗まれない）。

```go
// 不透明トークンを生成し、ハッシュを保存（平文は Cookie でのみ往復）
func (s *RefreshStore) Issue(ctx context.Context, userID string) (string, error) {
	raw := randomToken(32)              // crypto/rand 由来の安全な乱数
	hash := sha256.Sum256([]byte(raw))  // DB にはハッシュだけ保存（漏洩耐性）
	err := s.repo.Save(ctx, RefreshToken{
		UserID:    userID,
		TokenHash: hex.EncodeToString(hash[:]),
		ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
	})
	return raw, err
}

// Cookie 設定：盗難に強い属性を必ず付ける
func setRefreshCookie(c *echo.Context, raw string) {
	c.SetCookie(&http.Cookie{
		Name:     "refresh_token",
		Value:    raw,
		Path:     "/api/v1/auth/refresh",
		HttpOnly: true,                  // JS から読めない（XSS 対策）
		Secure:   true,                  // HTTPS のみ
		SameSite: http.SameSiteStrictMode, // CSRF 緩和
		Expires:  time.Now().Add(7 * 24 * time.Hour),
	})
}
```

`/auth/refresh` では、Cookie のトークンをハッシュして DB 照合 → 有効なら**古いものを失効させて新しいアクセス + リフレッシュを発行**（回転）します。

> **保管場所の判断**：アクセストークンはメモリ（または短命）で持ち、**リフレッシュは必ず httpOnly Cookie**。`localStorage` は XSS で全部抜かれるため、認証トークンの保管には使いません。Cookie 方式では[CSRF 対策](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide)（SameSite + CSRF トークン）を併用します。

---

## 5. 認可（RBAC）：ロールで操作を制限する

認証を通った先で、「**この操作をしてよいか**」を判定します。クレームの `roles` をミドルウェアで検査します。

```go
// 指定ロールのいずれかを持つことを要求するミドルウェア
func RequireRole(roles ...string) echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c *echo.Context) error {
			claims, err := CurrentUser(c)
			if err != nil {
				return err
			}
			for _, need := range roles {
				if slices.Contains(claims.Roles, need) {
					return next(c)
				}
			}
			return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions")
		}
	}
}

// 適用：管理者専用ルート
admin := secured.Group("/admin")
admin.Use(RequireRole("admin"))
admin.DELETE("/users/:id", deleteUser)
```

### 5.1 RBAC で防げないもの：所有者チェック（IDOR）

ロールだけでは「**他人のリソースを触れてしまう**」事故（IDOR）は防げません。`user` ロールを持つ A が `/orders/<Bの注文ID>` を叩けてしまう、が典型です。**リソースの所有者とリクエスト主体を必ず突き合わせ**ます。

```go
func (h *OrderHandler) Get(c *echo.Context) error {
	claims, _ := CurrentUser(c)
	order, err := h.orders.FindByID(c.Request().Context(), c.Param("id"))
	if err != nil {
		return err
	}
	if order.UserID != claims.UserID { // ← 所有者チェック（横の権限昇格を防ぐ）
		return echo.ErrForbidden // 存在を隠すなら 404 でもよい
	}
	return c.JSON(http.StatusOK, order)
}
```

この「縦のリスク（認可・IDOR）は自動化では守りきれず、設計でしか守れない」という構造は、別スタックですが[Next.js × Supabase のアプリ層セキュリティ](/blog/nextjs-supabase-application-security-guide)でも一貫して扱っています。

---

## 6. セキュリティ落とし穴チェックリスト

| 落とし穴 | 対策 |
| --- | --- |
| **alg 混同攻撃**（`alg:none` や HS↔RS のすり替え） | 検証側で `SigningMethod` を**固定**（echo-jwt の設定） |
| 署名鍵のハードコード | **環境変数／シークレットマネージャ**。HS256 鍵の漏洩＝なりすまし |
| アクセストークンが長命 | **15分程度に短命化**＋リフレッシュで再発行 |
| リフレッシュを失効できない | **不透明トークン + DB 管理 + 回転**（JWT にしない） |
| トークンを localStorage | **httpOnly Cookie**（XSS 耐性） |
| ユーザー列挙 | 認証失敗のエラーを**一律同一**に |
| 認可漏れ（IDOR） | ロール検査に加え**所有者チェック** |
| 総当たりログイン | [RateLimiter](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide) を `/login` に |

> 非対称鍵（RS256）と JWKS による検証、外部 IdP（Cognito 等）連携を本格運用するなら、署名検証の落とし穴は[JWT RS256 検証・JWKS の記事](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)が一次情報レベルで詳しいです。自前 JWT で十分か、IdP に寄せるかの判断は[認証基盤の技術選定（Cognito/Auth0/Clerk/Supabase）](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase)を参照してください。

---

## まとめ：認証・認可を本番品質にする7原則

1. **パスワードは bcrypt/argon2id でハッシュ**。`CompareHashAndPassword` で定数時間比較。
2. **アクセスは短命 JWT、リフレッシュは長命・DB 管理**で役割を分ける。
3. **発行は golang-jwt/v5、検証は echo-jwt/v5**。独自クレームを `RegisteredClaims` に埋め型安全に。
4. **検証側で SigningMethod を固定**し、alg 混同攻撃を封じる。
5. **リフレッシュは回転・失効・盗難検知**。httpOnly+Secure+SameSite Cookie に置く。
6. **認証と認可を分け、RBAC + 所有者チェック**で縦・横の権限昇格を防ぐ。
7. **鍵は環境変数、`/login` にレート制限**、認証失敗は一律同一エラー。

認証は「ライブラリを入れれば終わり」ではなく、**トークンのライフサイクル設計**そのものです。この型を土台に、[クリーンアーキテクチャ](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)で UseCase に認可ロジックを寄せ、[テスト](/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide)で権限境界を回帰検証すれば、堅牢な認証基盤になります。
