💡

Spring Boot実装例で理解するJWT認証の仕組み

に公開1

はじめに

APIやSPA(Single Page Application)を作っていると、「JWT認証」という言葉を耳にしたことはありませんか?

Spring BootでもBearerトークンを扱う実装が一般的になっていますが、「そもそもなぜJWTが必要なのか?」「リフレッシュトークンとは何か?」 という根本を理解していないままになってしまっていたので学習した記録を記事として投稿することにしました。

この記事では、

  1. JWTの目的と仕組み
  2. リフレッシュトークンが存在する理由
  3. Spring Bootでの最小限の実装例(JwtUtil)を通して、認証の基本構造を整理します。

1. JWT(JSON Web Token)とは?

JWT(JSON Web Token)は、サーバーが発行する「署名付きの認証情報」です。

通常のWebアプリでは「セッションID」がCookieに保存され、サーバーがセッション情報をメモリやRedisに保持しています。
しかし、アプリケーションがスケールアウト(サーバーの台数を増やす)すると、セッション情報の共有が複雑になるという課題があります。

JWTは、ユーザー情報をJSON形式で含んだトークンであり、サーバー側で状態を保持しない「ステートレス」な認証を実現します。これにより、サーバーのスケーラビリティが向上し、マイクロサービスアーキテクチャなど、さまざまな環境で認証・認可をシンプルに実装できます。。

2. JWTの構造

JWTは3つの部分から構成されています。

  • ヘッダー:署名アルゴリズム(例:HS256)
  • ペイロード:認証情報(例:ユーザID、発行時刻、有効期限)
  • 署名:改ざん防止のための暗号署名

サーバーはこのトークンを受け取り、署名を検証し、正当なトークンなら「このユーザはログイン済み」と判断します。

3. なぜJWTが必要なのか

✅ セッションレスでスケールしやすい

サーバーがセッション状態を保持しないため、どのサーバーにリクエストが届いても認証が成立します。

✅ API認証に向いている

モバイルアプリやSPAでは、CookieではなくAuthorization: Bearer <token>を使ってリクエストヘッダーで認証情報を送ることが多いため、JWTが適しています。

✅ 情報を署名付きで保持できる

トークン内に「誰が」「いつログインしたか」を安全に保持できます。
署名により改ざんが防止されるため、サーバーで状態を持つ必要がありません。

4. リフレッシュトークンとは?

JWTは安全性のために短い有効期限(例:10分)が設定されます。
そのままでは10分後に再ログインが必要になるため、
新しいトークンを発行するための「リフレッシュトークン」 を併用します。

種類 有効期限 保存場所 役割
アクセストークン 短い(数分〜十数分) メモリやヘッダ APIアクセス認証
リフレッシュトークン 長い(数時間〜数日) Cookie(HttpOnly) アクセス更新用

リフレッシュトークンは通常、HTTPOnly属性のCookieに保存し、
クライアントJavaScriptからは触れないようにします。

5. Spring Bootでの実装例

java21
Spring Boot 3.4.1
Spring Security

認証フロー

  1. ログイン: ユーザーがメールアドレスとパスワードを送信すると、サーバーはそれを検証し、正しければJWT(アクセストークン)を生成して返却します。
  2. APIリクエスト: ユーザーは、保護されたAPIにリクエストを送る際、HTTPヘッダーにこのJWTを含めます。
  3. トークン検証: サーバーはリクエストを受け取ると、まずJWTが改ざんされていないか、有効期限が切れていないかを検証します。
  4. アクセス許可:検証が成功すれば、ユーザーはリクエストしたリソースにアクセスできます。

ディレクトリツリー

com/xxxxxx/
    ├── ServiceApplication.java: SpringBootアプリケーションのエントリーポイント。
    ├── config/: アプリケーションの構成クラスを格納。
    │   ├── SecurityConfig.java: SpringSecurityの設定クラス。認証認可のルール定義。
    │   └── WebConfig.java: Web関連の構成クラスです。
    ├── controller/:HTTPリクエストを処理するコントローラークラスを格納。
    ├── dto/: データ転送オブジェクト(DTO)を格納。
    ├── exception/: 例外処理関連のクラスを格納。
    ├── mapper/: MyBatisのマッパーインターフェースを格納。
    ├── security/: Spring SecurityとJWTに関連するクラスを格納。
    │   ├── CustomUserDetails.java: SpringSecurityがユーザー情報を扱うためのカスタムクラス。
    │   ├── CustomUserDetailsServiceImpl.java:ユーザー名(メールアドレス)からユーザー情報を取得するサービスクラス。
    │   ├── JwtAuthenticationFilter.java:リクエスト毎にJWTトークンを検証し、ユーザー認証を行うフィルター。
    │   ├── JwtKeyProvider.java:JWTトークンの署名と検証に使用する秘密鍵を管理・提供。
    │   ├── JwtUtil.java:JWTトークンの生成、解析、検証を行うためのユーティリティクラス。
    ├── service/:ビジネスロジックを実装するサービスクラスのインターフェースを格納。
    └── serviceImpl/: サービスクラスの実装を格納。


