# Spring Security 6: JWT認証の完全ガイド > Spring Security 6でJWT認証を実装するための実践ガイド。設定、トークン生成、検証、セキュリティのベストプラクティスを解説します。 - Published: 2026-02-19 - Updated: 2026-04-28 - Author: SharpSkill - Tags: spring security, jwt, java, spring boot, authentication - Reading time: 15 min --- JWT(JSON Web Token)による認証は、現代のREST APIを保護するための標準となっています。Spring Security 6では関数型の設定アプローチが導入され、実装が簡潔になりつつセキュリティも強化されています。本ガイドでは、初期設定からエンドポイントの保護までの一連の流れを取り上げます。 > **前提条件** > > このチュートリアルではSpring Boot 3.2+とSpring Security 6.2+を使用します。新しいバージョンでも、わずかな構文調整で同じ概念がそのまま使えます。 ## Spring SecurityにおけるJWTアーキテクチャ JWT認証はステートレスの原則に基づきます。サーバーはセッションを保持しません。各リクエストには、ユーザーのアイデンティティを証明する署名付きトークンが含まれます。このアーキテクチャにより、インスタンス間でセッションを共有せずに水平スケールが可能になります。 JWT認証のフローは複数のステップに分けられます。ユーザーは資格情報で認証し、署名付きJWTトークンを受け取り、その後のすべてのリクエストにこのトークンを付与します。サーバーは署名を検証し、トークンからユーザー情報を取り出します。 ```java // JwtAuthenticationFlow.java // Conceptual representation of the authentication flow public class JwtAuthenticationFlow { // 1. Initial authentication: POST /api/auth/login // → Verify credentials against database // → Generate signed JWT token // → Return token to client // 2. Authenticated requests: GET /api/protected // → Header: Authorization: Bearer // → Extract and validate token // → Create SecurityContext // → Access protected resource } ``` このアプローチはスティッキーセッションの問題を取り除き、分散環境へのデプロイを容易にします。 ## Maven依存関係の設定 プロジェクトはSpring Securityの依存関係とJWTライブラリを必要とします。JJWT(Java JWT)は、トークン操作のための流暢でメンテナンスの行き届いたAPIを提供します。 ```xml org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web io.jsonwebtoken jjwt-api 0.12.5 io.jsonwebtoken jjwt-impl 0.12.5 runtime io.jsonwebtoken jjwt-jackson 0.12.5 runtime org.springframework.boot spring-boot-starter-data-jpa ``` JJWTを3つのモジュール(api、impl、jackson)に分けるのは、カプセル化の原則に従っています。コンパイル時にはAPIだけが見え、実装は実行時の詳細にとどまります。 ## JWTの生成と検証を行うサービス JWTサービスは、生成・クレーム抽出・検証といったトークンに関するすべての操作を一元化します。安全な秘密鍵が各トークンに署名し、その完全性を保証します。 ```java // JwtService.java @Service public class JwtService { // Secret key injected from application.yml @Value("${app.jwt.secret}") private String secretKey; // Token validity duration (24 hours by default) @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Generates a JWT token for an authenticated user public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } // Generates a token with custom claims public String generateToken(Map extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) // Additional claims (roles, permissions) .subject(userDetails.getUsername()) // Principal identifier .issuedAt(new Date()) // Creation date .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) .signWith(getSigningKey(), Jwts.SIG.HS256) // HMAC-SHA256 signature .compact(); } // Extracts the username (subject) from the token public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } // Extracts a specific claim via an extraction function public T extractClaim(String token, Function claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // Validates the token: correct signature and not expired public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } // Checks if the token has expired private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } // Extracts the expiration date private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // Parses the token and extracts all claims private Claims extractAllClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) // Verifies the signature .build() .parseSignedClaims(token) // Parses the signed token .getPayload(); // Returns the claims } // Generates the signing key from the Base64-encoded secret private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } } ``` 秘密鍵は十分に長く(HS256では最低256ビット)、安全に保管しなければなりません。ソースコードに含めることは決して避けてください。 > **秘密鍵のセキュリティ** > > JWT鍵には環境変数またはシークレットマネージャーを使用してください。鍵が漏洩すると、任意のユーザー向けの有効なトークンを偽造できてしまいます。 ## Userエンティティの構成 ユーザーエンティティはSpring Securityの`UserDetails`を実装し、認証システムと直接統合できるようにします。 ```java // User.java @Entity @Table(name = "users") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String password; @Column(nullable = false) private String firstName; @Column(nullable = false) private String lastName; // Role stored as enum for type safety @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; // UserDetails implementation: returns user authorities @Override public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } // Username corresponds to email in this implementation @Override public String getUsername() { return email; } // Account always active (adapt as needed) @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } // Getters and setters omitted for brevity } ``` ```java // Role.java public enum Role { USER, // Standard user ADMIN // Administrator with extended privileges } ``` `Role`列挙型は取り得る値を限定し、SpEL式での権限チェックを簡潔にします。 ## JWT認証フィルター JWTフィルターはすべてのリクエストを横取りし、トークンを抽出して検証します。Spring Securityのフィルターチェーンに、標準の認証フィルターより前で組み込まれます。 ```java // JwtAuthenticationFilter.java @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { // Retrieve the Authorization header final String authHeader = request.getHeader("Authorization"); // Check for Bearer prefix if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } // Extract the token (without the "Bearer " prefix) final String jwt = authHeader.substring(7); try { // Extract username from token final String userEmail = jwtService.extractUsername(jwt); // Check that user is not already authenticated if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { // Load user details from database UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail); // Validate token (signature + expiration + user match) if (jwtService.isTokenValid(jwt, userDetails)) { // Create authentication object UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); // Add request details authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); // Set authentication in security context SecurityContextHolder.getContext().setAuthentication(authToken); } } } catch (ExpiredJwtException e) { // Token expired: user must re-authenticate response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Token expired"); return; } catch (JwtException e) { // Invalid token: incorrect signature or malformed format response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid token"); return; } // Continue the filter chain filterChain.doFilter(request, response); } } ``` `OncePerRequestFilter`は、forwardやincludeが発生してもフィルターをリクエストごとに一度だけ実行することを保証します。 ## Spring Security 6の構成 Spring Security 6は、ラムダを用いた関数型のアプローチでセキュリティチェーンを構成します。この構成ではアクセスルールを定義し、JWTフィルターを組み込みます。 ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity // Enables @PreAuthorize and @PostAuthorize @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Disable CSRF since API is stateless (no session cookies) .csrf(csrf -> csrf.disable()) // Configure authorization rules .authorizeHttpRequests(auth -> auth // Public endpoints: authentication and registration .requestMatchers("/api/auth/**").permitAll() // API documentation accessible without authentication .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Admin endpoints reserved for administrators .requestMatchers("/api/admin/**").hasRole("ADMIN") // All other requests require authentication .anyRequest().authenticated() ) // Stateless mode: no server-side HTTP session .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // Custom authentication provider .authenticationProvider(authenticationProvider) // Insert JWT filter before standard authentication filter .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } } ``` ```java // ApplicationConfig.java @Configuration @RequiredArgsConstructor public class ApplicationConfig { private final UserRepository userRepository; // User loading service for Spring Security @Bean public UserDetailsService userDetailsService() { return username -> userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } // Authentication provider with password encoder @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } // Authentication manager exposed as bean @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } // BCrypt encoder for secure password hashing @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` ステートレスなAPIではCSRFを無効化しても安全です。認証はブラウザが自動送信するクッキーではなく、明示的に渡されるヘッダーに依存しているからです。 ## 認証コントローラー コントローラーは登録とログインのエンドポイントを公開します。これらのエンドポイントは公開されており、認証成功後にJWTトークンを返します。 ```java // AuthenticationController.java @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthenticationController { private final AuthenticationService authService; // POST /api/auth/register - Register a new user @PostMapping("/register") public ResponseEntity register( @Valid @RequestBody RegisterRequest request ) { return ResponseEntity.ok(authService.register(request)); } // POST /api/auth/login - Login existing user @PostMapping("/login") public ResponseEntity login( @Valid @RequestBody AuthenticationRequest request ) { return ResponseEntity.ok(authService.authenticate(request)); } // POST /api/auth/refresh - Token refresh (optional) @PostMapping("/refresh") public ResponseEntity refresh( @RequestHeader("Authorization") String authHeader ) { return ResponseEntity.ok(authService.refreshToken(authHeader)); } } ``` ```java // AuthenticationRequest.java public record AuthenticationRequest( @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") String password ) {} ``` ```java // RegisterRequest.java public record RegisterRequest( @NotBlank(message = "First name required") String firstName, @NotBlank(message = "Last name required") String lastName, @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") @Size(min = 8, message = "Password must contain at least 8 characters") String password ) {} ``` ```java // AuthenticationResponse.java public record AuthenticationResponse( String token, String type, long expiresIn ) { public AuthenticationResponse(String token, long expiresIn) { this(token, "Bearer", expiresIn); } } ``` JavaのレコードはDTOの定義を簡潔にしながら、不変性を保証します。 ## 認証サービス このサービスは登録と認証のロジックを取りまとめ、トークンの生成は`JwtService`に委譲します。 ```java // AuthenticationService.java @Service @RequiredArgsConstructor public class AuthenticationService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final AuthenticationManager authenticationManager; @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Register a new user @Transactional public AuthenticationResponse register(RegisterRequest request) { // Check that email is not already in use if (userRepository.existsByEmail(request.email())) { throw new EmailAlreadyExistsException("Email already registered"); } // Create user entity with hashed password User user = new User(); user.setFirstName(request.firstName()); user.setLastName(request.lastName()); user.setEmail(request.email()); user.setPassword(passwordEncoder.encode(request.password())); user.setRole(Role.USER); // Persist the user userRepository.save(user); // Generate and return JWT token String jwtToken = jwtService.generateToken(user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Authenticate an existing user public AuthenticationResponse authenticate(AuthenticationRequest request) { // Delegate verification to AuthenticationManager authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.email(), request.password() ) ); // Load user (authentication succeeded) User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate token with additional claims Map claims = new HashMap<>(); claims.put("role", user.getRole().name()); claims.put("userId", user.getId()); String jwtToken = jwtService.generateToken(claims, user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Refresh token (extend session) public AuthenticationResponse refreshToken(String authHeader) { if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new InvalidTokenException("Invalid token"); } String oldToken = authHeader.substring(7); String userEmail = jwtService.extractUsername(oldToken); User user = userRepository.findByEmail(userEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate new token String newToken = jwtService.generateToken(user); return new AuthenticationResponse(newToken, jwtExpiration); } } ``` `AuthenticationManager`は資格情報の検証を集約し、認証戦略の差し替えを容易にします。 > **リフレッシュトークン** > > セキュリティを高めるには、より長い有効期限を持ち、データベースに保存して取り消し可能なリフレッシュトークンの仕組みを導入してください。 ## アノテーションによるエンドポイント保護 メソッドレベルのセキュリティを使うと、ビジネスコードの中で細やかな権限制御ができます。 ```java // UserController.java @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; // Accessible to all authenticated users @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal User currentUser ) { return ResponseEntity.ok(UserDTO.from(currentUser)); } // Only the concerned user or an admin can modify the profile @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") @PutMapping("/{id}") public ResponseEntity updateUser( @PathVariable Long id, @Valid @RequestBody UpdateUserRequest request ) { return ResponseEntity.ok(userService.updateUser(id, request)); } // Reserved for administrators @PreAuthorize("hasRole('ADMIN')") @GetMapping public ResponseEntity> getAllUsers() { return ResponseEntity.ok(userService.findAll()); } // Deletion with post-execution verification @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } } ``` ```java // AdminController.java @RestController @RequestMapping("/api/admin") @PreAuthorize("hasRole('ADMIN')") // All methods require ADMIN public class AdminController { private final AdminService adminService; @GetMapping("/dashboard") public ResponseEntity getDashboard() { return ResponseEntity.ok(adminService.getDashboardStats()); } @PostMapping("/users/{id}/role") public ResponseEntity changeUserRole( @PathVariable Long id, @RequestParam Role newRole ) { return ResponseEntity.ok(adminService.changeUserRole(id, newRole)); } } ``` `@AuthenticationPrincipal`アノテーションは認証済みユーザーを直接注入し、`SecurityContext`に手動でアクセスする必要をなくします。 ## 認証エラーのハンドリング エラーを集中的に扱うことで、認証が失敗したときに一貫した分かりやすい応答を返せます。 ```java // SecurityExceptionHandler.java @RestControllerAdvice public class SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(SecurityExceptionHandler.class); // Authentication error (incorrect credentials) @ExceptionHandler(BadCredentialsException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleBadCredentials(BadCredentialsException ex) { return new ErrorResponse( "INVALID_CREDENTIALS", "Invalid email or password", null ); } // Access denied (authenticated but not authorized) @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDenied(AccessDeniedException ex) { return new ErrorResponse( "ACCESS_DENIED", "Access to this resource is not authorized", null ); } // Expired JWT token @ExceptionHandler(ExpiredJwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleExpiredToken(ExpiredJwtException ex) { return new ErrorResponse( "TOKEN_EXPIRED", "Session expired, please log in again", null ); } // Invalid JWT token @ExceptionHandler(JwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleInvalidToken(JwtException ex) { log.warn("Invalid JWT token: {}", ex.getMessage()); return new ErrorResponse( "INVALID_TOKEN", "Invalid authentication token", null ); } // Email already in use during registration @ExceptionHandler(EmailAlreadyExistsException.class) @ResponseStatus(HttpStatus.CONFLICT) public ErrorResponse handleEmailExists(EmailAlreadyExistsException ex) { return new ErrorResponse( "EMAIL_EXISTS", ex.getMessage(), null ); } } ``` ```java // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` 標準化されたエラーコードはクライアント側の処理とデバッグを容易にします。 ## application.ymlの設定 外部化された設定により、環境ごとにJWTのパラメーターを調整できます。 ```yaml # application.yml app: jwt: # Base64 secret key (256 bits minimum for HS256) # Generate with: openssl rand -base64 32 secret: ${JWT_SECRET:yourSuperSecretKeyOf256BitsMinimum} # Validity duration in milliseconds (24 hours) expiration: 86400000 spring: datasource: url: jdbc:postgresql://localhost:5432/springjwt username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} jpa: hibernate: ddl-auto: validate show-sql: false ``` ```yaml # application-dev.yml app: jwt: # Short expiration for development (1 hour) expiration: 3600000 spring: jpa: show-sql: true hibernate: ddl-auto: update logging: level: org.springframework.security: DEBUG ``` 本番環境では、秘密鍵は環境変数またはVaultのようなシークレット管理サービスから取得すべきです。 ## 統合テスト テストでは認証システム全体の振る舞いを検証します。登録から保護されたリソースへのアクセスまでを通しで確認します。 ```java // AuthenticationIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = Replace.ANY) class AuthenticationIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @BeforeEach void setUp() { userRepository.deleteAll(); } @Test void shouldRegisterAndAuthenticateUser() { // Registration RegisterRequest registerRequest = new RegisterRequest( "John", "Doe", "john@example.com", "password123" ); ResponseEntity registerResponse = restTemplate .postForEntity("/api/auth/register", registerRequest, AuthenticationResponse.class); assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(registerResponse.getBody().token()).isNotBlank(); // Login with same credentials AuthenticationRequest loginRequest = new AuthenticationRequest( "john@example.com", "password123" ); ResponseEntity loginResponse = restTemplate .postForEntity("/api/auth/login", loginRequest, AuthenticationResponse.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResponse.getBody().token()).isNotBlank(); } @Test void shouldRejectInvalidCredentials() { AuthenticationRequest request = new AuthenticationRequest( "unknown@example.com", "wrongpassword" ); ResponseEntity response = restTemplate .postForEntity("/api/auth/login", request, ErrorResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void shouldAccessProtectedResourceWithValidToken() { // Register to get a token RegisterRequest registerRequest = new RegisterRequest( "Jane", "Doe", "jane@example.com", "password123" ); AuthenticationResponse authResponse = restTemplate .postForObject("/api/auth/register", registerRequest, AuthenticationResponse.class); // Access protected resource with token HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(authResponse.token()); ResponseEntity response = restTemplate.exchange( "/api/users/me", HttpMethod.GET, new HttpEntity<>(headers), UserDTO.class ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().email()).isEqualTo("jane@example.com"); } @Test void shouldRejectAccessWithoutToken() { ResponseEntity response = restTemplate .getForEntity("/api/users/me", Void.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } } ``` これらのテストは、登録・ログイン・許可されたアクセス・拒否されたアクセスといった主要なシナリオをカバーします。 ## まとめ Spring Security 6によるJWT認証の実装は、明確なパターンに従います。JWTサービスがトークンの生成と検証を担当し、フィルターがリクエストを横取りしてセキュリティコンテキストを確立し、構成がアクセスルールを定めます。 **デプロイ用チェックリスト:** - ✅ 環境変数に保存された256ビット以上の秘密鍵 - ✅ 本番ではトークンを通信路上で保護するためHTTPSを必須 - ✅ コンテキストに合わせたトークン有効期間(15分〜24時間) - ✅ 再認証なしで長時間のセッションを支えるリフレッシュトークン - ✅ ビジネスコードによる標準化されたエラーハンドリング - ✅ 認証失敗の試行のロギング - ✅ 認証シナリオを網羅する統合テスト - ✅ 認証エンドポイントへのレートリミット このアーキテクチャはREST APIを保護するための堅牢な土台になり、多要素認証やOAuth2連携など、より高度な要件にも拡張しやすいまま保たれます。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/spring-boot/spring-security-6-jwt-authentication