🔒

Next.js × GoでのJWT認証とCSRF対策についての備忘録

に公開

Next.js×Goの構成での、JWTとCSRFトークンの実装方法を調べた際の備忘録です。
セッション管理とセキュリティ対策の構成として、JWTをHttpOnly Cookieで管理し、CSRF対策にはDouble Submit Cookieパターンを採用する方法が、実装の簡潔さとセキュリティのバランスに優れていると判断しました。

構成と前提

  • フロントエンド:Next.js
  • バックエンド:Go(Echo)
  • 認証方式:JWT を Cookie(HttpOnly)で保存
  • CSRF対策:Double Submit Cookie パターンを採用

認証とCSRF保護の全体フロー

解説

①〜④:ログイン処理とJWT発行

  • フロントエンドからログインフォームを送信し、Go APIに認証情報を渡します。
  • 認証成功時、GoはJWTを発行します。
    • golang-jwt/jwtを使用します。
    • 以下のように環境変数からシークレットを取得し、署名します。
    • 標準的なjwt.RegisteredClaimsに加えて、独自の要素をペイロードに追加できます。
    • JWTにはパスワードなどの機微な情報は含めないように注意してください。
type JWTClaims struct {
	jwt.RegisteredClaims
	Email  string `json:"email"`
	Name   string `json:"name"`
	Role   string `json:"role"`
}

func GenerateToken(userID int, email, name string, role string) (string, error) {
	secret := os.Getenv("JWT_SECRET")
	if secret == "" {
		return "", errors.New("JWT_SECRET environment variable not set")
	}
	now := time.Now()

	claims := JWTClaims{
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   fmt.Sprintf("%d", userID),
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(72 * time.Hour)),
		},
		Email: email,
		Name:  name,
		Role:  role,
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(secret))
}
  • HttpOnly属性付きのCookieに設定して返却します。
cookie := &http.Cookie{
    Name:     "auth-token",
    Value:    tokenString,
    HttpOnly: true,
    Secure:   os.Getenv("ENV") == "production", // 本番環境では true にする
    Path:     "/",
    MaxAge:   60 * 60 * 72, // 72時間
    SameSite: http.SameSiteStrictMode,
}
c.SetCookie(cookie)

ここで、以下のようにCookieの属性を設定します

項目 目的
HttpOnly true XSS対策
Secure 本番ではtrue 中間者攻撃対策
Samesite strict CSRF対策
  • フロントではこのCookieをそのまま保存。JavaScriptからは読み取れないため、XSS対策になります。

⑤〜⑦:認証後の通常ページ表示

  • CookieのJWTはブラウザから自動で送信されます。
  • middleware.tsなどのミドルウェアでCookieを取得し、joseのjwtVerifyでpayloadを取得します。
  • payload内の属性を利用して制御ができます。
middleware.ts
export function middleware(req: NextRequest) {
  const token = req.cookies.get(COOKIE_NAME)?.value;
  const verified = await verifyJwt(token);

  if (!verified) {
    const loginUrl = new URL('/login', req.url);
    return NextResponse.redirect(loginUrl);
  }

  // 管理者でなければ一般ユーザーページへリダイレクト
  if (verified.role == 'guest') {
    const userUrl = new URL('/top', req.url);
    return NextResponse.redirect(userUrl);
  }

  return NextResponse.next();
}
import { jwtVerify } from 'jose';

export const verifyJwt = async <
  T extends Record<string, unknown> = Record<string, unknown>,
>(
  token: string | undefined,
): Promise<T | null> => {
  if (!token) return null;
  try {
    const { payload } = await jwtVerify<T>(token, process.env.JWT_SECRET);
    return payload;
  } catch (err) {
    if (process.env.NODE_ENV !== 'production') {
      console.error('JWT verification failed:', err);
    }
    return null;
  }
};

⑧〜⑪:更新系画面のアクセスとCSRFトークン取得

  • フォームによる更新を伴うページに遷移するタイミングで、CSRFトークンを発行する専用APIを呼び出します(ワンタイムトークン)。
  • APIはトークンをランダム生成し、HttpOnlyをつけずにCookieへ保存します(JavaScriptで読み取れるように)。
  • フロントエンドはこの値を取得し、次のリクエストで利用できるように保持します。

