ReactやVueでフロントエンドを作ってSpring Boot REST APIと繋ごうとしたとき、必ずと言っていいほど遭遇するのがCORSエラーですよね。

Access to XMLHttpRequest at 'http://localhost:8080/api/users'
from origin 'http://localhost:3000' has been blocked by CORS policy

@CrossOrigin を付けたのに効かない」「Spring Securityを入れたら突然設定が無視された」という経験、ありませんか。この記事では3つの設定パターンを順番に解説しつつ、Spring Security導入時の落とし穴まで押さえます。

CORSとは - ブラウザがエラーを出す仕組み

CORSとは Cross-Origin Resource Sharing の略で、ブラウザのセキュリティ機能である 同一オリジンポリシー を安全に緩和する仕組みです。

オリジンとは プロトコル + ホスト + ポート の組み合わせのこと。http://localhost:3000http://localhost:8080 はポートが違うので 別オリジン になります。

ブラウザはPOSTやカスタムヘッダーを含むリクエストを送る前に、OPTIONSメソッドで Preflightリクエスト を投げます。サーバーが Access-Control-Allow-Origin などのヘッダーを返さないと、ブラウザ側でブロックされます。

curlやPostmanでは同じリクエストが成功するのに、ブラウザだけエラーになるのはこのためです。サーバーが正しいレスポンスヘッダーを返すよう設定するのがCORS対応です。

パターン1: @CrossOriginアノテーション

最もシンプルな方法です。コントローラーに直接付けるだけで動きます。

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "http://localhost:3000")
public class UserController {

    @GetMapping
    public List<User> getUsers() { ... }

    // このエンドポイントだけ別のオリジンを許可したい場合
    @PostMapping
    @CrossOrigin(origins = "https://app.example.com")
    public User createUser(@RequestBody UserRequest req) { ... }
}

手軽ですが、エンドポイントが増えるたびに付け忘れが発生しやすいです。Spring Securityなし・試作段階・特定エンドポイントだけ許可したいケース向けの選択肢と考えてください。

パターン2: WebMvcConfigurerでグローバル設定

全エンドポイントに一括でCORSを適用するなら WebMvcConfigurer を使います。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

addMapping("/**") でアプリ全体に適用できます。パスごとに設定を分けたい場合は addMapping("/api/**") のように書けます。

Spring Securityを使っていない環境ではこれで完璧です。ただし、Spring Securityを導入すると突然この設定が効かなくなります。

Spring Securityを導入するとCORS設定が効かなくなる理由

これが多くの人がハマるポイントです。

Spring SecurityはDispatcherServletよりも 前段のFilter層 で動作します。WebMvcConfigurer のCORS設定はDispatcherServlet以降のSpringMVC層に適用されるため、SecurityFilterChainがリクエストを先に捕まえてしまうのです。

PreflightのOPTIONSリクエストが来た際、SecurityFilterChainが「認証されていないリクエスト」として 401を返してしまい 、ブラウザがCORSエラーと認識します。WebMvcConfigurerの設定にはそもそも到達すらしていません。

解決策はSecurityFilterChain側にもCORSを認識させることです。

パターン3: SecurityFilterChainでCORSを設定する

Spring Security 6.x対応の書き方です。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

CorsConfigurationSource をBeanとして定義しておくと、将来 WebMvcConfigurer と設定を共有することもできて管理しやすいです。Spring Security 5.x(WebSecurityConfigurerAdapter)を使っている場合は書き方が異なりますが、Spring Boot 3.x環境では上記の形式が標準です。

allowedOriginsとallowedOriginPatternsの違い

本番環境でよく問題になる点です。

allowCredentials(true) にしているのに allowedOrigins("*") を指定すると、Springがエラーを投げます。CORSの仕様上、Credentialsを含むリクエストではワイルドカード指定が禁止されているためです。

代わりに allowedOriginPatterns を使うとワイルドカードが使えます。

// allowCredentials(true)と組み合わせて使える
config.setAllowedOriginPatterns(List.of("https://*.example.com"));

// 開発環境では * でも可
config.setAllowedOriginPatterns(List.of("*"));

本番環境では具体的なオリジンを明示するのが安全です。環境ごとに切り替えたい場合は Spring Profilesを使った設定切り替え が参考になります。

パターン4: CorsFilterをBeanとして登録する

実は、Spring MVCの設定ともSecurityFilterChainとも別に、CorsFilter をBeanとして登録する方法もあります。

@Configuration
public class CorsFilterConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

