認証・認可は、間違えると即座に重大インシデントになる領域です。パスワードを平文で持つ、JWT の署名検証を緩める、リフレッシュトークンを失効させない——どれも「動いてしまう」ぶん危険です。動くことと安全であることは別物です。
この記事は、Go Echo 本番運用ガイドの認証・認可編です。ミドルウェア完全ガイドが 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 推奨、より高コスト)を使います。
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)の設計も参考になります。
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 Cookie。
localStorageは 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 にしない) |
| トークンを localStorage | httpOnly Cookie(XSS 耐性) |
| ユーザー列挙 | 認証失敗のエラーを一律同一に |
| 認可漏れ(IDOR) | ロール検査に加え所有者チェック |
| 総当たりログイン | RateLimiter を /login に |
非対称鍵(RS256)と JWKS による検証、外部 IdP(Cognito 等)連携を本格運用するなら、署名検証の落とし穴はJWT RS256 検証・JWKS の記事が一次情報レベルで詳しいです。自前 JWT で十分か、IdP に寄せるかの判断は認証基盤の技術選定(Cognito/Auth0/Clerk/Supabase)を参照してください。
まとめ:認証・認可を本番品質にする7原則
- パスワードは bcrypt/argon2id でハッシュ。
CompareHashAndPasswordで定数時間比較。 - アクセスは短命 JWT、リフレッシュは長命・DB 管理で役割を分ける。
- 発行は golang-jwt/v5、検証は echo-jwt/v5。独自クレームを
RegisteredClaimsに埋め型安全に。 - 検証側で SigningMethod を固定し、alg 混同攻撃を封じる。
- リフレッシュは回転・失効・盗難検知。httpOnly+Secure+SameSite Cookie に置く。
- 認証と認可を分け、RBAC + 所有者チェックで縦・横の権限昇格を防ぐ。
- 鍵は環境変数、
/loginにレート制限、認証失敗は一律同一エラー。
認証は「ライブラリを入れれば終わり」ではなく、トークンのライフサイクル設計そのものです。この型を土台に、クリーンアーキテクチャで UseCase に認可ロジックを寄せ、テストで権限境界を回帰検証すれば、堅牢な認証基盤になります。