CORS(Cross-Origin Resource Sharing)

ブラウザのSame-Origin Policy(同一オリジン制約)を、サーバ側の許可で選択的に緩める仕組み。 フロントとバックのドメインが違う時に必ず出てくる。

同一オリジンとは

scheme + host + port がすべて一致すること:

CORS が必要な場面

CORS が要らない場面

シンプルリクエスト

以下を満たすとpreflight 不要でリクエスト送信:

サーバが返すレスポンスにAccess-Control-Allow-Origin があれば、ブラウザが許可。

preflight(事前確認)

上記を満たさない場合(Content-Type: application/json など)は、ブラウザがOPTIONS リクエストを先に送る。

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

→ 204 No Content
   Access-Control-Allow-Origin: https://example.com
   Access-Control-Allow-Methods: POST, GET, OPTIONS
   Access-Control-Allow-Headers: Content-Type, Authorization
   Access-Control-Max-Age: 600

preflight が成功してから本リクエストが飛ぶ。Max-Age でキャッシュ可能。

サーバ側(典型的な許可レスポンス)

クライアント側

Cookie(や Authorization ヘッダー)を別オリジンに送るには credentials 必須:

await fetch("https://api.example.com/me", {
  credentials: "include",       // 別オリジンに Cookie 送る
})

この時、サーバ側は:

サーバ側の実装例

Express

import cors from "cors"

app.use(cors({
  origin: "https://example.com",
  credentials: true,
  methods: ["GET", "POST", "PUT", "DELETE"],
}))

// 動的オリジン
app.use(cors({
  origin: (origin, cb) => {
    const allowed = ["https://example.com", "https://staging.example.com"]
    if (!origin || allowed.includes(origin)) cb(null, true)
    else cb(new Error("Not allowed"))
  },
  credentials: true,
}))

Hono

import { cors } from "hono/cors"

app.use("/api/*", cors({
  origin: ["https://example.com"],
  credentials: true,
}))

素の Node.js / Cloudflare Workers

function withCors(req, response) {
  const origin = req.headers.get("origin")
  const allowed = ["https://example.com"]
  if (origin && allowed.includes(origin)) {
    response.headers.set("Access-Control-Allow-Origin", origin)
    response.headers.set("Vary", "Origin")
    response.headers.set("Access-Control-Allow-Credentials", "true")
  }
  return response
}

// preflight を最初に処理
if (req.method === "OPTIONS") {
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": req.headers.get("origin") ?? "",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      "Access-Control-Max-Age": "600",
    },
  })
}

典型的なエラーメッセージ

"No 'Access-Control-Allow-Origin' header"

サーバが CORS ヘッダーを返していない。サーバ側で許可設定。

"Origin is not allowed"

許可リストに無いオリジン。サーバ側のホワイトリストに追加。

"Credentials flag is true, but Access-Control-Allow-Origin is '*'"

credentials: include + Allow-Origin: * はNG。具体的オリジンを指定。

"Method not allowed by Access-Control-Allow-Methods"

preflight でそのメソッドが許可されていない。

"Header not allowed by Access-Control-Allow-Headers"

カスタムヘッダーが許可されていない。

opaque レスポンス

mode: "no-cors"<img> の crossorigin 無しの場合、 レスポンスの中身を JS から読めない(type="opaque")。 SW でキャッシュは可能だが内容にアクセス不可。

preflight を回避する

パフォーマンスのために避けたい場合:

同一オリジンに見せる(プロキシ)

フロントexample.com から API api.example.com を叩くより、 example.com/api/* にリバースプロキシで転送すればCORS 不要

Vite の例
// vite.config.js
export default {
  server: {
    proxy: {
      "/api": {
        target: "http://localhost:8787",
        changeOrigin: true,
      },
    },
  },
}

セキュリティの観点

WebSocket と CORS

WebSocket にはCORS の概念がない。代わりに Origin ヘッダーを サーバ側で見て許可/拒否する。

調査の手順

✓ ブラウザの DevTools → Network → 該当リクエスト
OPTIONS リクエストが先に飛んでいるか確認
✓ レスポンスヘッダーに Access-Control-Allow-* が揃っているか
credentials: include + 具体的オリジンが噛み合っているか
サーバ側のログで実際に届いているか