Skip to content
Published on

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

Authors

SSO Cookie/JWT認証シリーズ · インデックス · 現在:Spring Boot編 · 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 JWTHttpOnly 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なので、同じスレッド内では安全です。しかし、@AsyncCompletableFuture、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 — フィルターチェーンアーキテクチャ
  2. Spring Security Reference — Authentication — 認証メカニズム
  3. Spring Security Reference — Authorization — 認可メカニズム
  4. jjwt GitHub — Java JWTライブラリ
  5. RFC 7519 — JSON Web Token — JWT標準仕様
  6. RFC 6749 — OAuth 2.0 — OAuth 2.0フレームワーク
  7. OWASP — Session Management Cheat Sheet — セッション管理セキュリティ
  8. OWASP — JWT Security Cheat Sheet — JWTセキュリティ
  9. OWASP — Cross-Site Request Forgery Prevention — CSRF防御
  10. MDN — Set-Cookie — Cookieヘッダー仕様
  11. MDN — SameSite cookies — SameSite属性
  12. Spring Boot + Redis Session — Redisベースセッション共有
  13. Baeldung — Spring Security JWT — JWT認証チュートリアル