Skip to content

필사 모드: Spring Boot認証実践ガイド — セッション、JWT、SecurityContext、Cookie基盤認証完全攻略

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> **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リクエストがコントローラーに到...

작성 글자: 0원문 글자: 22,905작성 단락: 0/636