Chuyển đến nội dung chính
友田 陽大
Go & Echo in production
Go
Echo
セキュリティ
型安全
アーキテクチャ設計
認証

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

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

Published
Reading time
9 min read
Author
友田 陽大
Chia sẻ

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

この記事は、Go Echo 本番運用ガイドの認証・認可編です。ミドルウェア完全ガイドecho-jwt の「検証」を扱ったのに対し、ここではログインからトークン発行・回転・失効・RBAC までの一気通貫を、セキュリティの落とし穴とともに実装します。

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


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

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

  • 認証(Authentication):「あなたは誰か」を確かめる。=ログイン、トークン検証。
  • 認可(Authorization):「あなたは何をしてよいか」を確かめる。=RBAC、所有者チェック。

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


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

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

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 を実測で調整高すぎ=ログインが重い/低すぎ=総当たりに弱い

より高い保証が要るなら argon2idgolang.org/x/crypto/argon2)。メモリハードでハードウェア総当たりに強い反面、パラメータ(メモリ・反復・並列度)の設計が必要です。PIN や独自要件での鍵導出はCognito カスタム認証(PBKDF2)の設計も参考になります。


2. ログイン:アクセストークンを発行する(golang-jwt/v5)

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

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 ハンドラはこれを薄く呼ぶだけです。

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 で保護ルートを守る

検証はミドルウェアガイドで触れた echo-jwt/v5 に任せます。発行時と同じ独自クレーム型NewClaimsFunc で指定するのが型安全のコツです。

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

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 で盗まれない)。
// 不透明トークンを生成し、ハッシュを保存(平文は 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 CookielocalStorage は XSS で全部抜かれるため、認証トークンの保管には使いません。Cookie 方式ではCSRF 対策(SameSite + CSRF トークン)を併用します。


5. 認可(RBAC):ロールで操作を制限する

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

// 指定ロールのいずれかを持つことを要求するミドルウェア
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> を叩けてしまう、が典型です。リソースの所有者とリクエスト主体を必ず突き合わせます。

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 のアプリ層セキュリティでも一貫して扱っています。


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

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

非対称鍵(RS256)と JWKS による検証、外部 IdP(Cognito 等)連携を本格運用するなら、署名検証の落とし穴はJWT RS256 検証・JWKS の記事が一次情報レベルで詳しいです。自前 JWT で十分か、IdP に寄せるかの判断は認証基盤の技術選定(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 にレート制限、認証失敗は一律同一エラー。

認証は「ライブラリを入れれば終わり」ではなく、トークンのライフサイクル設計そのものです。この型を土台に、クリーンアーキテクチャで UseCase に認可ロジックを寄せ、テストで権限境界を回帰検証すれば、堅牢な認証基盤になります。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

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.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc