- Spring
Spring Securityで403になる原因|POSTだけ失敗する時
GET は通るのに POST・PUT・DELETE だけ 403 になるときは、ほとんどが CSRF(クロスサイトリクエストフォージェリ対策)のトークンが付いていないのが原因です。 Spring Security は既定で、GET・HEAD・TRACE・OPTIONS 以外のリクエストに CSRF トークンを求めます。
原因を一番早く確かめる方法は、次の1行でログを出すことです。 Spring Security は、拒否した理由をレスポンスには書かず、ログにだけ書きます。
logging.level.org.springframework.security=TRACE
| ログに出る文 | 原因 | 対処 |
|---|---|---|
Invalid CSRF token found for http://… |
CSRF トークンが無い・違う | トークンを送る(SPA は csrf.spa())。Cookie を使わない API なら CSRF を外す |
Pre-authenticated entry point called. Rejecting access |
ログインしていない(401 ではなく 403 になる設定) | 認証方式を設定する。401 にしたいなら入口を変える |
Denying request since did not find matching RequestMatcher |
どの requestMatchers にも当たらない |
規則を足すか anyRequest() を置く |
Checking authorization on POST /api/… using … の後に拒否 |
先に書いた規則に当たった(順番) | 狭い規則を上に書く |
Sending … to access denied handler since access is denied |
ログイン済みだが権限が無い | hasRole と ROLE_ の対応を確かめる |
Securing POST /error の後に拒否 |
本当のエラーが /error で隠れた |
/error か ERROR の転送を許可する |
Invalid CORS request(本文) |
許可していないオリジン | CORS の設定にオリジンを足す |
確認環境:Spring Boot 4.1.1(Spring Security 7.1.1、Spring Framework 7.0.9 は Boot の依存管理の版)。ログの文・例外の文・既定値は、GitHub の Spring Security 7.1.1 タグのソース(CsrfFilter・ExceptionTranslationFilter・Http403ForbiddenEntryPoint・RequestMatcherDelegatingAuthorizationManager・CsrfConfigurer など)で確かめました。設定の書き方とログの出力例は Spring Security 7.1 のリファレンス、7.0 での変更点は「What's New in Spring Security 7.0」に基づきます(確認日 2026年10月13日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Securityで403になる原因|POSTだけ失敗する時」
まず 401 か 403 かを見る
| 状態 | Boot の既定の設定のまま | 自作の設定で認証方式が無い |
|---|---|---|
| ログインしていない | ブラウザはログイン画面へ 302、curl などは 401 | 403 |
| ログイン済み・権限が無い | 403 | 403 |
| CSRF トークンが無い・違う | 403 | 403 |
「Boot の既定の設定のまま」は自分で SecurityFilterChain を作っていない状態、「自作の設定で認証方式が無い」は formLogin・httpBasic などを書いていない状態です。
ログインしていないのに 403 になるのは、認証の入口(AuthenticationEntryPoint)が決まっていないときです。 Spring Security 7.1.1 の ExceptionHandlingConfigurer は、formLogin・httpBasic・oauth2ResourceServer などを1つも設定していないと、Http403ForbiddenEntryPoint を使います。このクラスは次のログを出して 403 を返します。
DEBUG o.s.s.w.a.Http403ForbiddenEntryPoint : Pre-authenticated entry point called. Rejecting access
未ログインを 401 で返したい API では、入口を明示します。
http.exceptionHandling(e -> e
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
原因の切り分け表
| 症状 | まず疑う原因 | この記事の節 |
|---|---|---|
| GET は通り、POST・PUT・DELETE だけ 403 | CSRF | 原因1 |
permitAll() を書いたのに 403 |
規則の順番・HTTP メソッドの指定・パスの書き方 | 原因2 |
| 400・404・500 のはずが 403(または 401) | /error が保護されている |
原因3 |
| ログインしているのに 403 | hasRole と ROLE_ の不一致 |
原因4 |
| URL の規則は合っているのに 403 | @PreAuthorize などのメソッドセキュリティ |
原因5 |
| ブラウザからだけ 403、curl では通る | CORS のプリフライト | 原因6 |
| CSS・JS・画像が 403(またはログイン画面へ) | 静的リソースが保護されている | 原因7 |
2つ目の SecurityFilterChain の規則が効かない |
チェーンの順番 | 原因8 |
TRACE ログで確かめる
# application.yml(調べ終わったら戻す)
logging:
level:
org.springframework.security: TRACE
Spring Security 7.1 のリファレンスには、CSRF トークンを付けずに POST したときのログが載っています。
DEBUG ... o.s.security.web.FilterChainProxy : Securing POST /hello
TRACE ... o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/15)
TRACE ... o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/15)
TRACE ... o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/15)
TRACE ... o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/15)
TRACE ... o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/15)
DEBUG ... o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
DEBUG ... o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
Invoking 〈フィルター名〉 (n/m) は「m 個のうち n 番目のフィルターを通った」という意味です。 最後に呼ばれたフィルターが、拒否した場所です。上の例では5番目の CsrfFilter で止まり、AuthorizationFilter(URL の規則を見るフィルター)まで届いていません。
7.1.1 のソースで、どのフィルターがどの文を出すかをまとめました。
| ロガー(クラス) | レベル | 文 | 意味 |
|---|---|---|---|
FilterChainProxy |
DEBUG | Securing POST /api/orders |
このリクエストの検査を始めた |
FilterChainProxy |
DEBUG | Secured POST /api/orders |
全部通った(ここまで来れば Security は原因ではない) |
CsrfFilter |
DEBUG | Invalid CSRF token found for 〈URL〉 |
CSRF で拒否 |
CsrfFilter |
TRACE | Did not protect against CSRF since request did not match … |
CSRF の検査対象外(GET など) |
RequestMatcherDelegatingAuthorizationManager |
TRACE | Authorizing POST /api/orders |
URL の規則を見始めた |
| 同上 | TRACE | Checking authorization on POST /api/orders using 〈規則〉 |
この規則に当たった |
| 同上 | TRACE | Denying request since did not find matching RequestMatcher |
どの規則にも当たらない |
ExceptionTranslationFilter |
TRACE | Sending 〈認証〉 to authentication entry point since access is denied |
未ログイン扱い(入口へ) |
ExceptionTranslationFilter |
TRACE | Sending 〈認証〉 to access denied handler since access is denied |
ログイン済みで権限が無い |
AccessDeniedHandlerImpl |
DEBUG | Responding with 403 status code |
403 を返した |
Checking authorization on … using 〈規則〉 の 〈規則〉 を見ると、どの requestMatchers に当たったかが分かります。 意図と違う規則なら、原因2の順番の問題です。
TRACE はリクエストごとに十数行出ます。本番では INFO に戻します。ログの設定は関連記事「Spring Bootログ設定|logback-spring.xml全文と設定対応表」で扱っています。
原因1:CSRF(POST だけ 403)
Spring Security 7.1.1 の CsrfFilter は、次のメソッド以外のすべてに CSRF トークンを求めます。
// CsrfFilter.DefaultRequiresCsrfMatcher(7.1.1 のソース)
private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));
permitAll() を付けても CSRF の検査は外れません。 CSRF は CsrfFilter、permitAll() は AuthorizationFilter の担当で、CsrfFilter が先に動くためです。「permitAll() が効かない、しかも POST だけ」というときは、ほぼこれです。
| アプリの形 | 対処 |
|---|---|
| Thymeleaf などのサーバー側のフォーム | th:action を使えばトークンが自動で入る。手書きの <form> なら _csrf の hidden を足す |
| SPA(React・Vue・Angular)で Cookie のセッションを使う | csrf.spa()(下の節) |
| JWT など、Cookie を使わないAPI | CSRF を外す(csrf.disable()) |
| 外部からの Webhook だけ受けたい | その URL だけ外す(ignoringRequestMatchers) |
Cookie を使わない API なら外す
CSRF 攻撃は「ブラウザが Cookie を自動で送る」ことを悪用します。認証を Authorization: Bearer … ヘッダーだけで行い、Cookie を使わない API なら、CSRF の検査を外しても狙われる経路がありません。
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
セッション Cookie でログインする画面があるなら、csrf.disable() にしないようにします。 その場合はトークンを送る形にします。JWT の設定の全文は関連記事「Spring Security JWT認証の実装|Boot 4対応の設定コード全文」で扱っています。
一部の URL だけ外す
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/**"));
HTML フォームで送る
<form method="post" th:action="@{/orders}">
<!-- th:action を使うと、次の hidden が自動で入る -->
<!-- <input type="hidden" name="_csrf" value="…"> -->
<button type="submit">注文する</button>
</form>
パラメータ名の既定は _csrf、ヘッダー名の既定は X-CSRF-TOKEN(セッションに保存する既定の HttpSessionCsrfTokenRepository の場合)です。
SPA から呼ぶときの CSRF(Security 7 の csrf.spa())
Spring Security 7.0 で、SPA 向けの設定が1行になりました。
http.csrf(csrf -> csrf.spa());
7.1.1 のソースでは、spa() は次の2つを設定します。
| 設定 | 中身 | 意味 |
|---|---|---|
| トークンの保存先 | CookieCsrfTokenRepository.withHttpOnlyFalse() |
Cookie XSRF-TOKEN に入れ、JavaScript から読めるようにする |
| トークンの受け取り方 | SpaCsrfTokenRequestHandler(内部クラス) |
ヘッダーで来たら Cookie の値そのまま、フォームで来たら BREACH 対策の符号化された値として照合 |
| 名前 | 既定値(CookieCsrfTokenRepository) |
|---|---|
| Cookie 名 | XSRF-TOKEN |
| ヘッダー名 | X-XSRF-TOKEN |
| パラメータ名 | _csrf |
フロント側は、Cookie XSRF-TOKEN の値を、ヘッダー X-XSRF-TOKEN に入れて送ります。 axios と Angular の HttpClient は、この名前の組み合わせを既定で扱います(同じオリジンへのリクエストのとき)。fetch では自分で付けます。
function csrfToken() {
const m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
await fetch("/api/orders", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-XSRF-TOKEN": csrfToken(),
},
body: JSON.stringify({ itemId: 1, qty: 2 }),
});
ログインとログアウトの後は、トークンが作り直されます。 リファレンスでは、CsrfAuthenticationStrategy と CsrfLogoutHandler が前のトークンを消すため、SPA は新しいトークンを受け取ってから POST する必要がある、と説明しています。spa() は毎回のリクエストでトークンを読み込む設定(setCsrfRequestAttributeName(null))を含むため、ログイン後に GET を1回すると新しい Cookie が届きます。
Security 6 系での書き方との違い
| 版 | SPA 向けの書き方 |
|---|---|
| Security 6.x | CookieCsrfTokenRepository.withHttpOnlyFalse() と、自作の SpaCsrfTokenRequestHandler クラス、Cookie を毎回出すためのフィルターを自分で書く(6.x のリファレンスの例) |
| Security 7.0 以降 | csrf.spa() の1行 |
Boot 4 に上げるときは、自作のクラスを消して spa() にまとめられます。CSRF と Cookie の SameSite の関係は、関連記事「CSRF対策とSameSite|Cookie送信をChromeで実測し多層防御を実装」で扱っています。
CSRF で 403 になるときの例外の文
7.1.1 のソースでは、403 の前に次のどちらかの例外が作られます(レスポンスの本文には出ません)。
| 例外 | 文 | 状況 |
|---|---|---|
MissingCsrfTokenException |
Could not verify the provided CSRF token because no token was found to compare. |
サーバー側にトークンが無い(セッション切れ・Cookie が届いていない) |
InvalidCsrfTokenException |
Invalid CSRF Token '…' was found on the request parameter '_csrf' or header 'X-CSRF-TOKEN'. |
送られた値が違う(ログイン前のトークン・名前違い) |
MissingCsrfTokenException なら、ブラウザが Cookie(セッションか XSRF-TOKEN)を送っていません。 別オリジンからの fetch で credentials: "include" が無い、Cookie の SameSite・Secure で落ちている、などを疑います。
原因2:permitAll が効かない(順番・メソッド・パス)
規則は上から順に見て、最初に当たったものが使われる
// NG:/api/public/** も 1行目に当たり、authenticated になる
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().denyAll());
// OK:狭い規則を上に
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll());
anyRequest() の後に requestMatchers を書くと、起動時に Can't configure requestMatchers after anyRequest で止まります(7.1.1 のソースの文)。
HTTP メソッドを指定している
.requestMatchers(HttpMethod.GET, "/api/items/**").permitAll() // GET だけ
.anyRequest().authenticated() // POST はこちら
メソッドを指定した permitAll() は、そのメソッドにしか効きません。 「GET は通るが POST は 403(または 401)」のもう1つの原因です。TRACE ログの Checking authorization on POST /api/items using … で、どの規則に当たったかを確かめます。
パスの書き方(Security 7)
| 書き方 | 当たるか(Security 7) |
|---|---|
/api/users/** と /api/users |
当たる(/** は0個以上の階層) |
/api/users/* と /api/users/1/orders |
当たらない(* は1階層だけ) |
/api/**/orders |
起動時のエラー('{*...}' or '**' pattern elements should be placed at the start or end of the pattern) |
/**/orders と /api/v1/orders |
当たる(Framework 7 の PathPattern は ** を先頭にも書ける) |
/api/users と /api/users/ |
当たらない(末尾のスラッシュは別のパス) |
Spring Security 7.0 で AntPathRequestMatcher・MvcRequestMatcher は削除され、PathPatternRequestMatcher になりました(What's New in 7.0)。requestMatchers("…") に渡す文字列は PathPattern として解釈されます。パスにはコンテキストパス(server.servlet.context-path)を含めません。
原因3:/error が保護されている
Spring Security は、エラー画面への転送(ERROR ディスパッチ)も検査します。 permitAll() の URL で 400・404・500 が起きると、Spring Boot は /error へ転送します。/error が許可されていないと、未ログインの利用者には 401 か 403 が返り、本当の原因が隠れます。
| 本当に起きたこと | 利用者に見える結果 |
|---|---|
permitAll() の URL で JSON の誤り(400) |
401 または 403 |
| 存在しない URL(404) | 401 または 403 |
| コントローラーで例外(500) | 401 または 403 |
TRACE ログでは、最初のリクエストの後に Securing POST /error が続きます。Spring Security のリファレンスでは、ERROR の転送を許可する書き方が紹介されています。
http.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated());
requestMatchers("/error").permitAll() でも同じ効果があります。Spring Boot のリファレンスにも「/error も既定で保護される」と書かれています。400 の本当の原因(JSON の誤り)は関連記事「HttpMessageNotReadableExceptionの直し方|原因一覧」で扱っています。
原因4:hasRole と ROLE_ 接頭辞
hasRole("ADMIN") は、権限(GrantedAuthority)が ROLE_ADMIN かどうかを見ます。 7.1.1 の AuthorityAuthorizationManager.hasRole は、ROLE_ を自動で前に付けて hasAuthority を呼びます。
| ユーザーの作り方・DB の値 | 実際の権限 | hasRole("ADMIN") |
hasAuthority("ADMIN") |
|---|---|---|---|
User.withUsername("a").roles("ADMIN") |
ROLE_ADMIN |
通る | 拒否 |
.authorities("ADMIN") |
ADMIN |
拒否 | 通る |
DB の値が ROLE_ADMIN で new SimpleGrantedAuthority(値) |
ROLE_ADMIN |
通る | 拒否 |
DB の値が ADMIN で new SimpleGrantedAuthority(値) |
ADMIN |
拒否 | 通る |
ROLE_ を付けたまま書くと、起動時に分かります(7.1.1 のソースの文)。
| 書き方 | エラーの文 |
|---|---|
hasRole("ROLE_ADMIN") |
ROLE_ADMIN should not start with ROLE_ since ROLE_ is automatically prepended when using hasRole. Consider using hasAuthority instead. |
User.withUsername("a").roles("ROLE_ADMIN") |
ROLE_ADMIN cannot start with ROLE_ (it is automatically added) |
DB の値に ROLE_ を付けるか付けないかを決め、hasRole と hasAuthority のどちらで書くかを揃えます。 権限の取り違えは、TRACE ログの Sending UsernamePasswordAuthenticationToken [… Granted Authorities=[ADMIN]] to access denied handler の Granted Authorities で見分けられます。DB 認証の作り方は関連記事「Spring Security入門|SecurityFilterChainとDB認証」で扱っています。
原因5:メソッドセキュリティ(@PreAuthorize)
@EnableMethodSecurity
@Configuration
class MethodSecurityConfig {}
@RestController
class AdminController {
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/users/{id}")
void delete(@PathVariable long id) { … }
}
URL の規則(authorizeHttpRequests)を通っても、メソッドの @PreAuthorize で拒否されると 403 になります。拒否されると AuthorizationDeniedException(文は Access Denied)が投げられます。
@ExceptionHandler(Exception.class) で全部の例外を受けていると、403 のはずが 500 になります。 AuthorizationDeniedException はコントローラーの中で投げられるため、先に @RestControllerAdvice に捕まります。受けたくない例外は投げ直すか、専用のハンドラーで 403 を返します。
@ExceptionHandler(AccessDeniedException.class)
ProblemDetail handleAccessDenied(AccessDeniedException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "この操作の権限がありません");
}
AccessDeniedException は org.springframework.security.access.AccessDeniedException です(AuthorizationDeniedException はその子クラス)。例外処理の全体は関連記事「Spring Boot例外処理|@ControllerAdvice完全版」で扱っています。
原因6:CORS のプリフライト
別のオリジン(http://localhost:5173 から http://localhost:8080 など)へ、Content-Type: application/json や独自ヘッダー付きで送ると、ブラウザは先に OPTIONS のリクエスト(プリフライト)を送ります。
プリフライトには Cookie や Authorization ヘッダーが付きません。 Spring Security のリファレンスには「CORS must be processed before Spring Security」とあり、CORS の処理を Security の前に置かないと、プリフライトが未ログインとして拒否されます。
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization", "X-XSRF-TOKEN"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
| 症状 | 原因 |
|---|---|
| OPTIONS が 401・403 | http.cors(…) が無く、プリフライトが認証で止まった |
本文が Invalid CORS request で 403 |
オリジン・メソッド・ヘッダーが許可の一覧に無い(Spring Framework の DefaultCorsProcessor の文) |
| CORS は通るが POST が 403 | 別オリジンでも CSRF は効く。X-XSRF-TOKEN を allowedHeaders に入れ、トークンを送る |
リファレンスには「Spring Security will automatically configure CORS only if a UrlBasedCorsConfigurationSource instance is present.」とあります。 戻り値の型を UrlBasedCorsConfigurationSource にして Bean を作ります。Spring Security 7.1 では、プリフライトを扱う PreFlightRequestFilter の対応も足されました(What's New in 7.1)。CORS の書き方の比較は関連記事「Spring Boot CORS設定|3つの書き方とSecurity併用のコード全文」で扱っています。開発中だけ CORS を避ける方法は関連記事「Angular proxy.conf.jsonの書き方|開発中のCORSを回避」で扱っています。
原因7:静的リソース
自分で SecurityFilterChain を作ると、/css/** や /js/** も anyRequest().authenticated() の対象になります。
import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest;
http.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.anyRequest().authenticated());
Boot 4.1.1 のソースで、atCommonLocations() が許可するパスは次のとおりです。
| 種類 | パス |
|---|---|
| CSS | /css/** |
| JavaScript | /js/** |
| 画像 | /images/** |
| WebJars | /webjars/** |
| アイコン | /favicon.*・/icons/icon-* |
| フォント | /fonts/** |
PathRequest のパッケージは Boot 4 で変わりました。 Boot 3 の org.springframework.boot.autoconfigure.security.servlet.PathRequest は、Boot 4 では org.springframework.boot.security.autoconfigure.web.servlet.PathRequest です。/assets/** など別の場所に置いているなら、requestMatchers("/assets/**").permitAll() を足します。
原因8:SecurityFilterChain が2つ以上ある
@Bean @Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**") /* … */;
return http.build();
}
@Bean @Order(2)
SecurityFilterChain web(HttpSecurity http) throws Exception {
http /* securityMatcher なし=すべてのリクエスト */;
return http.build();
}
リクエストは、最初に当たった1つのチェーンだけを通ります。 securityMatcher が無いチェーン(すべてに当たる)を先に置くと、後ろのチェーンは使われません。7.1.1 では起動時に … has already been configured, which means that this filter chain […] will never get invoked. Please use `HttpSecurity#securityMatcher` to ensure that there is only one filter chain configured for 'any request' and that the 'any request' filter chain is published last. で止まります。TRACE ログの Trying to match request against 〈チェーン〉 (n/m) で、どのチェーンに入ったかが分かります。
Spring Security 7 での主な変更点
「What's New in Spring Security 7.0」から、403 の調べ方に関わるものを抜き出しました。
| 変更 | 影響 |
|---|---|
authorizeRequests を削除し authorizeHttpRequests へ |
Security 5 系の例にある authorizeRequests() はコンパイルできない |
HttpSecurity の DSL から and() を削除 |
ラムダで書く(http.csrf(c -> …)) |
MvcRequestMatcher・AntPathRequestMatcher を削除し PathPatternRequestMatcher へ |
/api/**/x のように ** を途中に書くと起動時のエラー |
SPA 向けの CSRF 設定(csrf.spa())を追加 |
自作の CSRF ハンドラーが要らなくなった |
AccessDecisionManager など Access API を spring-security-access へ移動 |
投票方式の設定を使うなら依存を足す |
7.1 では、PreFlightRequestFilter の対応や、WWW-Authenticate ヘッダーへの charset の追加などが入っています(What's New in 7.1)。
テストで確かめる(MockMvc)
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mvc;
@Test
@WithMockUser
void CSRFトークンが無いPOSTは403() throws Exception {
mvc.perform(post("/orders"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser
void CSRFトークンがあれば通る() throws Exception {
mvc.perform(post("/orders").with(csrf()))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void ADMINでなければ削除できない() throws Exception {
mvc.perform(delete("/api/users/1").with(csrf()))
.andExpect(status().isForbidden());
}
}
csrf() と @WithMockUser は spring-security-test の機能です(SecurityMockMvcRequestPostProcessors.csrf)。テストの書き方は関連記事「Spring Bootテスト入門|@WebMvcTestとMockMvcの使い分け」で扱っています。
調べる順番のチェックリスト
logging.level.org.springframework.security=TRACEにして、もう一度送るInvalid CSRF token foundがあれば CSRF(原因1)Securing POST /errorがあれば、最初のリクエストの本当のエラーを探す(原因3)Checking authorization on … using 〈規則〉で、当たった規則を確かめる(原因2)Granted Authorities=[…]とhasRoleの対応を確かめる(原因4)Secured POST …まで出ているなら、Security ではなくコントローラー側(@PreAuthorize・例外ハンドラー)を見る(原因5)- ブラウザだけで起きるなら、開発者ツールで
OPTIONSの結果を見る(原因6)
よくある質問
Q. csrf.disable() にしてよいか迷います。
A. 判断の基準は「ブラウザが自動で送る認証情報(Cookie・Basic 認証)を使っているか」です。使っていないなら外しても狙われる経路がありません。セッション Cookie でログインする画面と API が同じアプリにあるなら、外さずにトークンを送る形にします。
Q. 403 の本文が空です。理由を返せませんか。
A. Spring Security は、攻撃者に手がかりを与えないよう、理由を本文に書きません。exceptionHandling(e -> e.accessDeniedHandler(…)) で自分のハンドラーを置くと本文を返せますが、CSRF の詳細など内部の理由は返さないようにします。調べるときはログを使います。
Q. Postman では通るのにブラウザでは 403 です。
A. CORS のプリフライト(原因6)か、CSRF の Cookie がブラウザで送られていない(Cookie の SameSite・Secure)ことを疑います。Postman はプリフライトを送らず、Cookie の扱いもブラウザと違います。
Q. ログインしているのに Pre-authenticated entry point called が出ます。
A. そのリクエストでログイン状態が引き継がれていません。セッション Cookie(JSESSIONID)が送られているか、SessionCreationPolicy.STATELESS にしていないかを確かめます。
まとめ
- POST だけ 403 は CSRF。
permitAll()では外れない - 調べるときは
logging.level.org.springframework.security=TRACE。理由はログにだけ出る - 規則は上から順に最初に当たったもの。HTTP メソッドの指定にも注意
/errorも保護される。400・404・500 が 401・403 に化けるhasRole("ADMIN")はROLE_ADMINを見る- SPA は Security 7 の
csrf.spa()と、CookieXSRF-TOKEN→ ヘッダーX-XSRF-TOKEN formLogin・httpBasicが無い設定では、未ログインも 403 になる
参考・出典
確認日はいずれも 2026年10月13日です。
- Spring Security リファレンス「Cross Site Request Forgery (CSRF)」:https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html
- Spring Security リファレンス「Authorize HttpServletRequests」:https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html
- Spring Security リファレンス「Architecture」(Logging):https://docs.spring.io/spring-security/reference/servlet/architecture.html
- Spring Security リファレンス「CORS」:https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html
- What's New in Spring Security 7.0:https://docs.spring.io/spring-security/reference/7.0/whats-new.html
- What's New in Spring Security 7.1:https://docs.spring.io/spring-security/reference/whats-new.html
- Spring Boot リファレンス「Spring Security」:https://docs.spring.io/spring-boot/reference/web/spring-security.html
- Spring Security ソースコード(7.1.1)CsrfFilter:https://github.com/spring-projects/spring-security/blob/7.1.1/web/src/main/java/org/springframework/security/web/csrf/CsrfFilter.java
- Spring Security ソースコード(7.1.1)CsrfConfigurer:https://github.com/spring-projects/spring-security/blob/7.1.1/config/src/main/java/org/springframework/security/config/annotation/web/configurers/CsrfConfigurer.java
- Spring Security ソースコード(7.1.1)ExceptionTranslationFilter:https://github.com/spring-projects/spring-security/blob/7.1.1/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Spring Securityで403になる原因|POSTだけ失敗する時」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。