⑫〜⑯:フォーム送信とセキュリティ検証

  • フォーム送信時に、JWTはHttpOnly Cookieから送信され、CSRFトークンはヘッダーに明示的に追加されます。

    • 例:X-CSRF-Token: <token_value>
  • Go側ではミドルウェアなどを通じて:

    • JWTの有効性と期限を検証
    • Cookieの csrf_token とヘッダーの X-CSRF-Token の一致を確認(Double Submit Cookie)

バックエンドでのJWTの検証

// 発行したときと同じjwtの型
type JWTClaims struct {
    jwt.RegisteredClaims
    Email   string `json:"email"`
    Name    string `json:"name"`
    Role    string `json:"role"`
}

type AuthenticatedUser struct {
	ID      string
	Email   string
	Name    string
	Role    string
}

func JWTAuthMiddleware() echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			// Cookieからトークンを取得
			cookie, err := c.Cookie(base.CookieAuth)
			if err != nil {
				// エラー処理
			}

			// トークンの検証
			user, err := validateJWTToken(cookie.Value)
			if err != nil {
				// エラー処理
			}

			// ユーザー情報をコンテキストに設定すると後続処理で使用可能
			c.Set("user", *user)

			return next(c)
		}
	}
}

func validateJWTToken(tokenString string) (*AuthenticatedUser, error) {
	// 秘密鍵の取得
	secret := os.Getenv("JWT_SECRET")
	if secret == "" {
		return nil, errors.New("JWT_SECRET が設定されていません")
	}

	claims := JWTClaims{}
	// トークンのパースと検証
	token, err := jwt.ParseWithClaims(
		tokenString,
		claims,
		func(token *jwt.Token) (interface{}, error) {
			// トークンの署名方法の検証
			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
				return nil, errors.New("無効な署名方法")
			}
			return []byte(secret), nil
		},
	)

	if err != nil {
		// トークンの有効期限切れなどのエラーハンドリング
		if errors.Is(err, jwt.ErrTokenExpired) {
			return nil, errors.New("トークンの有効期限が切れています")
		}
		return nil, errors.New("トークンの検証に失敗しました")
	}

	// トークンが有効であることを確認
	if !token.Valid {
		return nil, errors.New("無効なトークンです")
	}

	if claims.Subject == "" {
		return nil, errors.New("ユーザーIDの取得に失敗")
	}

	if claims.Email == "" {
		return nil, errors.New("メールアドレスの取得に失敗")
	}

	if claims.Name == "" {
		return nil, errors.New("ユーザー名の取得に失敗")
	}

	if claims.Role == "" {
		return nil, errors.New("権限の取得に失敗")
	}

	user := AuthenticatedUser{
		ID:      claims.Subject,
		Email:   claims.Email,
		Name:    claims.Name,
		Role:    claims.Role,
	}
	return &user, nil
}
  • 問題なければ処理を実行し、結果を返却します。

XSS対策

リスクとしては残るので、dangerouslySetInnerHTMLを避ける、CSPの設定、サニタイズなどの他の対策は考慮が必要です
https://zenn.dev/yutoo89/articles/d1ed72b821b2c0

採用したセキュリティ対策のポイント

項目 内容
JWT Cookieに保存(HttpOnly, Secure, SameSite=Strict
認証検証 josegolang-jwt/jwt を使用し署名・期限を検証
CSRF対策 Double Submit Cookie パターン(Cookie + ヘッダー照合)
トークン取得 必要なページでワンタイムCSRFトークンAPIを呼び出す

まとめ

このように、Next.jsとGo APIを用いたアプリケーションにおいては、次のような方針が現実的かつ堅牢です:

  • 認証はJWT + Cookieで管理
  • 認証チェックはフロントエンドとバックエンドそれぞれで持つSecretで検証
  • CSRFトークンをCookieとして発行するAPIを用意し、Double Submit Cookieパターンで検証する
  • トークンの扱いにはCookie属性を適切に設定して、XSSやCSRFリスクを最小限に

https://zenn.dev/yktakaha4/articles/study_csrf_attack

Discussion