実装の核心を担う主要コンポーネント

1. JwtUtil.java - JWTの生成と検証の心臓部

このクラスは、JWTに関するあらゆる操作(生成、検証、情報抽出)を担当するユーティリティです。

  • generateToken(String email): ユーザーのメールアドレスを元に、JWT(アクセストークン)を生成します。トークンには、ユーザーの識別情報(ここではメールアドレス)や有効期限などの「クレーム」が含まれます。
  • validateToken(String token): 受け取ったトークンが、改ざんされていないか、有効期限が切れていないかを検証します。
  • getEmailFromToken(String token):トークンからユーザーのメールアドレス情報を抽出します。
package com.xxxxxx.security;

import java.util.Arrays;
import java.util.Date;

import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
@Component
public class JwtUtil {

  private final long expirationTime = 600000;      // 10分
  private final long longExpirationTime = 3600000; // 1時間
  private final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
  private final JwtKeyProvider keyProvider; // SecretKey提供元

  public JwtUtil(JwtKeyProvider keyProvider) {
    this.keyProvider = keyProvider;
  }

  // アクセストークン発行
  public String generateToken(String mailAddress) {
    return Jwts.builder()
        .setSubject(mailAddress)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + expirationTime))
        .signWith(keyProvider.getSecretKey(), SignatureAlgorithm.HS256)
        .compact();
  }

  // リフレッシュトークン発行
  public String generateRefreshToken(String mailAddress) {
    return Jwts.builder()
        .setSubject(mailAddress)
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + longExpirationTime))
        .signWith(keyProvider.getSecretKey(), SignatureAlgorithm.HS256)
        .compact();
  }

  // トークン検証
 public boolean validateToken(String token) {
    try {
			Jwts.parserBuilder().setSigningKey(keyProvider.getSecretKey()).build().parseClaimsJws(token);
      return true;
    } catch (Exception e) {
      return false;
    }
  }

  // トークンからメールアドレスを取得
  public String getEmailFromToken(String token) {
    Claims claims = Jwts.parserBuilder().setSigningKey(keyProvider.getSecretKey()).build().parseClaimsJws(token)
				.getBody();
		return claims.getSubject();
  }

  /**
  * クッキーからリフレッシュトークンを取得するメソッド
  *
  * @param request HttpServletRequest
  * @return リフレッシュトークン(存在しない場合は null)
  */
  public String extractRefreshTokenFromCookie(HttpServletRequest request) {
    // クッキーが存在しない場合は null を返す
    if (request.getCookies() == null) {
			return null;
    }

    // クッキーを走査して "refreshToken" クッキーを探す
    return Arrays.stream(request.getCookies())
				.filter(cookie -> REFRESH_TOKEN_COOKIE_NAME.equals(cookie.getName())) // クッキー名が "refreshToken" の場合
				.map(Cookie::getValue) // クッキーの値を取得
				.findFirst() // 最初に見つかった値を返す
				.orElse(null); // 存在しない場合は null を返す
	}
}

2. JwtKeyProvider.java - 安全な鍵管理の要

JWTは、改ざんを防ぐために秘密鍵で署名されます。このクラスは、その署名と検証に使うための秘密鍵を安全に管理・提供する役割を担います。外部のファイルや環境変数からキーを読み込むことで、コード内に直接キーを記述するのを避け、セキュリティを高めることができます。

package com.xxxxxx.security;

import java.security.Key;
import java.util.Base64;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.security.Keys;
@Component
public class JwtKeyProvider {

	private final Key secretKey;

