shibomb

CORSエラーを自力で解決!プリフライト検証とヘッダー設計で通信遮断を防ぐ

ブラウザのコンソールに赤字で「blocked by CORS policy」と出て、APIは動いているはずなのに画面にデータが出ない。しかも直すのはフロントエンドなのかサーバーなのか分からない。そんな経験はありませんか。実は CORSエラー は、原因の見分け方さえ押さえれば怖くありません。この記事では 同一生成元ポリシー の存在理由から、プリフライトリクエスト の中身、Access-Control-Allow-Origin の正しい設定、開発時と本番での対処の使い分けまでを順番に解説します。

なぜブラウザは通信を止めるのか?「同一生成元ポリシー(SOP)」の存在理由

ブラウザには、あるオリジン(生成元)のページから別オリジンのリソースを自由に読ませない 同一生成元ポリシー(SOP) という安全装置があります。オリジンとは「スキーム + ホスト + ポート」の組み合わせです(定義は RFC 6454 にあります)。

たとえば http://localhost:5173 のページから見ると、次のURLは同一オリジンかどうかが次のように変わります。

  1. http://localhost:5173/api/users は同一オリジンです
  2. http://localhost:8080/api/users はポートが違うため別オリジンです
  3. https://localhost:5173/api/users はスキームが違うため別オリジンです

SOPがないと何が起きるでしょうか。あなたが銀行サイトにログインしたまま、悪意あるサイトを開いたとします。そのサイトのJavaScriptが銀行のAPIを呼び出し、Cookieつきで返ってきた口座情報を読み取れてしまいます。これを防ぐために、ブラウザは「他オリジンのレスポンスをスクリプトから読ませない」というルールを基本にしています。

CORSエラーの正体:サーバーが拒否しているのではなくブラウザが保護している

ここが最大の誤解ポイントです。CORS(Cross-Origin Resource Sharing)は、SOPを 安全な範囲で緩めるための仕組み です。サーバーが「このオリジンからの読み取りを許可します」とレスポンスヘッダーで宣言し、ブラウザがそれを確認して初めて、JavaScriptにレスポンスを渡します。

つまり CORSエラー の多くは「サーバーが処理に失敗した」のではなく「サーバーの許可宣言が見当たらないので、ブラウザが結果をJavaScriptに渡さなかった」という状態です。開発者ツールのNetworkタブを見ると、サーバーが200を返しているのにコンソールにエラーが出ていることも珍しくありません。

したがって、原因の切り分けは次のようになります。

  1. レスポンスに Access-Control-Allow-Origin ヘッダーがあるかを確認します
  2. ヘッダーの値が、リクエスト元の Origin と一致しているかを確認します
  3. プリフライトが失敗していないかを確認します(次のセクションで解説します)

サーバー側が許可ヘッダーを返すのが正攻法です。フロントエンドのコードをいくら書き換えても、通常のブラウザ上では解決しません。mode: 'no-cors' を指定するとエラーは消えますが、レスポンスの中身を読めない「opaque」な状態になるだけで、解決策にはなりません。

通信の裏側を覗く!単純リクエストとプリフライト(OPTIONS)リクエストの違い

クロスオリジンのリクエストには2種類あります。Fetch Standard で定められた条件を満たす 単純リクエスト は、そのまま送信され、レスポンスのヘッダーを見て読み取り可否が判断されます。単純リクエストの代表的な条件は次のとおりです。

  1. メソッドが GET、HEAD、POST のいずれかである
  2. 手動で付けるヘッダーが、Accept や Content-Type など安全とされるものに限られる
  3. Content-Type が application/x-www-form-urlencoded、multipart/form-data、text/plain のいずれかである

一方、Content-Type: application/json でPOSTする、Authorization ヘッダーを付ける、PUTやDELETEを使うといった場合は単純ではなくなります。ブラウザは本番のリクエストを送る前に、確認用の プリフライトリクエスト をOPTIONSメソッドで自動送信します。

OPTIONS /api/users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

サーバーは次のように答えます。

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600

ブラウザはこの返答に問題がなければ、本番のリクエストを送ります。実務でよく見るつまずきは、次の3つです。

  1. サーバーのルーティングがOPTIONSを想定しておらず、404や405を返している
  2. 認証ミドルウェアがOPTIONSにも認証を要求し、401を返している(プリフライトには認証情報が付きません)
  3. Access-Control-Allow-Headers に、実際に送るヘッダー(例:authorization)が含まれていない

