# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, セキュリティ, 型安全, アーキテクチャ設計, 認証
- URL: https://tomodahinata.com/en/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- Don't compare passwords in plaintext; hash with bcrypt/argon2id. Constant-time compare with bcrypt.CompareHashAndPassword, and tune the cost in production.
- Access tokens are short-lived (15 min) JWTs; refresh is long-lived with rotation/revocation managed in the DB. Splitting the roles minimizes damage on leak.
- Issue with golang-jwt/v5 (NewWithClaims+SignedString), verify with echo-jwt/v5. Embed custom claims in RegisteredClaims and handle them type-safely.
- Authorization (RBAC) inspects the claims' roles in middleware and returns 403 if insufficient. Separate authentication (who) from authorization (what they can do).
- Prevent alg-confusion attacks by fixing SigningMethod. Put the refresh token in an httpOnly+Secure+SameSite Cookie and avoid localStorage.

---

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](/blog/go-echo-framework-production-guide). Whereas the [complete middleware guide](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-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).

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

| Principle | Reason |
| --- | --- |
| Plaintext storage strictly forbidden | a leak = all users' passwords spilled |
| Reversible encryption also disallowed | if the key leaks, everything can be decrypted |
| Use `CompareHashAndPassword` | a homemade `==` compare is a target for timing attacks |
| Tune cost by measuring | too 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)](/blog/aws-cognito-custom-authentication-pin-pbkdf2-passwordless-guide) 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`.

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

The Echo handler just calls this thinly.

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

> **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](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide). The trick for type safety is to specify the **same custom-claims type as at issuance** via `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))
```

Prepare one helper to extract the claims from the handler, confining the type assertion to one place (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. 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).

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

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](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide) (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.

```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 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.**

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

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](/blog/nextjs-supabase-application-security-guide).

---

## 6. Security-pitfall checklist

| Pitfall | Countermeasure |
| --- | --- |
| **alg-confusion attack** (`alg:none` or HS↔RS swap) | **fix `SigningMethod`** on the verification side (echo-jwt config) |
| Hardcoding the signing key | **environment variables / a secrets manager.** A leaked HS256 key = impersonation |
| Long-lived access token | **shorten to ~15 minutes** + re-issue with refresh |
| Can't revoke refresh | **opaque token + DB management + rotation** (don't make it a JWT) |
| Token in localStorage | **httpOnly Cookie** (XSS resistance) |
| User enumeration | make the auth-failure error **uniformly identical** |
| Authorization gap (IDOR) | in addition to role inspection, **an owner check** |
| Brute-force login | put a [RateLimiter](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide) 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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide) 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)](/blog/auth-platform-selection-2026-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](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide), and regression-verify the permission boundaries with [tests](/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide), and you get a robust authentication foundation.
