REST APIを開発していると、 セッションを使わずにリクエストごとに認証を完結させたい という場面が必ず来ます。フォーム認証やBasic認証でSpring Securityに慣れてきた方の、次のステップが JWT(JSON Web Token)によるステートレス認証 です。
Spring SecurityでBasic認証・フォーム認証を実装する方法 では認証の基本を解説しました。本記事ではその続きとして、REST API向けに JWT発行・検証・Filterへの組み込み をゼロから実装します。
読み終えると、次のことが独力でできるようになります。
/api/auth/loginにPOSTしてJWTを受け取る- 取得したトークンをAuthorizationヘッダーに付与して保護エンドポイントにアクセスする
- curl / Postmanでエンドツーエンドの動作を確認する
実装の全体像は次の流れです。
依存関係追加 → JwtUtil → ログインエンドポイント → JwtAuthenticationFilter → SecurityFilterChain設定
順番に進めましょう。
JWTとステートレス認証の仕組みを3分で理解する
JWTの3パート構成
JWT(JSON Web Token)は ドット(.)で区切られた3つのパート から構成されます。
header.payload.signature
| パート | 内容 | エンコード |
|---|---|---|
| Header | アルゴリズム種別(例:HS256) | Base64URL |
| Payload | クレーム(ユーザー名・有効期限など) | Base64URL |
| Signature | HeaderとPayloadをまとめて秘密鍵で署名したもの | バイナリのBase64URL |
HeaderとPayloadは Base64URLエンコードされているだけ で暗号化はされていません。機密情報(パスワードなど)はPayloadに含めないようにしましょう。Signatureが改ざん検知の要です。
ステートレス認証のフロー
クライアント サーバー
| |
| POST /api/auth/login |
| { username, password } -------> |
| | 認証成功 → JWT生成
| { token: "eyJ..." } <--------- |
| |
| GET /api/protected |
| Authorization: Bearer eyJ... --> |
| | JWTを検証 → SecurityContextにセット
| 200 OK <--------- |
ポイントは サーバー側にセッションを保持しない ことです。トークン自体に認証情報が含まれるため、複数のサーバーインスタンスに水平スケールしやすくなります。フォーム認証・Basic認証との根本的な違いはここにあります。
本記事のスコープ外: OAuth2/OIDC連携、リフレッシュトークンの詳細実装、マイクロサービス間のJWT伝播は扱いません。
依存関係の追加(Maven / Gradle)
本記事では jjwt(Java JWT)ライブラリを使います。jjwt 0.11以降はアーティファクトが3つに分かれています。
| アーティファクト | 役割 |
|---|---|
jjwt-api | APIインターフェース |
jjwt-impl | 実装(ランタイムのみ必要) |
jjwt-jackson | JacksonによるJSON解析 |
Maven(pom.xml)
<dependencies>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jjwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
Gradle(build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
注意: 古い
jjwt 0.9.x系はAPIが大きく異なります。ネット上の記事でJwts.parser().setSigningKey(...)という書き方を見かけたら 0.9.x 系のコードです。本記事は 0.11以降の新API を使います。バージョンは Maven Central で最新版を確認してください。
application.properties:JWT秘密鍵・有効期限の外部化
まず設定値を外部化しておきます。秘密鍵は 本番環境では環境変数やVaultで管理 し、application.propertiesに直書きしないようにしましょう(詳細は後述)。
# JWT設定
# jwt.secretは32文字(256ビット)以上のランダムな文字列を設定してください
# 生成例: openssl rand -hex 32
jwt.secret=please-change-this-secret-key-in-production-environment-32chars
jwt.expiration=86400000
# 86400000ms = 24時間
単位に注意:
jwt.expirationは ミリ秒 で指定します。秒と間違えると1000倍短いトークンが発行されます(よくある罠です)。
JwtUtil:トークン生成・クレーム抽出・有効期限検証
JWT操作をすべて一か所にまとめたユーティリティクラスを実装します。
package com.example.demo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.function.Function;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
// 秘密鍵を生成(application.propertiesの値をUTF-8バイト列としてHMAC-SHA256鍵に変換)
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// トークン生成
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
// ユーザー名の抽出
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// 有効期限の抽出
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
// 汎用クレーム抽出
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
// 署名検証を行いながらClaimsを取得する。不正なトークンはここで例外になる
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// 有効期限チェック
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// トークン検証(署名検証 + 有効期限チェック + ユーザー名一致確認)
// 注意: extractAllClaims内でExpiredJwtException等がスローされる場合があります。
// 呼び出し元のJwtAuthenticationFilterでtry-catchしてください。
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
}
例外の扱い方針:
| 例外 | 意味 | 対処 |
|---|---|---|
ExpiredJwtException | 有効期限切れ | 401を返す |
MalformedJwtException | トークン形式不正 | 401を返す |
SignatureException | 署名不正(改ざん) | 401を返す |
UnsupportedJwtException | 非対応のJWT形式 | 401を返す |
これらの例外は後述の JwtAuthenticationFilter でキャッチします。
UserDetailsServiceの実装
Spring SecurityがユーザーをロードするためのServiceを用意します。
注意: 以下のインメモリ実装(
UserDetailsConfig)とDB連携実装(UserDetailsServiceImpl)は どちらか一方のみ プロジェクトに定義してください。両方定義するとUserDetailsServiceのBean定義が競合してアプリが起動しません。インメモリからDB連携に移行する手順:
UserDetailsConfigクラスの@Configurationアノテーションを削除するか、クラスごと削除するUserDetailsServiceImplの@Serviceアノテーションを有効にする(下記コードでは既に付与済み)PasswordEncoderの@Bean定義をSecurityConfig等に移動する
インメモリ実装(サンプル・検証用)
package com.example.demo.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserDetailsConfig {
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(
User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build()
);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
DB連携への拡張(実用パターン)
本番環境ではDBからユーザーをロードします。インターフェースの実装は以下のパターンが基本です。
package com.example.demo.security;
import com.example.demo.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.map(user -> User.builder()
.username(user.getUsername())
.password(user.getPassword()) // BCryptでエンコード済みの値
.roles(user.getRole())
.build()
)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
getPassword() は BCryptでエンコード済みのハッシュ値 を返す必要があります。平文パスワードをそのまま返すと認証が常に失敗するので注意してください。
LoginRequest / AuthenticationResponse DTOクラス
package com.example.demo.auth;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
package com.example.demo.auth;
public record AuthenticationResponse(String token) {}
バリデーションアノテーションの詳細は Spring Bootで@Validを使ってバリデーションする方法 を参照してください。
/api/auth/login エンドポイントの実装
package com.example.demo.auth;
import com.example.demo.security.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(@Valid @RequestBody LoginRequest request) {
try {
// Spring Securityに認証を委譲(失敗時はBadCredentialsExceptionがスロー)
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).build();
}
// 認証成功 → JWTを生成して返す
UserDetails userDetails = userDetailsService.loadUserByUsername(request.username());
String token = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(new AuthenticationResponse(token));
}
}
ポイント: AuthenticationManager.authenticate() はSpring Securityの認証メカニズム全体を呼び出します。パスワードのBCrypt検証やUserDetailsServiceの呼び出しを自動で行ってくれるため、自前で比較処理を書く必要はありません。
エラーハンドリングの設計については Spring BootのREST APIで例外処理を統一する方法 も参考にしてください。
JwtAuthenticationFilter の実装(OncePerRequestFilter)
すべてのリクエストで 1度だけ 実行されるFilterを実装します。これがJWT認証の中核です。
注意: このクラスには
@Componentを付けないでください。@Componentを付けるとSpring Bootがサーブレットフィルターとして自動登録してしまい、SecurityFilterChainへのaddFilterBefore登録と合わせてフィルターが 二重登録 されます。SecurityConfig内で@Beanとして定義することで、セキュリティチェーン専用のフィルターとして正しく機能します。
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// Authorizationヘッダーが存在しない、またはBearerトークンでない場合はスキップ
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7); // "Bearer "の7文字を除去
final String username;
try {
username = jwtUtil.extractUsername(token);
} catch (Exception e) {
// トークン形式不正・署名エラーなど → 401を返してFilterChainを止める
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
return;
}
// ユーザー名が取得でき、かつまだ認証されていない場合のみ処理を続ける
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
// 認証オブジェクトを生成してSecurityContextにセット
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
// 有効期限切れなど検証失敗
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT token expired or invalid");
return;
}
}
filterChain.doFilter(request, response);
}
}
OncePerRequestFilter を使う理由: サーブレットのFilterはリクエストの転送(forward)などで複数回呼ばれる場合があります。OncePerRequestFilter はリクエストごとに 確実に1回だけ 実行されることを保証します。
SecurityFilterChainの設定
package com.example.demo.security;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
// JwtAuthenticationFilterをBeanとして定義(@Componentは付けない)
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ステートレスAPIではCSRFトークンは不要(セッションを使わないため)
.csrf(csrf -> csrf.disable())
// セッションを一切生成・使用しない
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 未認証リクエストに対して401を返す
.exceptionHandling(e -> e.authenticationEntryPoint(
(req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
))
// エンドポイントごとのアクセス制御
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // ログインは認証不要
.anyRequest().authenticated() // それ以外はすべて認証必須
)
// JwtAuthenticationFilterをUsernamePasswordAuthenticationFilterの前に挿入
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// AuthenticationManagerをBeanとして公開(AuthenticationControllerで使用)
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config
) throws Exception {
return config.getAuthenticationManager();
}
}
addFilterBefore の位置について: UsernamePasswordAuthenticationFilter の 前に 挿入することで、Spring Securityがフォーム認証を試みる前にJWT検証が走ります。JWTが有効であればSecurityContextに認証情報がセットされるため、以降のFilterは認証済みとして扱います。
csrf().disable() の根拠: CSRFは主に ** ブラウザのセッションCookie ** を悪用した攻撃です。ステートレスなJWT認証ではCookieではなくAuthorizationヘッダーでトークンを送るため、CSRF攻撃が成立しません。ただし ** HTTPS化は必須** です(後述)。
動作確認:curl / Postman でエンドツーエンドテスト
アプリを起動したら、以下の手順で動作を確認しましょう。
1. ログインしてJWTを取得
curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "user", "password": "password"}'
レスポンス例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNzA2MDAwMDAwLCJleHAiOjE3MDYwODY0MDB9.xxxxx"
}
2. 取得したトークンで保護エンドポイントにアクセス
# TOKEN変数にレスポンスのtokenをセット
TOKEN="eyJhbGciOiJIUzI1NiJ9..."
curl -s http://localhost:8080/api/hello \
-H "Authorization: Bearer $TOKEN"
3. 異常系の確認
Spring Security は未認証リクエストに対して 401 Unauthorized を返します。exceptionHandling の authenticationEntryPoint で明示的に設定しているため、以下のいずれのケースでも 401 が返ります。
# トークンなし → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello
# 出力例: 401
# 改ざんトークン → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer invalidtoken"
# 出力例: 401
# 期限切れトークン → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer <expired-token>"
# 出力例: 401
Postmanでの効率的なテスト
Postmanの Environment Variables に token 変数を定義し、ログインリクエストの Tests タブに以下を設定すると、以降のリクエストに自動でトークンが付与されます。
const response = pm.response.json();
pm.environment.set("token", response.token);
その後のリクエストのAuthorizationタブで Bearer {{token}} と指定するだけです。
まとめ
本記事では、Spring Boot REST APIにJWT認証を組み込む手順を解説しました。
| ステップ | 実装内容 |
|---|---|
| 1 | jjwtの依存関係追加 |
| 2 | application.propertiesに秘密鍵・有効期限を外部化 |
| 3 | JwtUtil でトークン生成・検証ロジックを実装 |
| 4 | UserDetailsService でユーザーロードの土台を整備 |
| 5 | /api/auth/login でJWT発行エンドポイントを実装 |
| 6 | JwtAuthenticationFilter でリクエストごとのトークン検証を実装 |
| 7 | SecurityFilterChain でSTATELESS設定・Filter挿入・認可ルールを設定 |
| 8 | curl / Postmanでエンドツーエンドテストを実施 |
Basic認証・フォーム認証からJWT認証へのステップアップは、REST APIの設計として自然な進化です。ぜひ自分のプロジェクトに組み込んでみてください。