Access-Control-Max-Age を設定すると、プリフライトの結果をブラウザがキャッシュします。ただし上限値はブラウザごとに異なるため、秒数は控えめに指定するのが無難です。

「Access-Control-Allow-Origin: *」で済ませてはいけない理由とクレデンシャル(Cookie)問題

エラーを消す一番手っ取り早い方法が Access-Control-Allow-Origin: * です。公開データだけを返す、認証不要の読み取り専用APIなら、これで問題ないケースもあります。ただし、ログイン情報を扱うAPIでこれを使うのは避けましょう。

まず仕様上の制約があります。Cookieや認証情報を伴うリクエスト(フロント側で credentials: 'include' を指定した場合)では、* は使えません。ブラウザが拒否します。

// フロントエンド
const res = await fetch('https://api.example.com/me', {
  credentials: 'include',
});

この場合、サーバーは具体的なオリジンを返し、さらに Access-Control-Allow-Credentials: true を付ける必要があります。

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Vary: Origin は、オリジンごとにレスポンスが変わることをキャッシュに伝えるためのヘッダーです。付け忘れると、あるオリジン向けのレスポンスが別のオリジンに使い回される不具合につながります。

もう1つ危険なのが、リクエストの Origin ヘッダーを検証なしにそのまま Access-Control-Allow-Origin へ反射する実装です。事実上すべてのオリジンを許可することになり、Cookie付きでも読み取られてしまいます。許可するオリジンは、あらかじめ決めた許可リストと照合する のが基本です。

Cookieを使う場合は、SameSite属性の影響も受けます。サイトをまたぐ送信にはブラウザごとの既定の扱いや SameSite=None; Secure の指定が絡むため、CORS設定とあわせて確認してください。

開発時と本番運用のベストプラクティス:APIサーバーの設定とVite/Next.jsプロキシの使い分け

対処法は大きく2つあり、目的が違います。

  1. APIサーバー側でCORSを正しく設定する:本番でも通用する根本的な解決策です
  2. 開発サーバーのプロキシを使って同一オリジンに見せる:ローカル開発を楽にする手段です

APIサーバーを自分で管理しているなら、許可リスト方式が基本です。Node.jsのExpress と cors パッケージの例を示します。

import express from 'express';
import cors from 'cors';

const app = express();

app.use(
  cors({
    origin: ['https://app.example.com', 'http://localhost:5173'],
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
    maxAge: 600,
  })
);

許可リストは環境変数から読み込むと、環境ごとの切り替えとレビューがしやすくなります。チーム開発では、CORS設定をコードとして管理し、変更をレビュー対象にしておくと保守性が上がります。

次にフロントエンド側です。Viteの開発サーバーには、リクエストを別のサーバーへ中継するプロキシ機能があります。ブラウザから見ると同一オリジンへの通信になるため、CORSの問題自体が発生しません。

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
};

フロントのコードは fetch('/api/users') と相対パスで書きます。Next.jsでは next.config.js の rewrites で同様に中継できます。

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://localhost:8080/api/:path*',
      },
    ];
  },
};

注意したいのは、Viteの server.proxy が 開発サーバー専用 という点です。ビルド後の静的ファイルを配信する本番環境には効きません。本番では、リバースプロキシ(Nginxやクラウドのロードバランサーなど)でフロントとAPIを同一オリジンにまとめるか、APIサーバー側でCORSを設定するかのどちらかが必要です。「開発では動いたのに本番で CORSエラー が出る」原因の典型です。

外部の第三者APIを呼ぶ場合は、相手がCORSを許可していなければ、こちらでは変更できません。その場合は、自分のバックエンドを経由して呼び出す構成にします。APIキーをブラウザに置かずに済むため、セキュリティ面でも有利です。

使い分けの目安は次のとおりです。

  1. 自分でAPIを管理していて、ドメインが分かれる構成なら、サーバー側でCORSを設定します
  2. ローカル開発の手間を減らしたいなら、開発サーバーのプロキシを使います
  3. 他社APIでCORS非対応なら、自前のサーバー経由に切り替えます

コンソールのエラーメッセージには「何が足りないか」が書かれています。たとえば「Request header field authorization is not allowed」なら Access-Control-Allow-Headers の不足です。メッセージを読み、Networkタブで該当のOPTIONSリクエストを確認する癖をつけると、WebAPI通信のトラブルを自力で切り分けられるようになります。

関連記事