	public JwtKeyProvider(@Value("${jwt.secret-key}") String secretKeyString) {
		// Base64 でエンコードされた鍵をデコードして Key オブジェクトを生成
		byte[] decodedKey = Base64.getDecoder().decode(secretKeyString);
		this.secretKey = Keys.hmacShaKeyFor(decodedKey);
	}

	public Key getSecretKey() {
		return secretKey;
	}
}

3. CustomUserDetailsServiceImpl.java & CustomUserDetails.java - Spring Securityとユーザー情報の橋渡し

Spring Securityが認証処理を行うためには、ユーザーの詳細情報(ユーザー名、パスワード、権限など)が必要です。

  • CustomUserDetailsServiceImpl:UserDetailsServiceインターフェースを実装し、loadUserByUsernameメソッドで、データベースなどからユーザー情報を取得します。
package com.xxxxxx.security;

import java.util.Collections;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.xxxxxx.dto.User;
import com.xxxxxx.mapper.UserMapper;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
public class CustomUserDetailsServiceImpl implements UserDetailsService {

	@Autowired
	private UserMapper userMapper;

	@Override
	public UserDetails loadUserByUsername(String mailAddress) throws UsernameNotFoundException {
		// ユーザ情報の取得
		User user = userMapper.findByMailAddress(mailAddress);
		if (user == null) {
			log.warn("User not found for mail address: {}", mailAddress);
			throw new UsernameNotFoundException("User not found with mail address: " + mailAddress);
		}
		log.info("User retrieved: {}", user);

		if (user.getMailAddress() == null || user.getMailAddress().isEmpty()) {
			throw new IllegalArgumentException("User mail address is null or empty");
		}

		if (user.getPassword() == null || user.getPassword().isEmpty()) {
			throw new IllegalArgumentException("User password is null or empty");
		}
		return new org.springframework.security.core.userdetails.User(
				user.getMailAddress(),
				user.getPassword(),
				Collections.emptyList() // 権限情報は今回は設定しない
		);
	}
}
  • CustomUserDetails:UserDetailsインターフェースを実装し、取得したユーザー情報をSpringSecurityが扱える形式に変換します。
package com.xxxxxx.security;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

public class CustomUserDetails implements UserDetails {
	private String emailAddress;

	public CustomUserDetails(String email) {
		this.emailAddress = email;
	}

	public String getEmailAddress() {
		return emailAddress;
	}

	@Override
	public String getPassword() {
		// JWT認証後はpassword不要のため
		return null;
	}

	@Override
	public String getUsername() {
		return this.emailAddress;
	}

}

4. JwtAuthenticationFilter.java - リクエストの入り口でトークンをチェックする門番

このフィルターは、保護されたAPIへのリクエストが来た際に、一番最初に動作します。

  1. リクエストのHTTPヘッダーからJWTを抽出します。
  2. JwtUtilを使ってトークンを検証します。
  3. トークンが有効であれば、CustomUserDetailsServiceを使ってユーザー情報を取得します。
  4. 取得したユーザー情報を元に認証オブジェクトを作成し、SecurityContextHolderにセットします。これにより、後続の処理で「このユーザーは認証済みである」と認識されるようになります。
package com.xxxxxx.security;

import java.io.IOException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

	private final JwtUtil jwtUtil;

	public JwtAuthenticationFilter(JwtUtil jwtUtil) {
		this.jwtUtil = jwtUtil;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		String token = request.getHeader("Authorization");

		if (token != null && token.startsWith("Bearer ")) {
			token = token.substring(7);
			try {
				if (jwtUtil.validateToken(token)) {
					String email = jwtUtil.getEmailFromToken(token);

					// CustomUserDetails を作成して SecurityContext に保存
					CustomUserDetails userDetails = new CustomUserDetails(email);
					UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
							userDetails, null, null);
					SecurityContextHolder.getContext().setAuthentication(authentication);
				}
			} catch (ExpiredJwtException ex) {
				SecurityContextHolder.clearContext(); // SecurityContext をクリア
				response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
				response.getWriter().write("Token has expired");
				response.getWriter().flush();
				return; // フィルタチェーンを中断
			} catch (Exception ex) {
				// その他のエラー
				response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
				response.getWriter().write("An unexpected error occurred");
				response.getWriter().flush(); // 必要に応じて明示的にフラッシュ
			}
		} else {
			System.out.println("No Authorization header or invalid format");
		}

		// 次のフィルタを実行
		filterChain.doFilter(request, response);
	}
}

5. SecurityConfig.java - 認証システムの全体設計図

