- Published on
Spring Boot認証実践ガイド — セッション、JWT、SecurityContext、Cookie基盤認証完全攻略
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 概要 — Spring Boot認証アーキテクチャ
- セッションベース認証
- JWTベース認証
- Cookie設定の実践
- Claimパースとユーザー情報アクセス
- ブラウザストレージ別アクセス可能性表
- CORS + Credential送信設定
- ログアウトとトークン無効化
- トークンリフレッシュ戦略
- セキュリティトレードオフ
- チェックリスト
- よくあるバグと誤解
- 参考資料
概要 — Spring Boot認証アーキテクチャ
Spring Securityは**サーブレットフィルターチェーン(Servlet Filter Chain)**の上にセキュリティロジックを積み重ねる構造です。HTTPリクエストがコントローラーに到達する前に複数のセキュリティフィルターを通過し、認証(Authentication)と認可(Authorization)が行われます。
HTTPリクエスト
|
DelegatingFilterProxy
|
FilterChainProxy
|
SecurityFilterChain
|-- CorsFilter
|-- CsrfFilter
|-- UsernamePasswordAuthenticationFilter (セッションベース)
|-- JwtAuthenticationFilter (カスタムJWTフィルター)
|-- ExceptionTranslationFilter
+-- AuthorizationFilter
|
DispatcherServlet -> Controller
コアフローは以下の通りです。
- SecurityFilterChain — どのリクエストにどのフィルターを適用するか決定
- AuthenticationManager — 認証処理の委譲を受けるマネージャー
- AuthenticationProvider — 実際の認証ロジック(DB検索、パスワード検証など)
- SecurityContext — 認証済みユーザー情報をスレッドローカルに保存
// 基本構造の理解
SecurityContextHolder
+-- SecurityContext
+-- Authentication
|-- Principal (ユーザー情報)
|-- Credentials (パスワード/トークン)
+-- Authorities (権限リスト)
セッションベース認証
最も伝統的な方式です。サーバーがHttpSessionを生成し、クライアントにJSESSIONID Cookieを発行します。
ログインハンドラー実装
@RestController
@RequestMapping("/api/auth")
public class SessionAuthController {
private final AuthenticationManager authenticationManager;
public SessionAuthController(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
// 1. 認証試行
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 2. SecurityContextに保存
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. セッション生成(JSESSIONID Cookie自動発行)
HttpSession session = httpRequest.getSession(true);
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return ResponseEntity.ok(Map.of(
"username", userDetails.getUsername(),
"roles", userDetails.getAuthorities()
));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); // セッション無効化
}
SecurityContextHolder.clearContext();
return ResponseEntity.ok(Map.of("message", "ログアウト成功"));
}
}
SecurityConfig(セッションベース)
@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // 同時セッション制限
.maxSessionsPreventsLogin(false) // 既存セッション期限切れ
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
セッション方式は実装が簡単ですが、サーバースケールアウト時にセッション共有の問題が発生します。RedisベースのSpring Sessionで解決できますが、複雑度が上がります。
JWTベース認証
JWTトークン生成
io.jsonwebtoken(jjwt)ライブラリを使用します。build.gradleに依存関係を追加します。
// build.gradle
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration; // 例: 900000(15分)
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration; // 例: 604800000(7日)
private SecretKey key;
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/** Access Token生成 */
public String generateAccessToken(UserDetails userDetails, String tenantId) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
claims.put("tenant_id", tenantId);
claims.put("token_type", "access");
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(key)
.compact();
}
/** Refresh Token生成 */
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("token_type", "refresh")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
.signWith(key)
.compact();
}
/** トークンからClaims抽出 */
public Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
/** トークンからusername抽出 */
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
/** トークン有効性検証 */
public boolean isTokenValid(String token, UserDetails userDetails) {
try {
Claims claims = extractAllClaims(token);
String username = claims.getSubject();
Date expiration = claims.getExpiration();
return username.equals(userDetails.getUsername())
&& expiration.after(new Date());
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
JWTフィルター実装
OncePerRequestFilterを継承してリクエストごとに一度JWTを検証します。AuthorizationヘッダーとHttpOnly Cookieの両方をサポートします。
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
private final TokenBlacklistService tokenBlacklistService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
UserDetailsService userDetailsService,
TokenBlacklistService tokenBlacklistService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
this.tokenBlacklistService = tokenBlacklistService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && !tokenBlacklistService.isBlacklisted(token)) {
try {
String username = jwtTokenProvider.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails =
userDetailsService.loadUserByUsername(username);
if (jwtTokenProvider.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request)
);
SecurityContextHolder.getContext()
.setAuthentication(authToken);
}
}
} catch (JwtException e) {
// 無効なトークン — 認証なしで次のフィルターへ
logger.warn("JWT検証失敗: " + e.getMessage());
}
}
filterChain.doFilter(request, response);
}
/**
* トークン抽出:Authorizationヘッダー優先、なければCookieから読み取り
*/
private String resolveToken(HttpServletRequest request) {
// 1) Authorization: Bearer <token>
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
// 2) HttpOnly Cookie: access_token=<token>
if (request.getCookies() != null) {
return Arrays.stream(request.getCookies())
.filter(c -> "access_token".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}
return null;
}
}
SecurityFilterChain設定(JWT)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize、@Secured有効化
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
AuthenticationProvider authenticationProvider) {
this.jwtAuthFilter = jwtAuthFilter;
this.authenticationProvider = authenticationProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF: JWT + SameSite Cookie使用時に無効化可能
.csrf(AbstractHttpConfigurer::disable)
// CORS設定
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// セッションを使用しない(STATELESS)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// URL別認可ルール
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/public/**",
"/actuator/health"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/tenant/**").hasAnyRole("ADMIN", "TENANT_ADMIN")
.anyRequest().authenticated()
)
// AuthenticationProvider登録
.authenticationProvider(authenticationProvider)
// JWTフィルターをUsernamePasswordAuthenticationFilterの前に配置
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class)
// 認証失敗ハンドリング
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"認証が必要です\",\"status\":401}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"アクセス権限がありません\",\"status\":403}");
})
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // Cookie送信許可
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Cookie設定の実践
JWTをCookieに入れて送信すると、フロントエンドでトークンを管理する必要がありません。HttpOnly + Secure + SameSiteの組み合わせが鍵です。
Cookie生成ユーティリティ
@Component
public class CookieUtil {
@Value("${app.cookie.domain}")
private String cookieDomain; // 例: ".example.com"
@Value("${app.cookie.secure}")
private boolean secure; // 本番: true、ローカル: false
/**
* Access Token Cookie生成
*/
public ResponseCookie createAccessTokenCookie(String token) {
return ResponseCookie.from("access_token", token)
.httpOnly(true) // JSアクセス遮断(XSS防御)
.secure(secure) // HTTPSでのみ送信
.sameSite("Lax") // CSRF基本防御
.domain(cookieDomain) // サブドメイン共有
.path("/") // すべてのパス
.maxAge(Duration.ofMinutes(15))
.build();
}
/**
* Refresh Token Cookie生成
*/
public ResponseCookie createRefreshTokenCookie(String token) {
return ResponseCookie.from("refresh_token", token)
.httpOnly(true)
.secure(secure)
.sameSite("Strict") // Refreshはより厳格に
.domain(cookieDomain)
.path("/api/auth/refresh") // refreshエンドポイントでのみ送信
.maxAge(Duration.ofDays(7))
.build();
}
/**
* Cookie削除(maxAge=0)
*/
public ResponseCookie deleteAccessTokenCookie() {
return ResponseCookie.from("access_token", "")
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.domain(cookieDomain)
.path("/")
.maxAge(0) // 即座に削除
.build();
}
public ResponseCookie deleteRefreshTokenCookie() {
return ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(secure)
.sameSite("Strict")
.domain(cookieDomain)
.path("/api/auth/refresh")
.maxAge(0)
.build();
}
}
ログインレスポンスでのCookie設定
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtTokenProvider jwtTokenProvider;
private final CookieUtil cookieUtil;
// コンストラクター省略
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
UserDetails userDetails = (UserDetails) auth.getPrincipal();
String tenantId = ((CustomUserDetails) userDetails).getTenantId();
String accessToken = jwtTokenProvider.generateAccessToken(userDetails, tenantId);
String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
ResponseCookie accessCookie = cookieUtil.createAccessTokenCookie(accessToken);
ResponseCookie refreshCookie = cookieUtil.createRefreshTokenCookie(refreshToken);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessCookie.toString())
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"message", "ログイン成功",
"username", userDetails.getUsername(),
"roles", userDetails.getAuthorities()
));
}
}
Claimパースとユーザー情報アクセス
Custom UserDetails
@Getter
public class CustomUserDetails implements UserDetails {
private final Long id;
private final String username;
private final String password;
private final String tenantId;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.password = user.getPassword();
this.tenantId = user.getTenantId();
this.authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.collect(Collectors.toList());
}
}
コントローラーでのユーザー情報アクセス
@RestController
@RequestMapping("/api/users")
public class UserController {
// 方法1:SecurityContextHolder直接使用
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"tenantId", user.getTenantId(),
"roles", user.getAuthorities()
));
}
// 方法2:@AuthenticationPrincipalアノテーション(推奨)
@GetMapping("/profile")
public ResponseEntity<?> getProfile(
@AuthenticationPrincipal CustomUserDetails user) {
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"tenantId", user.getTenantId()
));
}
// 方法3:JWT Claimsから直接抽出
@GetMapping("/claims")
public ResponseEntity<?> getClaimsInfo(HttpServletRequest request) {
String token = extractTokenFromCookie(request);
Claims claims = jwtTokenProvider.extractAllClaims(token);
return ResponseEntity.ok(Map.of(
"sub", claims.getSubject(),
"roles", claims.get("roles"),
"tenant_id", claims.get("tenant_id"),
"exp", claims.getExpiration(),
"iat", claims.getIssuedAt()
));
}
// 方法4:@PreAuthorizeでメソッドレベルの認可
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/all")
public ResponseEntity<?> getAllUsers() {
// ADMINのみアクセス可能
return ResponseEntity.ok(userService.findAll());
}
@PreAuthorize("#userId == authentication.principal.id")
@PutMapping("/{userId}")
public ResponseEntity<?> updateUser(@PathVariable Long userId,
@RequestBody UpdateRequest req) {
// 自分自身のみ修正可能
return ResponseEntity.ok(userService.update(userId, req));
}
}
ブラウザストレージ別アクセス可能性表
| ストレージ | JSアクセス | サーバー自動送信 | XSS脆弱 | CSRF脆弱 |
|---|---|---|---|---|
| localStorage | 可能 | なし | あり(危険) | なし |
| sessionStorage | 可能 | なし | あり(危険) | なし |
| 通常Cookie | 可能 | あり(同一ドメイン) | あり | あり(危険) |
| HttpOnly Cookie | 不可(安全) | あり(同一ドメイン) | 不可(安全) | あり(SameSiteで緩和) |
| HttpOnly + SameSite=Strict | 不可 | 同一サイトのみ | 不可 | なし(安全) |
| HttpOnly + SameSite=Lax | 不可 | GETクロスサイト許可 | 不可 | ほぼ安全 |
推奨組み合わせ:HttpOnly + Secure + SameSite=Lax(Access Token)、HttpOnly + Secure + SameSite=Strict(Refresh Token)
CORS + Credential送信設定
フロントエンドとバックエンドのドメインが異なる場合、Cookieを送信するにはCORS + credentials設定が必須です。
Spring Boot CORS設定
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// allowedOriginsに*は使用不可(credentials: trueの場合)
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "X-Requested-With", "Accept"));
config.setExposedHeaders(List.of("Set-Cookie"));
config.setAllowCredentials(true); // 必ずtrue
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
フロントエンド(fetch / axios)
// fetch
const res = await fetch('https://api.example.com/api/users/me', {
method: 'GET',
credentials: 'include', // Cookie送信必須
})
// axios全体設定
axios.defaults.withCredentials = true
注意:
allowCredentials(true)使用時、allowedOriginsに"*"ワイルドカードは使用できません。必ず明示的にドメインを列挙する必要があります。
ログアウトとトークン無効化
JWTはサーバーで状態を保存しないため、「無効化」が困難です。Redisベースのブラックリストが最も実用的な方法です。
Redisブラックリストサービス
@Service
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* トークンをブラックリストに追加(残り有効期限分のTTLを設定)
*/
public void blacklist(String token, long remainingMillis) {
redisTemplate.opsForValue().set(
"blacklist:" + token,
"revoked",
Duration.ofMillis(remainingMillis)
);
}
/**
* トークンがブラックリストにあるか確認
*/
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(
redisTemplate.hasKey("blacklist:" + token));
}
}
ログアウトエンドポイント
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
String token = resolveToken(request);
if (token != null) {
try {
Claims claims = jwtTokenProvider.extractAllClaims(token);
long remainingMillis =
claims.getExpiration().getTime() - System.currentTimeMillis();
if (remainingMillis > 0) {
tokenBlacklistService.blacklist(token, remainingMillis);
}
} catch (JwtException ignored) {
// すでに期限切れのトークンはブラックリストに追加不要
}
}
// Cookie削除
ResponseCookie deleteAccess = cookieUtil.deleteAccessTokenCookie();
ResponseCookie deleteRefresh = cookieUtil.deleteRefreshTokenCookie();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, deleteAccess.toString())
.header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())
.body(Map.of("message", "ログアウト成功"));
}
トークンリフレッシュ戦略
Access Tokenは短く(15分)、Refresh Tokenは長く(7日)設定し、Refresh Tokenで新しいAccess Tokenを発行します。Refresh Token Rotationを適用すれば、窃取されたRefresh Tokenも一度しか使用できません。
Refreshエンドポイント
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
// 1. CookieからRefresh Tokenを抽出
String refreshToken = Arrays.stream(
Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
.filter(c -> "refresh_token".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new AuthException("Refresh Tokenがありません"));
// 2. Refresh Token検証
Claims claims;
try {
claims = jwtTokenProvider.extractAllClaims(refreshToken);
} catch (JwtException e) {
throw new AuthException("無効なRefresh Tokenです");
}
if (!"refresh".equals(claims.get("token_type"))) {
throw new AuthException("不正なトークンタイプです");
}
// 3. ブラックリスト確認
if (tokenBlacklistService.isBlacklisted(refreshToken)) {
throw new AuthException("すでに使用済みのRefresh Tokenです(Rotation違反検知)");
}
// 4. ユーザー情報ロード
String username = claims.getSubject();
CustomUserDetails userDetails =
(CustomUserDetails) userDetailsService.loadUserByUsername(username);
// 5. 既存のRefresh Tokenをブラックリスト処理(Rotation)
long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
tokenBlacklistService.blacklist(refreshToken, remaining);
// 6. 新しいトークンペア発行
String newAccessToken = jwtTokenProvider.generateAccessToken(
userDetails, userDetails.getTenantId());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
// 7. 新しいCookie設定
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE,
cookieUtil.createAccessTokenCookie(newAccessToken).toString())
.header(HttpHeaders.SET_COOKIE,
cookieUtil.createRefreshTokenCookie(newRefreshToken).toString())
.body(Map.of("message", "トークン更新成功"));
}
Rotation違反検知:すでにブラックリストにあるRefresh Tokenが使用された場合、攻撃の試みとみなし、そのユーザーのすべてのトークンを無効化する防御ロジックを追加できます。
セキュリティトレードオフ
| 脅威 | localStorage JWT | HttpOnly Cookie JWT | セッション |
|---|---|---|---|
| XSS | 即座に窃取可能 | JSアクセス不可(安全) | セッションIDアクセス不可 |
| CSRF | 該当なし | SameSite + CORSで防御 | CSRFトークン必要 |
| トークン窃取 | XSSで容易に窃取 | ネットワークレベルでのみ可能 | セッションID漏洩時危険 |
| リプレイ攻撃 | 短い有効期限で緩和 | 短い有効期限 + ブラックリスト | サーバーでセッション無効化 |
| スケーラビリティ | 水平スケール容易 | 水平スケール容易 | Redis Session必要 |
| サーバー負荷 | なし | ブラックリスト検索 | セッションストレージ |
結論:ほとんどの場合、HttpOnly Cookie + SameSite=Lax + Secure + 短い有効期限 + Refresh Token Rotationが最も実用的な選択です。
チェックリスト
Spring Boot JWT認証を実装する際に以下の項目を確認してください。
- JWT Secret Keyを十分な長さに設定したか(256ビット以上)
- Secret Keyを環境変数/Vaultからロードしているか(コードにハードコーディングしていないか)
- Access Tokenの有効期限が十分に短いか(15分以下推奨)
- Refresh Token Rotationを適用したか
- HttpOnly + Secure + SameSite Cookie設定をしたか
- CORS allowCredentials(true)と明示的Originを設定したか
- SecurityFilterChainでSTATELESSセッションポリシーを設定したか
- JWTフィルターで例外発生時に安全に処理しているか(500エラー防止)
- ログアウト時にCookie削除 + トークンブラックリストを実装したか
- Tokenブラックリストの TTLをトークン有効期限と一致させたか
- @PreAuthorizeまたはURLベースの認可ルールを設定したか
- CSRF設定がAPI特性に合わせて構成されているか
- ExceptionHandling(authenticationEntryPoint、accessDeniedHandler)を設定したか
- Refreshエンドポイントがrefresh Tokenのみ受け入れているか(token_type検証)
- 機密性の高いClaim(パスワードなど)をJWTに含めていないか
よくあるバグと誤解
1. 「CSRFを無条件にdisableしても良い」
JWTをAuthorizationヘッダーで送信する場合、CSRF攻撃は不可能です(ブラウザが自動添付しない)。しかし、CookieにJWTを入れる場合、SameSite設定なしにCSRFを無効にすると脆弱になります。
2. 「JWTに機密情報を入れても暗号化されているから安全」
JWTは**署名(Signed)であり暗号化(Encrypted)**ではありません。Base64デコードするだけで誰でもPayloadを見ることができます。パスワード、マイナンバーなどの機密情報は絶対に入れないでください。
3. 「SecurityContextHolderは常にスレッドセーフ」
デフォルト戦略はThreadLocalなので、同じスレッド内では安全です。しかし、@Async、CompletableFuture、WebFluxなど別のスレッドで実行されるコードではSecurityContextが伝播されません。SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)またはDelegatingSecurityContextExecutorを使用する必要があります。
4. 「OncePerRequestFilterなのになぜ2回実行されるのか」
サーブレットフォワーディング(RequestDispatcher.forward())やエラーディスパッチ時にフィルターが再実行される可能性があります。shouldNotFilter()メソッドをオーバーライドするか、shouldNotFilterErrorDispatch()を確認してください。
5. 「allowedOriginsに*を入れたのにCookieが送信されない」
allowCredentials(true)とallowedOrigins("*")は一緒に使用できません。明示的にドメインを列挙するか、allowedOriginPatterns("*")を使用してください(セキュリティ上非推奨)。
6. 「Refresh TokenをlocalStorageに保存しても良いか」
Refresh TokenはAccess Tokenより有効期限が長く、新しいAccess Tokenを発行できる機密性の高いトークンです。localStorageはXSSに脆弱なため、必ずHttpOnly Cookieに保存すべきです。
7. 「JWTの有効期限を1年に設定した」
Access Tokenの有効期限が長いと、窃取時の被害が大きくなります。Access Tokenは15分以下、Refresh Tokenは7〜30日が一般的です。
参考資料
- Spring Security Reference — Architecture — フィルターチェーンアーキテクチャ
- Spring Security Reference — Authentication — 認証メカニズム
- Spring Security Reference — Authorization — 認可メカニズム
- jjwt GitHub — Java JWTライブラリ
- RFC 7519 — JSON Web Token — JWT標準仕様
- RFC 6749 — OAuth 2.0 — OAuth 2.0フレームワーク
- OWASP — Session Management Cheat Sheet — セッション管理セキュリティ
- OWASP — JWT Security Cheat Sheet — JWTセキュリティ
- OWASP — Cross-Site Request Forgery Prevention — CSRF防御
- MDN — Set-Cookie — Cookieヘッダー仕様
- MDN — SameSite cookies — SameSite属性
- Spring Boot + Redis Session — Redisベースセッション共有
- Baeldung — Spring Security JWT — JWT認証チュートリアル