CORS とは?

CORS(Cross-Origin Resource Sharing、オリジン間リソース共有)は、異なるオリジン(ドメイン)間でのHTTPリクエストを制御するブラウザのセキュリティ機能です。

オリジンとは?

オリジンは「プロトコル + ホスト + ポート」の組み合わせです。

URLオリジン
https://example.com/pagehttps://example.com
https://example.com:443/apihttps://example.com
http://example.com/pagehttp://example.com ← プロトコルが違う
https://api.example.com/v1https://api.example.com ← ホストが違う
https://example.com:8080https://example.com:8080 ← ポートが違う

同一オリジンポリシー

ブラウザは、セキュリティのためにJavaScriptから異なるオリジンへのリクエストをデフォルトでブロックします。これが同一オリジンポリシーです。

フロントエンド: https://myapp.com
API:           https://api.myapp.com  ← 別オリジン!

fetch('https://api.myapp.com/users')
→ CORSエラー!(設定がない場合)

CORSエラーのよくあるメッセージ

パターン1: Access-Control-Allow-Origin がない

Access to fetch at 'https://api.example.com/data' from origin
'https://myapp.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

原因: サーバーがCORSヘッダーを返していない。

パターン2: オリジンが許可されていない

Access to fetch at 'https://api.example.com/data' from origin
'https://myapp.com' has been blocked by CORS policy:
The 'Access-Control-Allow-Origin' header has a value 'https://other.com'
that is not equal to the supplied origin.

原因: サーバーが別のオリジンしか許可していない。

パターン3: プリフライトリクエストが失敗

Access to fetch at 'https://api.example.com/data' from origin
'https://myapp.com' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check.

原因: OPTIONSリクエスト(プリフライト)にサーバーが正しく応答していない。

CORSの仕組み

シンプルリクエスト

以下の条件をすべて満たすリクエストは、プリフライトなしで送信されます:

  • メソッド: GET, HEAD, POST
  • ヘッダー: Accept, Content-Type(一部), etc.
  • Content-Type: text/plain, multipart/form-data, application/x-www-form-urlencoded
ブラウザ → サーバー
GET /api/users HTTP/1.1
Origin: https://myapp.com

サーバー → ブラウザ
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com

プリフライトリクエスト

上記の条件を満たさないリクエスト(JSON送信、カスタムヘッダー等)は、事前にOPTIONSリクエストが送信されます。

1. プリフライト(OPTIONS)
ブラウザ → サーバー
OPTIONS /api/users HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

サーバー → ブラウザ
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

2. 実際のリクエスト
ブラウザ → サーバー
POST /api/users HTTP/1.1
Origin: https://myapp.com
Content-Type: application/json
Authorization: Bearer xxx

解決方法(バックエンド別)

Express.js

import cors from 'cors';

// すべてのオリジンを許可(開発用)
app.use(cors());

// 特定のオリジンだけ許可(本番用)
app.use(cors({
  origin: ['https://myapp.com', 'https://www.myapp.com'],
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,  // Cookie を送受信する場合
  maxAge: 86400,       // プリフライトのキャッシュ(秒)
}));

// 動的にオリジンを許可
app.use(cors({
  origin: (origin, callback) => {
    const allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
}));

Hono

import { Hono } from 'hono';
import { cors } from 'hono/cors';

const app = new Hono();

app.use('*', cors({
  origin: ['https://myapp.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
  maxAge: 86400,
}));

Next.js API Routes

// app/api/users/route.ts
export async function GET(request: Request) {
  const data = await getUsers();

  return Response.json(data, {
    headers: {
      'Access-Control-Allow-Origin': 'https://myapp.com',
      'Access-Control-Allow-Methods': 'GET, POST',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

export async function OPTIONS() {
  return new Response(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': 'https://myapp.com',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': '86400',
    },
  });
}

Nginx

server {
    location /api/ {
        # CORSヘッダーを追加
        add_header 'Access-Control-Allow-Origin' 'https://myapp.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        add_header 'Access-Control-Max-Age' '86400' always;

        # プリフライトリクエスト
        if ($request_method = 'OPTIONS') {
            return 204;
        }

        proxy_pass http://backend;
    }
}

開発環境での解決方法

Vite のプロキシ設定

開発環境ではプロキシを使ってCORSを回避できます。

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
});
// フロントエンドのコード
// /api/users → https://api.example.com/users にプロキシされる
fetch('/api/users');

Next.js の Rewrites

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

credentials(Cookie)を使う場合

CookieをCORSリクエストで送受信する場合、追加の設定が必要です。

バックエンド

app.use(cors({
  origin: 'https://myapp.com',  // * は使えない!
  credentials: true,
}));

フロントエンド

// fetch
fetch('https://api.myapp.com/profile', {
  credentials: 'include',  // Cookie を送信
});

// axios
axios.get('https://api.myapp.com/profile', {
  withCredentials: true,
});

重要: credentials: true の場合、Access-Control-Allow-Origin* は使えません。具体的なオリジンを指定する必要があります。

よくある間違い

1. Access-Control-Allow-Origin: * と credentials の併用

NG: Access-Control-Allow-Origin: *
    credentials: true

OK: Access-Control-Allow-Origin: https://myapp.com
    credentials: true

2. OPTIONSメソッドの未対応

// NG: POSTだけ対応
app.post('/api/data', handler);

// OK: OPTIONSも対応
app.options('/api/data', cors());
app.post('/api/data', handler);

// 最善: ミドルウェアで全ルートに適用
app.use(cors());

3. CORSをフロントエンドで解決しようとする

CORSはブラウザのセキュリティ機能なので、バックエンド側で設定する必要があります。フロントエンドだけでは解決できません。

セキュリティ上の注意点

Access-Control-Allow-Origin: * のリスク

// 危険: すべてのオリジンを許可
app.use(cors({ origin: '*' }));

// 安全: 特定のオリジンだけ許可
app.use(cors({ origin: ['https://myapp.com'] }));

公開APIでない限り、* は使わないようにしましょう。

リクエストのOriginヘッダーを信用しすぎない

CORSはブラウザの機能です。curl等のツールからはCORSに関係なくリクエストが送れます。CORSだけに頼らず、認証・認可も適切に実装しましょう。

デバッグ方法

ブラウザの開発者ツール

  1. Network タブを開く
  2. 失敗したリクエストを選択
  3. Headers タブで Response Headers を確認
  4. Access-Control-Allow-Origin が正しく設定されているか確認

curl でプリフライトをテスト

curl -X OPTIONS https://api.example.com/data \
  -H "Origin: https://myapp.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v

まとめ

症状解決策
No 'Access-Control-Allow-Origin'サーバーにCORSヘッダーを追加
プリフライトが失敗OPTIONSメソッドに対応
Cookie が送信されないcredentials: true + 具体的なオリジン
開発環境でCORSエラープロキシ設定を使う

CORSは「フロントエンド開発者なら誰もが一度はハマる」定番の問題です。仕組みを理解すれば、適切な対処ができるようになります。


APIのURLパラメータのエンコード・デコードには、AssistyのURLエンコード/デコードツールが便利です。