Spring Bootは Filter 型のBeanを見つけると自動でサーブレットFilterとして登録するので、これだけでアプリ全体にグローバルなCORS設定が効きます。DispatcherServletより前の段階でPreflightに応答するため、Spring MVC以外のサーブレットが同居している構成でも使えるのが利点です。

ただし、Spring Securityと併用する場合は注意が必要です。Bean登録した CorsFilter の実行順は既定で最も低い優先度になるため、SecurityFilterChainより後ろに並びます。つまりこの方法でも、パターン3と同じくSecurityFilterChain側で cors() を有効にしておかないと、PreflightがSecurityFilterChainで止まってしまいます。Spring Securityは corsFilter という名前の CorsFilter Beanを見つけると、cors() の中でそのFilterをそのまま使ってくれます。

結論として、Spring Securityを使うなら素直にパターン3、使わないならWebMvcConfigurerかこの CorsFilter Beanのどちらか一つ、という使い分けで十分です。両方書いても壊れはしませんが、設定が二重管理になるので一つに絞りましょう。

cors(Customizer.withDefaults())でCorsConfigurationSource Beanを自動検出する

パターン3では cors(cors -> cors.configurationSource(...)) と明示的に渡しましたが、corsConfigurationSource という名前で CorsConfigurationSource Beanを定義しておけば、cors(Customizer.withDefaults()) と書くだけで自動的に拾ってくれます。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())  // corsConfigurationSource Beanを自動検出
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    return http.build();
}

Bean名が違うと検出されないので、メソッド名は corsConfigurationSource のままにしておくのが無難です。逆に、Beanは定義したのに cors() の呼び出し自体を書き忘れると「設定したはずなのにOPTIONSが401」という状態になります。Preflightで401が出たら、まず cors() があるかを確認してください。

exposedHeadersを忘れるとフロントからレスポンスヘッダーが読めない

CORSが通ったあとに意外とハマるのが、「レスポンスヘッダーがJavaScriptから取れない」問題です。

CORSの仕様では、ブラウザがJavaScriptに公開するレスポンスヘッダーは Content-TypeCache-Control などごく一部に限られています。AuthorizationLocationX-Total-Count のような独自ヘッダーを fetchresponse.headers.get() で読むには、サーバー側で明示的に公開する必要があります。

config.setExposedHeaders(List.of("Authorization", "Location", "X-Total-Count"));

WebMvcConfigurerなら .exposedHeaders("Authorization", "Location") と書きます。JWTをレスポンスヘッダーで返す構成や、201 CreatedLocation をフロントで使いたい場合は、この設定がないと null しか返ってこないので気をつけましょう。

Preflightをcurlで確認する

設定が正しく適用されているかはcurlで確認できます。

curl -v -X OPTIONS http://localhost:8080/api/users \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type"

レスポンスに Access-Control-Allow-Origin: http://localhost:3000 が含まれていればOKです。含まれていない場合は設定が正しく適用されていません。Spring Securityを入れている場合、OPTIONSリクエストが401になっていないかもここで確認できます。

なお、よくあるミスとして allowCredentials(true)allowedOrigins("*") の組み合わせ、SecurityFilterChainで cors() の記述を忘れてWebMvcConfigurerだけ設定している、などが挙げられます。設定が効いていない場合はまずこのcurlコマンドで動作確認してみてください。

正常系は通るのにエラーレスポンスにだけCORSヘッダーが付かない、というケースもあります。認証Filterなど、CORS処理より前のFilterで例外が投げられてそのままレスポンスが返っている、というのが典型です。コントローラー層の例外なら @RestControllerAdvice で捕まえて通常のレスポンスとして返しておけば、ブラウザにはCORSヘッダー付きのエラーレスポンスが届きます。本番向けの例外ハンドラの組み方は GlobalExceptionHandlerを本番運用向けに実装する で解説しています。

まとめ

4パターンの使い分けをまとめます。

状況推奨パターン
Spring Securityなし・特定エンドポイントのみ@CrossOrigin
Spring Securityなし・全体に一括適用WebMvcConfigurer
Spring Securityあり(本番環境)SecurityFilterChain + CorsConfigurationSource
Spring MVC以外のサーブレットも同居CorsFilter Bean

Spring Securityを使っているなら基本的にパターン3一択です。CorsConfigurationSource をBeanとして切り出しておくと設定の一元管理もしやすくなります。

認証・認可の実装については Spring Security JWTの記事 も合わせてどうぞ。FilterとInterceptorの仕組みをもっと詳しく知りたい方は InterceptorとFilterの違いと使い分け も参考になります。

参考リンク

本記事のコードはSpring Boot 3.x / Spring Security 6.xで動作確認しています。より詳しい仕様は以下の公式ドキュメントを参照してください。