403 Forbiddenのデッドロック
深夜2時。認証ロジックは完璧に見え、JWTは有効で、ユーザーは間違いなくログインしています。それなのに、コンソールはこのスタックトレースで溢れかえっています:
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73)
at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:232)
フロントエンドには、冷淡な 403 Forbidden が表示されています。このエラーは、Spring Securityが「君が誰かはわかっているが、このドアを開けるための正しい鍵を持っていない」と言っているようなものです。AuthenticationException がログイン失敗を意味するのに対し、AccessDeniedException は認可レベルが不十分であることを意味します。
根本原因1:「ROLE_」プレフィックスの不一致
Spring Securityはロールの命名規則にこだわりがあります。よくある悩みの種は、データベースには ADMIN のようにロールを保存しているのに、セキュリティフレームワークが特定のプレフィックスを期待している場合に発生します。
デフォルトでは、hasRole('ADMIN') 式は ROLE_ADMIN という名前の権限(Authority)をチェックします。データベースやJWTのクレームが単に ADMIN を提供している場合、チェックは静かに失敗します。
解決策:権限の標準化
ユーザー権限をロードする際、ソースデータにプレフィックスが欠けている場合は、手動で追加するようにしてください:
// UserDetailsService内
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
authorities
);
プレフィックスをスキップしたい場合は、hasAuthority('ADMIN') を使用してください。これは内部で ROLE_ を付与することなく、文字列をそのまま照合します。
根本原因2:メソッドセキュリティが休止状態
@PreAuthorize や @Secured アノテーションが無視されていませんか?パブリックなエンドポイントでさえアクセス拒否が発生することがあります。それは、これらのアノテーションを処理するロジックが起動していないためかもしれません。
解決策:メソッドセキュリティの有効化
Spring Boot 3.xでは、設定クラスで @EnableMethodSecurity を使用して、これらのチェックを明示的に有効にする必要があります:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorizeを動作させるために必須です
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// ここに設定ロジックを記述
return http.build();
}
}
ヒント:Spring Boot 2.xの場合は、代わりに @EnableGlobalMethodSecurity(prePostEnabled = true) を使用してください。
根本原因3:CSRFによる状態変更リクエストのブロック
GETリクエストは成功するのに、POST、PUT、DELETEの呼び出しが403で失敗する場合、隠れた障害物は通常CSRF(クロスサイトリクエストフォージェリ)保護です。Spring Securityは、セッションベースのアプリを保護するために、デフォルトでこれを有効にしています。
解決策:アーキテクチャに合わせたCSRFの設定
JWTを使用したステートレスなREST APIの場合、通常はCSRFを無効にするべきです。セッション管理にブラウザのクッキーを使用していないため、リスクは軽減されます:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // ステートレスなJWTベースのAPIには安全
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
セッションを使用している場合は、無効にしないでください。代わりに、フロントエンドが状態を変更するすべてのリクエストで X-XSRF-TOKEN ヘッダーを送信するようにしてください。
根本原因4:「シャドウイング」フィルタチェーン
Spring Securityは、マッチャーを上から下の順番で線形に処理します。広範囲なルールが上部にあると、その下にあるより具体的なルールを「シャドウ(隠蔽)」してしまいます。
// 誤った設定
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").authenticated() // これがすべてのリクエストを飲み込んでしまいます!
.requestMatchers("/api/admin/**").hasRole("ADMIN") // このコードには到達しません
)
解決策:具体性の高い順に並べる
フィルタチェーンを漏斗(ファンネル)のように考えてください。最も限定的なパスを一番上に、キャッチオール(すべてに一致する)ルールを一番下に配置します:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated() // キャッチオールは最後に記述
)
修正の検証方法
推測はやめましょう。application.properties でデバッグログを有効にして、リクエストが拒否された正確な理由を確認します:
logging.level.org.springframework.security=DEBUG
ログ内の AuthorizationFilter の出力を探してください。次のような比較が表示されます:
DEBUG o.s.s.w.a.i.FilterSecurityInterceptor - Secure object: URL: /api/admin/users; Attributes: [hasRole('ROLE_ADMIN')]
ログにユーザーが [ADMIN] を持っていると表示されているのに、インターセプターが [ROLE_ADMIN] を要求している場合、不一致の原因が見つかったことになります。
クイック防止チェックリスト
- 開発中は `DEBUG` ログを有効にして、すぐに状況を把握できるようにする。
- 早期に標準化する:プロジェクト全体で `hasRole`(プレフィックスあり)または `hasAuthority`(生の値)のどちらかに統一する。
- JWTを使用する場合は、`JwtAuthenticationConverter` がクレームを正しい権限形式にマッピングしているか確認する。
- カスタムの `AccessDeniedHandler` が、誤って403エラーを401や500として隠蔽していないか確認する。