このクラスは、Spring Securityの全体的な設定を行います。

  • フィルターチェーンの設定:JwtAuthenticationFilterをどのタイミングで実行するかを定義します。
  • 認証ルールの定義: どのAPIエンドポイント(URL)が認証を必要とし(例:/api/private/**)、どのエンドポイントが公開されているか(例:/api/public/login)を設定します。
  • AuthenticationManagerのBean定義: ログイン処理でユーザーの認証を行うAuthenticationManagerをDIコンテナに登録します。
package com.xxxxxx.config;

import java.util.Arrays;
import java.util.Collections;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import com.xxxxxx.security.CustomUserDetailsServiceImpl;
import com.xxxxxx.security.JwtAuthenticationFilter;
import com.xxxxxx.security.JwtUtil;

import jakarta.servlet.http.HttpServletResponse;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	private final JwtUtil jwtUtil;

	public SecurityConfig(JwtUtil jwtUtil) {
		this.jwtUtil = jwtUtil;
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
				.exceptionHandling(exception -> exception
						.authenticationEntryPoint((request, response, authException) -> {
							response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
							response.getWriter().write("Unauthorized: Token required");
							response.getWriter().flush();
						}))
				// CSRFの無効化(REST APIに適している設定)
				.csrf(csrf -> csrf.disable())

				// 認可の設定
				.authorizeHttpRequests(auth -> auth
						.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS リクエストを許可
						.requestMatchers("/api/public/**").permitAll() // 認証不要
						.requestMatchers("/h2-console/**").permitAll() // H2コンソールへのアクセス許可
						.requestMatchers("/api/private/**").authenticated() // 認証必要
				)
				// セッションを無効化(Stateless設定)
				.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

				.cors(Customizer.withDefaults())

				// フレームオプション無効化(H2コンソール用)
				.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()))

				// JWT認証フィルターを追加
				.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

		return http.build();
	}

	@Bean
	public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
		DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
		authenticationProvider.setUserDetailsService(userDetailsService());
		authenticationProvider.setPasswordEncoder(passwordEncoder());

		// AuthenticationManagerBuilderを通じて設定
		AuthenticationManagerBuilder builder = http.getSharedObject(AuthenticationManagerBuilder.class);
		builder.authenticationProvider(authenticationProvider);
		return builder.build();
	}

	@Bean
	public JwtAuthenticationFilter jwtAuthenticationFilter() {
		return new JwtAuthenticationFilter(jwtUtil);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder(); // パスワードのハッシュ化
	}

	@Bean
	public CustomUserDetailsServiceImpl userDetailsService() {
		return new CustomUserDetailsServiceImpl();
	}

	@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();
		configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173"));
		configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
		configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
		configuration.setAllowCredentials(true);

		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}

}

まとめ

このように、SpringBootとJWTを組み合わせることで、各クラスがそれぞれの責務を果たす、クリーンで拡張性の高い認証システムを構築することができます。JwtUtilがトークンの実務を、JwtAuthenticationFilterがリクエストの検証を、そしてSecurityConfigが全体の
交通整理を行う、という役割分担を理解することが、実装の鍵となります。

6. 仕組みの流れ(サーバー側)

  1. /auth/login で認証成功 → JWT発行
    • アクセストークンはレスポンスBodyで返却
    • リフレッシュトークンはCookie(HttpOnly)で送信
  2. クライアントはアクセストークンを Authorization: Bearer ヘッダで送信
  3. トークンが期限切れ → /auth/refresh にCookieを送信して新トークンを発行
  4. 検証は毎リクエストで JwtUtil.validateToken() により行う

7. セキュリティ上の注意

  • Secret Keyは32バイト以上を推奨(.env や AWS Secrets Manager で管理)
  • リフレッシュトークンはCookieに保存し、HttpOnlySecureを必ず設定
  • CSRF対策:Bearer方式なら基本不要だが、Cookie運用時はトークン照合を追加

8. まとめ

  • JWTは「セッションレスでスケーラブルな認証」を実現するための仕組み。
  • 署名付きで改ざんを防ぎ、API中心の構成に最適。
  • リフレッシュトークンを使うことで安全に長期ログインを実現できる。
  • Spring Bootでは io.jsonwebtoken ライブラリを使うとシンプルに構築可能。

💬 私の理解やソースコードに間違い、改善点あればぜひコメントでご指摘お願いいたします!

Discussion