Spring Securityを設定していて、POSTリクエストが突然403になった経験はありませんか。ログを見ても原因が分からず、とりあえず csrf().disable() を書いてしまった、という人は多いと思います。
でも、この設定が正しいかどうかはアプリの種類によって全然違います。REST APIなら無効化して問題ないですが、Thymeleafのフォームアプリでは有効化しておかないと本物のセキュリティリスクになります。この記事で、設定の根拠を整理しましょう。
CSRF攻撃の仕組み
CSRFはCross-Site Request Forgeryの略で、ログイン済みユーザーに意図しない操作をさせる攻撃です。
具体的なシナリオで考えると分かりやすいです。あなたが銀行サイト(bank.example.com)にログインした状態で、悪意のある別サイト(evil.example.com)を開いたとします。そのサイトには以下のHTMLが隠れています。
<form action="https://bank.example.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit();</script>
ページを開いた瞬間、フォームが自動送信されます。このとき ブラウザは bank.example.com のセッションCookieを自動的に付与 します。銀行サーバーからすると、正規のユーザーが送金をリクエストしたように見えてしまうわけです。攻撃が成立する根本原因は「ブラウザがリクエスト先ドメインのCookieを自動付与する」という仕様にあります。
REST API(JWT認証)でCSRFが不要な理由
JWTを使ったステートレスなREST APIでは、CSRFを無効化しても問題ありません。理由は3点あります。
JWTはAuthorizationヘッダーで送信します。 ブラウザはカスタムヘッダーを自動付与しないため、悪意のあるサイトのフォームからはトークンを送ることができません。
カスタムヘッダーはCORSのプリフライトで阻止されます。 Authorization ヘッダーを付けると、ブラウザはOPTIONSリクエストを事前に送ります。CORSが正しく設定されていれば、別ドメインからのリクエストはここで止まります。
ステートレスなAPIはセッションを持ちません。 CSRFはセッションCookieが自動送信されることが前提なので、セッションがなければ攻撃の条件が成立しません。
JWTの実装についてはこちらの記事で詳しく解説しています。
セッション+Cookie認証のフォームアプリではCSRFが必要
Thymeleafなどのサーバーサイドレンダリングアプリは、通常セッションIDをCookieで管理します。これはCSRF攻撃の条件がそのまま揃う状況です。
ログイン後にセッションCookieが発行されると、ブラウザは同じドメインへのリクエストにそのCookieを自動で付けます。悪意のあるサイトから <form> タグで送信しても、ブラウザがセッションCookieを付けてしまうため、サーバーは正規のリクエストと区別できません。
CSRFトークンはこの問題を解決します。サーバーがランダムなトークンをフォームに埋め込み、一致しないリクエストを拒否することで、悪意のあるサイトからのリクエストを弾けます。
403エラーの診断
症状別 原因と対処の早見表
| 症状 | 原因として疑うべきポイント | 対処 |
|---|---|---|
| GETは通るがPOST/PUT/DELETEで403 | CSRFトークンが付与されていない | フォームに _csrf を埋め込む、またはJWT認証ならCSRFを無効化 |
ログに InvalidCsrfTokenException | 古いトークンがキャッシュされている | ブラウザのCookieとセッションをクリアして再ログイン |
ログに MissingCsrfTokenException | リクエストにトークンが含まれていない | AJAXなら X-CSRF-TOKEN ヘッダーを付与 |
| 同一オリジンでも403 | Spring Securityのフィルタ順序かパス除外設定 | authorizeHttpRequests のマッチング順を確認 |
| プリフライトOPTIONSで失敗 | CORS設定不足(CSRFではない) | CorsConfigurationSource を確認 |
上記で原因が特定できない場合は logging.level.org.springframework.security=TRACE を有効化し、CsrfFilter 周辺のログを確認してください。
まず、アプリのログを確認しましょう。InvalidCsrfTokenException や MissingCsrfTokenException が出ていれば、CSRFが原因です。
GETは通るがPOST/PUT/DELETEだけ403になる パターンがCSRFの典型症状です。詳細なログを確認したい場合はTRACEレベルを設定します。
# application.properties
logging.level.org.springframework.security=TRACE
CORSエラーと混同しやすいですが、CORSはブラウザが別オリジンへのリクエストをブロックする仕組みで、CSRFとは別の問題です。CORSの設定はこちらで解説しています。
Spring Security 6:REST API向けCSRF無効化
JWT認証のREST APIでは以下のように設定します。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// JWT認証のためセッション不使用・CSRF不要
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
Spring Security 5以前では http.csrf().disable() と書いていましたが、6ではLambda DSLに統一されました。コメントに「なぜ無効化しているか」を残しておくと、後から設定を見た人が意図を理解しやすくなります。SessionCreationPolicy.STATELESS と合わせて使うことで、セッションを作らないという意図もコードで表現できます。
Spring Security 6:ThymeleafフォームアプリでのCSRF有効化
Spring SecurityはデフォルトでCSRFが有効です。withDefaults() を使って意図を明示的にコードに残しましょう。
import static org.springframework.security.config.Customizer.withDefaults;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(withDefaults()) // セッション認証のため有効化(デフォルト動作を明示)
.formLogin(withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
Thymeleafでは th:action を使うと、_csrf のhiddenフィールドが 自動的に埋め込まれます。
<!-- Thymeleafテンプレート -->
<form th:action="@{/profile/update}" method="post">
<input type="text" name="username" />
<button type="submit">更新</button>
</form>
ブラウザのデベロッパーツールで生成されたHTMLを確認すると、hiddenフィールドが挿入されているのが分かります。
<!-- 実際に出力されるHTML -->
<form action="/profile/update" method="post">
<input type="hidden" name="_csrf" value="abc123...">
<input type="text" name="username" />
<button type="submit">更新</button>
</form>
Thymeleafを使わない素のHTMLフォームでは自動埋め込みが行われないため、手動での設定が必要です。Thymeleafの詳しい使い方はこちらをご参照ください。
AJAXリクエストでのCSRFトークン送信
CookieCsrfTokenRepository.withHttpOnlyFalse() の動作詳細
CookieCsrfTokenRepository.withHttpOnlyFalse() はトークンを XSRF-TOKEN という名前のCookieとしてレスポンスに付与し、HttpOnly 属性を 付けない 設定で発行します。これにより JavaScript(axios / fetch / Angular の HttpClient など)から document.cookie 経由でトークンを読み取れるようになります。
発行されるCookieとリクエストヘッダーの対応は以下の通りです。
| 項目 | 値 |
|---|---|
| Cookie名 | XSRF-TOKEN |
| Cookie属性 | HttpOnly=false, Path=/, SameSite=Lax(デフォルト) |
| 期待されるリクエストヘッダー | X-XSRF-TOKEN: <トークン値> |
| サーバー側検証 | CsrfFilter がヘッダー値とCookie値を比較 |
Angular は HttpClientXsrfModule が XSRF-TOKEN Cookieを自動で X-XSRF-TOKEN ヘッダーに転送するため、デフォルトでこの仕組みに適合します。axios の場合は withCredentials: true と xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN' の設定で同様に動作します。
注意点として、withHttpOnlyFalse() を選ぶ場合は XSS 対策を強化する必要があります。XSSが成立するとJavaScript経由でトークンが読み取られるため、Content-Security-Policy ヘッダーの設定や入力エスケープを徹底してください。
フォームアプリでAJAXを使う場合は、CSRFトークンをヘッダーに付与する必要があります。metaタグにトークンを埋め込んでJavaScriptで取得する方法が定番です。
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[header]: token
},
body: JSON.stringify({ key: 'value' })
});
fetchやaxiosからCookieを自動参照させたい場合は CookieCsrfTokenRepository を使います。
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
withHttpOnlyFalse() にするとJavaScriptからCookieを読めるようになります。XSRF-TOKEN という名前でトークンが発行され、リクエスト時は X-XSRF-TOKEN ヘッダーとして送信することで認証されます。
SameSiteクッキーとCSRF緩和の関係
SameSite 属性はCookieをクロスサイトリクエストに添付するかを制御するブラウザ仕様で、CSRF攻撃の緩和に寄与します。3つの値の挙動を整理します。
| 値 | 同一サイト遷移 | クロスサイトGET(リンククリック等) | クロスサイトPOST(攻撃想定) | CSRF緩和効果 |
|---|---|---|---|---|
Strict | 送信される | 送信されない | 送信されない | 最も強い(外部リンク経由のログイン状態すら維持されない場合あり) |
Lax | 送信される | 送信される | 送信されない | 実用的なバランス(モダンブラウザのデフォルト) |
None | 送信される | 送信される | 送信される | 緩和効果なし(Secure 必須) |
Spring Boot で SameSite を明示的に設定するには application.properties で以下のように指定します。
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.http-only=true
実際の Set-Cookie レスポンスヘッダーは次の形になります。
Set-Cookie: JSESSIONID=ABC123; Path=/; Secure; HttpOnly; SameSite=Lax
Chrome 80以降・Firefox・Safariの最近のバージョンは SameSite 未指定時に Lax 相当として扱うようになっており、CSRF攻撃の難度は以前より上がっています。ただし以下のケースでは SameSite 単独での防御が不十分です。
- 古いブラウザ(Chrome 79以前、IE11等)からのアクセスを許容する必要がある場合
- サブドメイン間の攻撃(
SameSiteは eTLD+1 単位で同一サイト扱いになるため、evil.example.comからbank.example.comへの攻撃を防げないことがある) LaxではPOSTは防げてもGETでの副作用操作(設計上避けるべきだが)は防げない
したがって SameSite はCSRFトークンの代替ではなく、多層防御の一層 として位置づけ、Spring Security のトークン検証と組み合わせて運用するのが正しい姿勢です。
まとめ
アプリの種類によって設定が変わります。
| ケース | 設定 |
|---|---|
| REST API + JWT/APIキー | csrf(csrf -> csrf.disable()) + STATELESS |
| Thymeleafフォーム + セッション | csrf(withDefaults()) + 自動トークン埋め込み |
| フォームアプリ + AJAX | CookieCsrfTokenRepository.withHttpOnlyFalse() |
| REST API + フォームが混在 | requestMatcherでパス別に設定を分岐 |
セッション認証のフォームアプリで csrf().disable() を書いてしまうと、本物のセキュリティホールになります。403エラーが出たときは、まずログで原因を特定してから、自分のアプリに合った設定を選ぶようにしましょう。
セキュリティ関連の運用Tipsとして、設定ファイルの機密情報を扱う場合はSpring BootでJasyptを使って設定ファイルの機密情報を暗号化する方法、403以外の例外を本番運用品質で処理したい場合はSpring BootのGlobalExceptionHandlerを本番運用向けに実装するも合わせてご覧ください。