Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
セキュリティ
型安全
アーキテクチャ設計
認証

Echo authentication & authorization implementation guide: building password hashing, JWT issuance/verification, refresh tokens, and RBAC at production quality

A guide to implementing authentication and authorization at production quality with Go Echo (v5). With real code it covers bcrypt/argon2id password hashing, access-token issuance with golang-jwt/v5, verification with echo-jwt/v5, refresh-token rotation and revocation, role-based access control (RBAC), and security pitfalls such as alg confusion and token storage location.

Published
Reading time
8 min read
Author
友田 陽大
Share

Authentication & authorization is an area where a mistake immediately becomes a serious incident. Holding passwords in plaintext, loosening JWT signature verification, not revoking refresh tokens — all are dangerous precisely because they "work." Working and being safe are different things.

This article is the authentication & authorization chapter of the Go Echo production-operations guide. Whereas the complete middleware guide handled echo-jwt's "verification," here we implement the whole flow from login through token issuance, rotation, revocation, and RBAC, along with the security pitfalls.

Rules for this article: Echo's API is based on the official documentation (v5, as of June 2026). golang-jwt/jwt/v5, echo-jwt/v5, and golang.org/x/crypto are updated, so confirm the latest API in each official doc before production rollout. Signing keys and DB credentials assume environment variables / a secrets manager and are never written in code.


0. The map of the design: think of authentication and authorization separately

Separate two often-confused words first.

  • Authentication: confirms "who you are." = login, token verification.
  • Authorization: confirms "what you may do." = RBAC, owner check.

This article's flow: ① store passwords safely → ② issue tokens on login → ③ verify the token on protected routes (authentication) → ④ restrict operations by role (authorization) → ⑤ rotate/revoke tokens.


1. Password hashing: neither plaintext nor reversible encryption

Store passwords hashed. Not encryption (which can be decrypted) but a one-way hash + salt + cost. In Go, use bcrypt (easy and solid) or argon2id (OWASP-recommended, higher cost).

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
}
PrincipleReason
Plaintext storage strictly forbiddena leak = all users' passwords spilled
Reversible encryption also disallowedif the key leaks, everything can be decrypted
Use CompareHashAndPassworda homemade == compare is a target for timing attacks
Tune cost by measuringtoo high = login is heavy / too low = weak to brute force

If you need higher assurance, argon2id (golang.org/x/crypto/argon2). It's memory-hard and strong against hardware brute force, but requires designing the parameters (memory, iterations, parallelism). For key derivation in PINs or custom requirements, the design of Cognito custom authentication (PBKDF2) is also a reference.


2. Login: issue an access token (golang-jwt/v5)

On successful login, issue a JWT (access token). Handle custom claims (user ID, roles) type-safely by embedding them in 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")
}

The Echo handler just calls this thinly.

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

User-enumeration countermeasure: don't distinguish between "the email doesn't exist" and "the password is wrong" in the response. To not tell the attacker whether an account exists, make the error always the same wording.


3. Token verification (authentication): protect routes with echo-jwt/v5

Leave verification to echo-jwt/v5 touched on in the middleware guide. The trick for type safety is to specify the same custom-claims type as at issuance via 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))

Prepare one helper to extract the claims from the handler, confining the type assertion to one place (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. Refresh tokens: rotation of short-lived access + long-lived refresh

Making the access token short-lived (15 min) means even on a leak the damage is over in a short time. Re-issue with a refresh token (long-lived) instead. Build this sloppily and the JWT's advantages are ruined.

Key design points:

  • Don't make the refresh token a JWT (because it can't be revoked). Generate a random opaque string and hash it and store it in the DB.
  • Rotation: on each refresh, revoke the old token and issue a new one.
  • Theft detection: if a revoked token is reused, revoke all of that user's refreshes (lock out the attacker).
  • Storage is httpOnly + Secure + SameSite Cookie (can't be read from JS = not stolen via 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),
	})
}

At /auth/refresh, hash the Cookie's token and match it against the DB → if valid, revoke the old one and issue a new access + refresh (rotation).

Storage-location judgment: hold the access token in memory (or short-lived), and always put the refresh in an httpOnly Cookie. localStorage is fully drained via XSS, so don't use it to store auth tokens. With the Cookie method, also use CSRF measures (SameSite + a CSRF token).


5. Authorization (RBAC): restrict operations by role

Past authentication, judge "whether this operation is allowed." Inspect the claims' roles in middleware.

// 指定ロールのいずれかを持つことを要求するミドルウェア
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 What RBAC can't prevent: owner check (IDOR)

Roles alone can't prevent the accident of "being able to touch someone else's resource" (IDOR). The typical case is A with the user role being able to hit /orders/<B's order ID>. Always cross-check the resource's owner with the requesting subject.

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

The structure that "vertical risk (authorization, IDOR) can't be fully protected by automation and can only be protected by design" is handled consistently — in a different stack — in Next.js × Supabase application-layer security.


6. Security-pitfall checklist

PitfallCountermeasure
alg-confusion attack (alg:none or HS↔RS swap)fix SigningMethod on the verification side (echo-jwt config)
Hardcoding the signing keyenvironment variables / a secrets manager. A leaked HS256 key = impersonation
Long-lived access tokenshorten to ~15 minutes + re-issue with refresh
Can't revoke refreshopaque token + DB management + rotation (don't make it a JWT)
Token in localStoragehttpOnly Cookie (XSS resistance)
User enumerationmake the auth-failure error uniformly identical
Authorization gap (IDOR)in addition to role inspection, an owner check
Brute-force loginput a RateLimiter on /login

If you seriously operate verification with asymmetric keys (RS256) and JWKS, or external IdP (Cognito, etc.) integration, the JWT RS256 verification / JWKS article is detailed at the primary-source level on signature-verification pitfalls. For the judgment of whether your own JWT suffices or to lean on an IdP, see the technology selection of authentication platforms (Cognito/Auth0/Clerk/Supabase).


Conclusion: 7 principles to make authentication & authorization production-quality

  1. Hash passwords with bcrypt/argon2id. Constant-time compare with CompareHashAndPassword.
  2. Access is a short-lived JWT, refresh is long-lived and DB-managed — split the roles.
  3. Issue with golang-jwt/v5, verify with echo-jwt/v5. Embed custom claims in RegisteredClaims type-safely.
  4. Fix SigningMethod on the verification side and seal off alg-confusion attacks.
  5. Refresh is rotation, revocation, theft detection. Put it in an httpOnly+Secure+SameSite Cookie.
  6. Separate authentication and authorization, and prevent vertical/horizontal privilege escalation with RBAC + an owner check.
  7. Keys in environment variables, rate-limit /login, and make auth failures a uniform identical error.

Authentication is not "done once you add a library" but the design of the token lifecycle itself. On this pattern as a foundation, lean the authorization logic into the UseCase with clean architecture, and regression-verify the permission boundaries with tests, and you get a robust authentication foundation.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading