メインコンテンツへスキップ
友田 陽大
Go・Echo 本番運用
Go
Echo
セキュリティ
型安全
アーキテクチャ設計
認証

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

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

公開日
読了時間
9分
著者
友田 陽大
シェア

認証・認可は、間違えると即座に重大インシデントになる領域です。パスワードを平文で持つ、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 に認可ロジックを寄せ、テストで権限境界を回帰検証すれば、堅牢な認証基盤になります。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Go / Echo のバックエンドを、設計から本番運用まで承ります

Echo v5 へのAPI設計・移行、クリーンアーキテクチャ(Controller/UseCase/Repository + DI)、ミドルウェアとセキュリティ、集中エラー処理、グレースフルシャットダウン、テストとCIまで。Go/Echo + google/wire で実際にクリーンアーキのバックエンドを構築した知見で、落ちない・追える・変更しやすいAPIを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい