> **SSO Cookie/JWT認証シリーズ** · [インデックス](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-series-index) · 現在:Spring Boot編 · [Django編](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-django)
概要 — Spring Boot認証アーキテクチャ
Spring Securityは**サーブレットフィルターチェーン(Servlet Filter Chain)**の上にセキュリティロジックを積み重ねる構造です。HTTPリクエストがコントローラーに到達する前に複数のセキュリティフィルターを通過し、認証(Authentication)と認可(Authorization)が行われます。
HTTPリクエスト
|
DelegatingFilterProxy
|
FilterChainProxy
|
SecurityFilterChain
|-- CorsFilter
|-- CsrfFilter
|-- UsernamePasswordAuthenticationFilter (セッションベース)
|-- JwtAuthenticationFilter (カスタムJWTフィルター)
|-- ExceptionTranslationFilter
+-- AuthorizationFilter
|
DispatcherServlet -> Controller
コアフローは以下の通りです。
1. **SecurityFilterChain** — どのリクエストにどのフィルターを適用するか決定
2. **AuthenticationManager** — 認証処理の委譲を受けるマネージャー
3. **AuthenticationProvider** — 実際の認証ロジック(DB検索、パスワード検証など)
4. **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日が一般的です。
参考資料
1. [Spring Security Reference — Architecture](https://docs.spring.io/spring-security/reference/servlet/architecture.html) — フィルターチェーンアーキテクチャ
2. [Spring Security Reference — Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/index.html) — 認証メカニズム
3. [Spring Security Reference — Authorization](https://docs.spring.io/spring-security/reference/servlet/authorization/index.html) — 認可メカニズム
4. [jjwt GitHub](https://github.com/jwtk/jjwt) — Java JWTライブラリ
5. [RFC 7519 — JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) — JWT標準仕様
6. [RFC 6749 — OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0フレームワーク
7. [OWASP — Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — セッション管理セキュリティ
8. [OWASP — JWT Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) — JWTセキュリティ
9. [OWASP — Cross-Site Request Forgery Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) — CSRF防御
10. [MDN — Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) — Cookieヘッダー仕様
11. [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) — SameSite属性
12. [Spring Boot + Redis Session](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — Redisベースセッション共有
13. [Baeldung — Spring Security JWT](https://www.baeldung.com/spring-security-oauth-jwt) — JWT認証チュートリアル
현재 단락 (1/636)
Spring Securityは**サーブレットフィルターチェーン(Servlet Filter Chain)**の上にセキュリティロジックを積み重ねる構造です。HTTPリクエストがコントローラーに到...